diff --git a/AdminInterface/www/admin.php b/AdminInterface/www/admin.php index aa8c6c6d..072a261c 100644 --- a/AdminInterface/www/admin.php +++ b/AdminInterface/www/admin.php @@ -1,46 +1,113 @@ WARNING: Please upload a .zip-File!"; + $CHANGE_TXT=$CHANGE_TXT."
  • WARNING: Please upload a .zip-File! (only A-Z, 0-9, ._- allowed in filename)
  • "; $change=0; } else { if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) { - $string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true); - $data = json_decode($string, true); + // ZIP-Slip / arbitrary-path defence. backup.php and + // fullbackup.php only ever pack files under three roots — + // reject any zip entry that escapes them. Without this, + // `unzip -o -a -d /` happily writes anywhere on disk. + $allowedPrefixes = [ + 'home/dietpi/MuPiBox/media/', + 'etc/mupibox/mupiboxconfig.json', + 'home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/data.json', + ]; + $zip = new ZipArchive(); + $zipOk = false; + $badEntry = ''; + if ($zip->open($target_file) === true) { + $zipOk = true; + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + // Normalise: strip leading slash, forbid `..` + $norm = ltrim($entry, '/'); + if (strpos($norm, '..') !== false) { + $zipOk = false; + $badEntry = $entry; + break; + } + $matched = false; + foreach ($allowedPrefixes as $p) { + if (strpos($norm, $p) === 0) { $matched = true; break; } + } + if (!$matched) { + $zipOk = false; + $badEntry = $entry; + break; + } + } + $zip->close(); + } + if (!$zipOk) { + exec("sudo rm " . escapeshellarg($target_file)); + $CHANGE_TXT=$CHANGE_TXT."
  • ERROR: Backup rejected (entry outside whitelist: ".htmlspecialchars($badEntry).")
  • "; + $change=0; + } else { + // M5: external command above just mutated the config -- force fresh re-read. + $data = mupibox_config(true); $old_version = $data["mupibox"]["version"]; - $command = "sudo unzip -o -a '".$target_file."' -d / >> /tmp/restore.log"; - #$command = "sudo su - -c \"unzip -o -a '".$target_file."' -d / >> /tmp/restore.log && sleep 1\""; - #$command = "sudo su - -c 'tar xvzf ".$target_file." >> /tmp/restore.log'"; + $command = "sudo unzip -o -a " . escapeshellarg($target_file) . " -d / >> /tmp/restore.log"; exec($command, $output, $result ); exec("sudo chown root:www-data /etc/mupibox/mupiboxconfig.json"); exec("sudo chmod 644 /etc/mupibox/mupiboxconfig.json"); @@ -50,22 +117,23 @@ function write_json($data) $command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/conf_update.sh | sudo bash"; exec($command, $output, $result ); - $string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true); - $data = json_decode($string, true); + // M5: external command above just mutated the config -- force fresh re-read. + $data = mupibox_config(true); $data["mupibox"]["version"] = $old_version; write_json($data); - $command = "sudo /boot/dietpi/func/change_hostname " . $data["mupibox"]["host"]; + $command = "sudo /boot/dietpi/func/change_hostname " . escapeshellarg($data["mupibox"]["host"]); $change_hostname = exec($command, $output, $change_hostname ); $command = "sudo su dietpi -c '/usr/local/bin/mupibox/./set_hostname.sh'"; exec($command); - - $command = "sudo rm '".$target_file."'"; + + $command = "sudo rm " . escapeshellarg($target_file); exec($command, $output, $result ); $change=99; $CHANGE_TXT=$CHANGE_TXT."
  • Backup-File restored! The MuPiBox will reboot now!
  • "; $reboot=1; } + } else { $CHANGE_TXT=$CHANGE_TXT."
  • ERROR: Error on uploading Backup-File!
  • "; @@ -136,8 +204,8 @@ function write_json($data) { $command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/start_mupibox_update.sh | sudo bash -s -- stable"; exec($command, $output, $result ); - $string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true); - $data = json_decode($string, true); + // M5: external command above just mutated the config -- force fresh re-read. + $data = mupibox_config(true); $change=3; $reboot=1; $CHANGE_TXT=$CHANGE_TXT."
  • Update complete to Version ".$data["mupibox"]["version"]."
  • "; @@ -146,8 +214,8 @@ function write_json($data) { $command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/start_mupibox_update.sh | sudo bash -s -- beta"; exec($command, $output, $result ); - $string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true); - $data = json_decode($string, true); + // M5: external command above just mutated the config -- force fresh re-read. + $data = mupibox_config(true); $change=1; $reboot=1; $data["mupibox"]["version"]=$data["mupibox"]["version"]." BETA"; @@ -158,8 +226,8 @@ function write_json($data) $command = "cd; curl -L https://raw.githubusercontent.com/splitti/MuPiBox/main/update/start_mupibox_update.sh | sudo bash -s -- dev"; exec($command, $output, $result ); - $string = file_get_contents('/etc/mupibox/mupiboxconfig.json', true); - $data = json_decode($string, true); + // M5: external command above just mutated the config -- force fresh re-read. + $data = mupibox_config(true); $change=1; $reboot=1; $data["mupibox"]["version"]=$data["mupibox"]["version"]." DEVELOPMENT"; @@ -272,9 +340,7 @@ function write_json($data) } if( $change == 2 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); } if( $change == 3 ) diff --git a/AdminInterface/www/includes/header.php b/AdminInterface/www/includes/header.php index 65915a8b..ae6893b3 100644 --- a/AdminInterface/www/includes/header.php +++ b/AdminInterface/www/includes/header.php @@ -1,6 +1,19 @@ diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php index 7b1441fb..a479a074 100644 --- a/AdminInterface/www/mupi.php +++ b/AdminInterface/www/mupi.php @@ -554,19 +554,14 @@ } if( $change == 1 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); exec("sudo rm -R " . $data["chromium"]["cachepath"]); - exec("sudo chmod 755 /etc/mupibox/mupiboxconfig.json"); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh"); } if( $change == 2 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); } diff --git a/AdminInterface/www/mupihat.php b/AdminInterface/www/mupihat.php index d2c22949..d539bb03 100644 --- a/AdminInterface/www/mupihat.php +++ b/AdminInterface/www/mupihat.php @@ -66,39 +66,28 @@ if( $change == 1 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo chmod 755 /etc/mupibox/mupiboxconfig.json"); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh"); } if( $change == 2 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); } if( $change == 3 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); $command="sudo su dietpi -c 'pm2 restart spotify-control'"; exec($command); } if( $change == 4 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); } if( $change == 5 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo service mupi_hat restart"); } $CHANGE_TXT=$CHANGE_TXT.""; diff --git a/AdminInterface/www/smart.php b/AdminInterface/www/smart.php index ff5ddd21..84198654 100644 --- a/AdminInterface/www/smart.php +++ b/AdminInterface/www/smart.php @@ -181,33 +181,24 @@ if( $change == 1 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo chmod 755 /etc/mupibox/mupiboxconfig.json"); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); exec("sudo -i -u dietpi /usr/local/bin/mupibox/./restart_kiosk.sh"); } if( $change == 2 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); } if( $change == 3 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); $command="sudo su dietpi -c 'pm2 restart spotify-control'"; exec($command); } if( $change == 4 ) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); } $CHANGE_TXT=$CHANGE_TXT.""; ?> diff --git a/AdminInterface/www/spotify.php b/AdminInterface/www/spotify.php index 3cf50bdb..3dc9b1e4 100644 --- a/AdminInterface/www/spotify.php +++ b/AdminInterface/www/spotify.php @@ -18,14 +18,32 @@ } if ($_GET['code']) { - $command = "curl -d client_id=" . $data["spotify"]["clientId"] . " -d client_secret=" . $data["spotify"]["clientSecret"] . " -d grant_type=authorization_code -d code=" . $_GET['code'] . " -d redirect_uri=" . $REDIRECT_URI . " https://accounts.spotify.com/api/token"; + // All four interpolated values reach the shell. clientId / clientSecret + // come from mupiboxconfig.json (admin-controlled) but $_GET['code'] is + // echoed back from Spotify's redirect — an attacker could craft a + // redirect URL with `code=$(rm -rf /)` or backticks. escapeshellarg() + // each value so the shell sees them as a single quoted token. + $command = "curl -d client_id=" . escapeshellarg($data["spotify"]["clientId"]) + . " -d client_secret=" . escapeshellarg($data["spotify"]["clientSecret"]) + . " -d grant_type=authorization_code" + . " -d code=" . escapeshellarg($_GET['code']) + . " -d redirect_uri=" . escapeshellarg($REDIRECT_URI) + . " https://accounts.spotify.com/api/token"; exec($command, $Tokenoutput, $result); $tokendata = json_decode($Tokenoutput[0], true); $data["spotify"]["accessToken"] = $tokendata["access_token"]; $data["spotify"]["refreshToken"] = $tokendata["refresh_token"]; - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + // Re-authorising via OAuth implies the user wants Spotify ON. Without this + // flip, an admin who turned `active` off (e.g. while debugging) and then + // re-ran the Connect-Spotify flow would still have Spotify hidden in the + // frontend — and might assume the new tokens are also broken. Only flip if + // we actually got both tokens back; the OAuth call could have failed and + // returned an error blob, in which case enabling Spotify would resurrect + // the loading-spinner-stuck state. + if (!empty($tokendata["access_token"]) && !empty($tokendata["refresh_token"])) { + $data["spotify"]["active"] = true; + } + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); exec("sudo rm {$data['spotify']['cachepath']}/credentials.json"); exec("sudo /usr/local/bin/mupibox/./spotify_restart.sh"); @@ -75,9 +93,7 @@ } if ($change) { - $json_object = json_encode($data); - $save_rc = file_put_contents('/tmp/.mupiboxconfig.json', $json_object); - exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json"); + save_mupiboxconfig($data); exec("sudo /usr/local/bin/mupibox/./setting_update.sh"); } diff --git a/config/templates/crontab.template b/config/templates/crontab.template index 495fda04..aa216d78 100644 --- a/config/templates/crontab.template +++ b/config/templates/crontab.template @@ -23,9 +23,11 @@ # m h dom mon dow command #@reboot /usr/local/bin/mupibox/./get_network.sh #@reboot sleep 30; /usr/local/bin/mupibox/./get_network.sh +# M7: dropped from 4 invocations/minute to 1. The RRD database serves +# cputemp/cpuusage/RAM graphs that the user looks at via the Admin-UI on +# the order of seconds-to-minutes, not 15s resolution. Each invocation +# forks rrdtool 3× under sudo (~17k sudo/day at 4/min) -- the audit.log +# was full of these, and the baseline CPU time was non-trivial on the Pi. * * * * * /usr/local/bin/mupibox/./save_rrd.sh -* * * * * sleep 15; /usr/local/bin/mupibox/./save_rrd.sh -* * * * * sleep 30; /usr/local/bin/mupibox/./save_rrd.sh -* * * * * sleep 45; /usr/local/bin/mupibox/./save_rrd.sh * * * * * /usr/local/bin/mupibox/./get_network.sh * * * * * sleep 30; /usr/local/bin/mupibox/./get_network.sh diff --git a/scripts/mupibox/get_monitor.sh b/scripts/mupibox/get_monitor.sh index 19728868..96422676 100644 --- a/scripts/mupibox/get_monitor.sh +++ b/scripts/mupibox/get_monitor.sh @@ -5,34 +5,66 @@ MONITOR_FILE="/home/dietpi/.mupibox/Sonos-Kids-Controller-master/server/config/monitor.json" minimumsize=18 +# M7: track the last persisted state in-process so we only write monitor.json +# when the polled state actually changed. Previously the script did a full +# jq + mv cycle every second regardless -- ~86k file rewrites/day, all of +# them identical content. Skip-if-unchanged drops that to a handful per day. +# Plus the polling cadence went from 1s to 5s -- the kid never notices a +# 4-second delay before the touch is re-enabled after the screen blanks. +LAST_STATE="" + +write_state() { + local NEW_STATE="$1" + if [ "${NEW_STATE}" = "${LAST_STATE}" ]; then + return + fi + local _TMP="${MONITOR_FILE}.tmp.$$" + if [ -f "${MONITOR_FILE}" ]; then + /usr/bin/jq --arg v "${NEW_STATE}" '.monitor = $v' "${MONITOR_FILE}" > "${_TMP}" \ + && mv "${_TMP}" "${MONITOR_FILE}" \ + || rm -f "${_TMP}" + else + /usr/bin/jq -n --arg v "${NEW_STATE}" '.monitor = $v' > "${_TMP}" \ + && mv "${_TMP}" "${MONITOR_FILE}" \ + || rm -f "${_TMP}" + fi + LAST_STATE="${NEW_STATE}" +} + +seed_file() { + # HIGH-14 (Phase-3) + Phase-5 follow-up: drop the sudo -- + # MONITOR_FILE lives under /home/dietpi/.../config/ and the + # script runs as dietpi, so direct write works. + rm -f "${MONITOR_FILE}" + echo -n "{}" > "${MONITOR_FILE}" + LAST_STATE="" # force write_state to write the next polled value +} + while true do - actualsize=$(wc -c <"${MONITOR_FILE}") - - if [ ! -f ${MONITOR_FILE} ]; then - sudo echo -n "{}" ${MONITOR_FILE} - sudo chown dietpi:dietpi ${MONITOR_FILE} - /usr/bin/cat <<< $(/usr/bin/jq -n --arg v "On" '.monitor = $v' ${MONITOR_FILE}) > ${MONITOR_FILE} - elif [ $actualsize -le $minimumsize ]; then - sudo rm ${MONITOR_FILE} - sudo echo -n "{}" ${MONITOR_FILE} - sudo chown dietpi:dietpi ${MONITOR_FILE} - /usr/bin/cat <<< $(/usr/bin/jq -n --arg v "On" '.monitor = $v' ${MONITOR_FILE}) > ${MONITOR_FILE} + actualsize=$(wc -c <"${MONITOR_FILE}" 2>/dev/null || echo 0) + + if [ ! -f "${MONITOR_FILE}" ]; then + seed_file + write_state "On" + elif [ "$actualsize" -le "$minimumsize" ]; then + seed_file + write_state "On" else MONITOR=$(sudo -H -u root bash -c "vcgencmd display_power") MONITOR=(${MONITOR##*=}) POWER=-1 - if [ ${MONITOR} == "-1" ]; then - POWER=$(cat /sys/class/backlight/*/bl_power) + if [ "${MONITOR}" = "-1" ]; then + POWER=$(cat /sys/class/backlight/*/bl_power 2>/dev/null || echo -1) fi - if [ ${MONITOR} == "0" ] || [ ${POWER} == "4" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "Off" '.monitor = $v' ${MONITOR_FILE}) > ${MONITOR_FILE} - elif [ ${MONITOR} == "1" ] || [ ${POWER} == "0" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "On" '.monitor = $v' ${MONITOR_FILE}) > ${MONITOR_FILE} + if [ "${MONITOR}" = "0" ] || [ "${POWER}" = "4" ]; then + write_state "Off" + elif [ "${MONITOR}" = "1" ] || [ "${POWER}" = "0" ]; then + write_state "On" fi fi - sleep 1 + sleep 5 done diff --git a/scripts/mupihat/mupihat.py b/scripts/mupihat/mupihat.py index 07d6e568..77f8aa97 100644 --- a/scripts/mupihat/mupihat.py +++ b/scripts/mupihat/mupihat.py @@ -35,13 +35,19 @@ import argparse from datetime import datetime from flask import Flask, render_template, jsonify -from threading import Thread +from threading import Thread, Lock from mupihat_bq25792 import bq25792 app = Flask(__name__) # Global variables hat = None +# AR5-5: Flask is threaded by default — without serialising access to the +# shared BQ25792 driver, the periodic_json_dump background thread and the +# Flask request workers can interleave their I2C transactions on the same +# smbus2.SMBus handle, producing "Remote I/O error" (errno 121) on the +# Pi's i2c bus. All hat.* calls below run under this lock. +i2c_lock = Lock() log_flag = False json_flag = False json_file = "/tmp/mupihat.json" @@ -84,7 +90,9 @@ def log_register_values(): def index(): """Flask route to display register values.""" try: - return render_template("index.html", registers=hat.to_json_registers()) + with i2c_lock: + registers = hat.to_json_registers() + return render_template("index.html", registers=registers) except Exception as e: return f"Error reading registers: {str(e)}", 500 @@ -92,8 +100,10 @@ def index(): @app.route("/api/registers") def api_registers(): """Flask API endpoint to return register values as JSON.""" - try: - return jsonify(hat.to_json_registers()) + try: + with i2c_lock: + registers = hat.to_json_registers() + return jsonify(registers) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -102,19 +112,36 @@ def periodic_json_dump(): """Periodically writes the register values to a JSON file.""" global json_flag, json_file while True: - hat.watchdog_reset() - time.sleep(0.1) # Allow time for the watchdog reset - hat.read_all_register() - time.sleep(1) # Allow time for the registers to be updated - if json_flag: + # AR5-5: serialise I2C work via i2c_lock so Flask request workers + # don't interleave bus transactions with this thread. Sleeps stay + # OUTSIDE the lock so request workers can fit between phases. + snapshot = None + try: + with i2c_lock: + hat.watchdog_reset() + time.sleep(0.1) # Allow time for the watchdog reset + with i2c_lock: + hat.read_all_register() + # Phase-12: append the current VBAT to the smoothing ring so + # battery_percent_granular() sees a moving-average of the + # last ~32s instead of a single sample. Cheap, done inside + # the lock so we use the value the bulk-read just refreshed. + hat.record_vbat_sample(hat.read_Vbat()) + time.sleep(1) # Allow time for the registers to be updated + with i2c_lock: + if json_flag: + snapshot = hat.to_json() + if log_flag: + log_register_values() + except Exception as e: + logging.error("periodic_json_dump I2C cycle failed: %s", str(e)) + if snapshot is not None: try: with open(json_file, "w") as outfile: - json.dump(hat.to_json(), outfile, indent=4) + json.dump(snapshot, outfile, indent=4) except Exception as e: logging.error("Failed to write JSON dump: %s", str(e)) - if log_flag: - log_register_values() - time.sleep(3.9) # Run every 4 seconds + time.sleep(3.9) # Run every ~4 seconds def parse_arguments(): @@ -172,9 +199,16 @@ def main(): json_thread = Thread(target=periodic_json_dump, daemon=True) json_thread.start() - # Flask web server + # AR5-20: bind the debug Flask server to loopback only. No consumer on + # the box hits port 5000 — all consumers (frontend, admin-ui, server.ts, + # telegram_*.py, mqtt.py, .bashrc, fan_control.py) read /tmp/mupihat.json + # directly. Port 5000 is the BQ25792 register inspector at "/" and + # "/api/registers". With 0.0.0.0 the raw charger registers were + # unauthenticated for every client on the LAN; harmless on a home + # WLAN, problematic on guest/school networks. For remote debugging + # use `ssh -L 5000:127.0.0.1:5000 mupibox`. try: - app.run(host="0.0.0.0", port=5000, debug=False) + app.run(host="127.0.0.1", port=5000, debug=False) except KeyboardInterrupt: print("MuPiHAT stopped by Keyboard Interrupt") sys.exit(0) diff --git a/scripts/mupihat/mupihat_bq25792.py b/scripts/mupihat/mupihat_bq25792.py index ab1182f2..e95d13d1 100644 --- a/scripts/mupihat/mupihat_bq25792.py +++ b/scripts/mupihat/mupihat_bq25792.py @@ -139,6 +139,13 @@ def __init__(self, i2c_device=1, i2c_addr=0x6b, busWS_ms=10, exit_on_error = Fal 'th_warning': 7000, 'th_shutdown': 6800 } self._exit_on_error = exit_on_error + # Phase-12: VBAT history for granular-percent smoothing (5%-step display). + # Voltage swings 30-100 mV under load (Bass-pumping, Display wake); + # without smoothing the 5%-display would visibly flap. 8 samples at + # 4s cycle = ~32s window, well below the timescale of real SoC + # change but long enough to absorb the load-sag transients. + self._vbat_history: list[int] = [] + self._vbat_history_max = 8 self.i2c_device = i2c_device self.i2c_addr = i2c_addr self.busWS_ms = busWS_ms @@ -259,11 +266,17 @@ def battery_soc(self): VBat = self.read_Vbat() - if VBat > v_100 : Bat_SOC = "100%" - elif VBat > v_75 : Bat_SOC = "75%" - elif VBat > v_50 : Bat_SOC = "50%" - elif VBat > v_25 : Bat_SOC = "25%" - elif VBat > v_0 : Bat_SOC = "0%" + # Phase-12 follow-up: changed > to >= so a pack sitting exactly at + # the v_100 threshold (8200 mV for a freshly-finished CV charge on + # the 2S3P pack) renders as "100%" / Battery100.svg instead of + # falling through to "75%" / Battery70.svg. Without this the icon + # bucket disagrees with the new granular Bat_Percent text at the + # exact-voll-Fall (icon: 3/4-strich, text: 100%) which looks broken. + if VBat >= v_100 : Bat_SOC = "100%" + elif VBat >= v_75 : Bat_SOC = "75%" + elif VBat >= v_50 : Bat_SOC = "50%" + elif VBat >= v_25 : Bat_SOC = "25%" + else : Bat_SOC = "0%" if VBat > th_warning : Bat_Stat = 'OK' elif (VBat < th_warning) & (VBat > th_shutdown) : Bat_Stat = 'LOW' @@ -271,6 +284,97 @@ def battery_soc(self): return Bat_SOC, Bat_Stat + def record_vbat_sample(self, vbat_mv: int): + ''' + Phase-12: append a VBAT reading to the smoothing ring buffer. + Called from mupihat.py's periodic_json_dump after read_all_register(). + Drops the oldest sample once the buffer is full so the average always + reflects the most-recent ~32s window. + ''' + if vbat_mv is None or vbat_mv <= 0: + return # ignore invalid reads (post-reopen, etc.) + self._vbat_history.append(int(vbat_mv)) + if len(self._vbat_history) > self._vbat_history_max: + self._vbat_history.pop(0) + + def smoothed_vbat(self) -> int: + ''' + Return the moving-average VBAT over the recorded samples. Falls back + to the current single read if the buffer is empty (cold start). + ''' + if not self._vbat_history: + return self.read_Vbat() + return sum(self._vbat_history) // len(self._vbat_history) + + def battery_percent_granular(self): + ''' + Phase-12: piecewise-linear interpolation between the 5 thresholds for + a 0-100 % SoC value, rounded to 5 % steps. Returns a tuple: + (percent: int, source: str) + where source is "charging" while CC/Taper charge is running (the + voltage-based estimate is systematically optimistic during active + charge because the pack is held above its rest curve), otherwise + "voltage". The frontend can show a ⚡ next to the number when + source == "charging" to signal the disclaimer. + + Uses smoothed_vbat() to absorb sub-second voltage swings -- without + smoothing the 5%-display would flap visibly under audio load. + + Why 5% steps and not 1%: the discharge curve in the plateau region + (~3.7-3.9 V/cell) gives ~2-3 mV per 1 %, smaller than the BQ25792 + ADC's noise floor (~10 mV) and far smaller than load-sag transients + (30-60 mV under typical box load). 1 % steps would zap-zap-zap + constantly; 5 % steps map cleanly to ~90 mV per step which IS + resolvable above the noise. + ''' + try: + v_100 = int(self.battery_conf['v_100']) + v_75 = int(self.battery_conf['v_75']) + v_50 = int(self.battery_conf['v_50']) + v_25 = int(self.battery_conf['v_25']) + v_0 = int(self.battery_conf['v_0']) + except (KeyError, ValueError, TypeError): + return (0, "voltage") # config not loaded yet — safe default + + # USB-C mode profile has v_*=1 (sentinel for "no battery"). Don't + # report any percent in that case — the icon will fall back to its + # plug-symbol mode. + if v_100 <= 10: + return (0, "voltage") + + v = self.smoothed_vbat() + + # Piecewise linear: each segment maps [v_lower, v_upper] to a + # 25-percentage-point range linearly. + if v >= v_100: + pct = 100.0 + elif v >= v_75: + pct = 75.0 + 25.0 * (v - v_75) / max(1, v_100 - v_75) + elif v >= v_50: + pct = 50.0 + 25.0 * (v - v_50) / max(1, v_75 - v_50) + elif v >= v_25: + pct = 25.0 + 25.0 * (v - v_25) / max(1, v_50 - v_25) + elif v >= v_0: + pct = 0.0 + 25.0 * (v - v_0) / max(1, v_25 - v_0) + else: + pct = 0.0 + + # Round to 5 % steps -- see docstring above for why not 1 %. + rounded = int(round(pct / 5.0) * 5) + rounded = max(0, min(100, rounded)) + + # During active charging the voltage-based estimate is too optimistic + # (pack held above rest curve by CC current). Flag the source so the + # frontend can render a charging indicator (⚡) instead of treating + # the number as a settled SoC reading. + try: + _, chg_str = self.read_ChargerStatus() + source = "charging" if 'Charge' in chg_str and 'Done' not in chg_str else "voltage" + except Exception: + source = "voltage" + + return (rounded, source) + # BQ25795 Register class BQ25795_REGISTER: def __init__(self, addr, value=0): @@ -330,7 +434,7 @@ def __init__(self, addr=0x1, value=0): self.VREG = self._value * 10 def set (self, value): super().set(value) - self.VRE0G = self._value * 10 + self.VREG = self._value * 10 class REG03_Charge_Current_Limit(BQ25795_REGISTER): #Charge Current Limit During POR, the device reads the resistance tie to PROG pin, to identify the default battery cell count and determine the default power-on battery charging current: 1s and 2s: 3s and 4s: 1A Type : RW Range : 50mA-5000mA Fixed Offset : 0mA Bit Step Size : 10mA @@ -5724,6 +5828,7 @@ def to_json(self): Input Current Limit obtained from ICO or ILIM_HIZ pin setting ''' bat_SOC, bat_Stat = self.battery_soc() + bat_Percent, bat_PercentSource = self.battery_percent_granular() return { 'Charger_Status': self.read_ChargerStatus(), 'Vbat': self.read_Vbat(), @@ -5735,7 +5840,12 @@ def to_json(self): 'Bat_SOC' : bat_SOC, 'Bat_Stat' : bat_Stat, 'Bat_Type' : self.battery_conf['battery_type'], - 'Input_Current_Limit' : self.read_InputCurrentLimit() + 'Input_Current_Limit' : self.read_InputCurrentLimit(), + # Phase-12: granular 5%-step percent + charging-state hint for + # the frontend. Bat_SOC stays for backwards-compat (Telegram bot + # messages, legacy Admin-UI bits). + 'Bat_Percent' : bat_Percent, + 'Bat_PercentSource' : bat_PercentSource, } def to_json_registers(self): diff --git a/src/backend-api/src/server.ts b/src/backend-api/src/server.ts index 51399c7d..a69dcdb3 100644 --- a/src/backend-api/src/server.ts +++ b/src/backend-api/src/server.ts @@ -12,6 +12,7 @@ import { LogRequest, LogResponse } from './models/log.model' import type { MupiboxConfig } from './models/mupibox-config.model' import { ServerConfig } from './models/server.model' import type { SpotifyValidationRequest, SpotifyValidationResponse } from './models/spotify-api.model' +import { CoverCacheService } from './services/cover-cache.service' import { SpotifyApiService } from './services/spotify-api.service' import { SpotifyMediaInfo } from './services/spotify-media-info.service' @@ -50,6 +51,12 @@ readJsonFile(`${configBasePath}/config.json`).then((configFile) => { console.warn('No Spotify configuration found, Spotify API service will not be available') } }) +// Phase-X: SD-backed LRU cache for Spotify cover images. Frontend rewrites +// i.scdn.co URLs to /api/spotify/cover/:imageId so the box serves covers +// from its own SD+RAM after the first miss -- caps Chromium's parallel +// connection limit to one host and saves repeat CDN round-trips. +const coverCacheService = new CoverCacheService(path.join(process.cwd(), 'cache')) + const mupiboxConfigPath = '/etc/mupibox/mupiboxconfig.json' const mupiboxConfigDir = path.dirname(mupiboxConfigPath) const mupiboxConfigFile = path.basename(mupiboxConfigPath) @@ -149,6 +156,25 @@ app.get('/api/resume', (_req, res) => { } }) +// Phase-X cover proxy. Frontend converts every i.scdn.co/image/ URL to +// /api/spotify/cover/; we serve from the SD+RAM cache after first miss. +// imageId is validated as alphanumeric inside CoverCacheService -- any +// crafted path traversal attempt returns 400. +app.get('/api/spotify/cover/:imageId', async (req, res) => { + const buf = await coverCacheService.get(req.params.imageId) + if (!buf) { + res.status(404).type('text/plain').send('cover not available') + return + } + // Long max-age + immutable since Spotify image IDs are content-addressed: + // a given imageId always resolves to the same bytes, so the browser can + // cache aggressively. ETag echoes the imageId for conditional requests. + res.set('Content-Type', 'image/jpeg') + res.set('Cache-Control', 'public, max-age=31536000, immutable') + res.set('ETag', `"${req.params.imageId}"`) + res.send(buf) +}) + app.get('/api/mupihat', (_req, res) => { if (fs.existsSync(mupihat)) { jsonfile.readFile(mupihat, (error, data) => { diff --git a/src/backend-api/src/services/cover-cache.service.ts b/src/backend-api/src/services/cover-cache.service.ts new file mode 100644 index 00000000..1e5945b6 --- /dev/null +++ b/src/backend-api/src/services/cover-cache.service.ts @@ -0,0 +1,217 @@ +import { existsSync, mkdirSync } from 'node:fs' +import fsPromises from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +/** + * SD-backed LRU cache for Spotify cover images. + * + * Phase-7.6 took the artist-click latency from 8-10 s down to ~3 s. + * The remaining ~3 s comes from Spotify-CDN image round-trips: Chromium + * limits parallel connections to i.scdn.co to ~6 (HTTP/1.1), and the + * Pi sees 50-150 ms per cover. With 276 albums for Benjamin Blümchen, + * the cover load is the dominant cost after the API responses are + * cached. + * + * This service serves /api/spotify/cover/:imageId from the box's own + * SD + RAM, with these properties: + * - First request fetches from i.scdn.co/image/, persists to + * /.jpg, returns the bytes. + * - Subsequent requests on the same imageId serve from RAM (hot) or + * SD (warm) -- no CDN round-trip, no Chromium parallelism cap. + * - LRU eviction: at MAX_FILES (2000, ~100-150 MB) prune the oldest + * PRUNE_BATCH (400) by mtime. mem-LRU caps at MEM_CACHE_MAX_BYTES + * (auto-sized to 5% of os.freemem(), max 10 MB). + * - Concurrent same-key fetches de-duplicate via pendingFetches: + * when 200 covers are requested at once, each unique imageId + * still hits i.scdn.co only once. + * - HTTP 404 from i.scdn.co is short-circuited via negativeCache so + * a non-existent image doesn't get re-fetched 6× per page. + * + * SD-Wear: writes happen only on first-miss per imageId. After the + * cache is warm, hits do reads + occasional mtime touches for LRU. + * Reads are free for SD lifetime; writes are the cost. With 2000 + * covers × ~50 KB = ~100 MB ever-written, sub-trivial. + */ +export class CoverCacheService { + private cacheDir: string + private static readonly MAX_FILES = 2000 + private static readonly PRUNE_BATCH = 400 + private static readonly UPSTREAM_TIMEOUT_MS = 5000 + private static readonly NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000 + + private memCache = new Map() + private memCacheBytes = 0 + private memCacheMaxBytes: number + + // De-dup map: when N requests for the same imageId arrive while a + // fetch is in flight, the second through Nth await the same Promise + // rather than firing N concurrent fetches. + private pendingFetches = new Map>() + + // Track recent 404s so we don't re-hammer i.scdn.co for known-missing + // images. Key -> expiry-epoch. + private negativeCache = new Map() + + // Observability (no UI yet). + public stats = { memHits: 0, sdHits: 0, cdnHits: 0, negativeHits: 0, fetchErrors: 0 } + + constructor(baseDir: string) { + this.cacheDir = path.join(baseDir, 'covers') + if (!existsSync(this.cacheDir)) { + mkdirSync(this.cacheDir, { recursive: true }) + } + this.memCacheMaxBytes = Math.max( + 512 * 1024, // 512 KB floor — at least handful of covers on a Pi 3 + Math.min( + 10 * 1024 * 1024, // 10 MB ceiling — even big covers fit comfortably on Pi 4 + Math.floor(os.freemem() * 0.05), + ), + ) + } + + /** + * Validate that the requested ID is a Spotify image identifier. + * Spotify CDN paths after /image/ are alphanumeric; observed lengths + * are 40-60 chars. Reject anything else to prevent path traversal / + * SSRF via crafted URLs. + */ + private static isValidImageId(id: string): boolean { + return /^[A-Za-z0-9]{32,80}$/.test(id) + } + + async get(imageId: string): Promise { + if (!CoverCacheService.isValidImageId(imageId)) return null + + // RAM + const mem = this.memCache.get(imageId) + if (mem !== undefined) { + // Touch LRU position + this.memCache.delete(imageId) + this.memCache.set(imageId, mem) + this.stats.memHits++ + return mem + } + + // Negative cache short-circuit + const negExp = this.negativeCache.get(imageId) + if (negExp !== undefined) { + if (Date.now() < negExp) { + this.stats.negativeHits++ + return null + } + this.negativeCache.delete(imageId) + } + + // SD + const filePath = path.join(this.cacheDir, `${imageId}.jpg`) + try { + const buf = await fsPromises.readFile(filePath) + // Touch mtime asynchronously for LRU-by-mtime ordering, don't wait + const now = new Date() + fsPromises.utimes(filePath, now, now).catch(() => {}) + this.memCachePut(imageId, buf) + this.stats.sdHits++ + return buf + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`cover-cache SD read error for ${imageId}:`, err) + } + } + + // Miss — fetch from i.scdn.co. Dedup concurrent requests. + const inflight = this.pendingFetches.get(imageId) + if (inflight) return inflight + + const fetchPromise = this.fetchFromCdn(imageId, filePath).finally(() => { + this.pendingFetches.delete(imageId) + }) + this.pendingFetches.set(imageId, fetchPromise) + return fetchPromise + } + + private async fetchFromCdn(imageId: string, filePath: string): Promise { + const url = `https://i.scdn.co/image/${imageId}` + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(CoverCacheService.UPSTREAM_TIMEOUT_MS), + }) + if (response.status === 404) { + this.negativeCache.set(imageId, Date.now() + CoverCacheService.NEGATIVE_CACHE_TTL_MS) + return null + } + if (!response.ok) { + console.warn(`cover-cache upstream non-OK for ${imageId}: HTTP ${response.status}`) + this.stats.fetchErrors++ + return null + } + const arrayBuf = await response.arrayBuffer() + const buf = Buffer.from(arrayBuf) + + // Persist to SD (fire-and-forget — the response can already start + // serving from RAM while the write completes). + fsPromises.writeFile(filePath, buf).catch((err) => { + console.error(`cover-cache SD write error for ${imageId}:`, err) + }) + this.memCachePut(imageId, buf) + this.stats.cdnHits++ + // Prune in background -- no need to block the response. + this.pruneIfNeeded().catch(() => {}) + return buf + } catch (err) { + console.error(`cover-cache fetch failed for ${imageId}:`, err) + this.stats.fetchErrors++ + return null + } + } + + private memCachePut(key: string, buf: Buffer): void { + if (this.memCache.has(key)) { + const old = this.memCache.get(key) as Buffer + this.memCacheBytes -= old.length + this.memCache.delete(key) + } + this.memCache.set(key, buf) + this.memCacheBytes += buf.length + + while (this.memCacheBytes > this.memCacheMaxBytes) { + const oldest = this.memCache.keys().next().value + if (oldest === undefined) break + const oldBuf = this.memCache.get(oldest) as Buffer + this.memCacheBytes -= oldBuf.length + this.memCache.delete(oldest) + } + } + + private async pruneIfNeeded(): Promise { + try { + const files = await fsPromises.readdir(this.cacheDir) + if (files.length <= CoverCacheService.MAX_FILES) return + + const stats = await Promise.all( + files.map(async (name) => { + try { + const s = await fsPromises.stat(path.join(this.cacheDir, name)) + return { name, mtime: s.mtimeMs } + } catch { + return null + } + }), + ) + const valid = stats.filter((s): s is { name: string; mtime: number } => s !== null) + valid.sort((a, b) => a.mtime - b.mtime) + const victims = valid.slice(0, CoverCacheService.PRUNE_BATCH) + + for (const v of victims) { + try { + await fsPromises.unlink(path.join(this.cacheDir, v.name)) + } catch { + // ignore — file may have been pruned in parallel + } + } + console.info(`🗑️ Cover-cache pruned: removed ${victims.length} of ${files.length}`) + } catch (err) { + console.error('cover-cache prune error:', err) + } + } +} diff --git a/src/backend-api/src/services/spotify-api.service.ts b/src/backend-api/src/services/spotify-api.service.ts index fa1be57b..ec485893 100644 --- a/src/backend-api/src/services/spotify-api.service.ts +++ b/src/backend-api/src/services/spotify-api.service.ts @@ -1,4 +1,7 @@ +import { createHash } from 'node:crypto' import fs from 'node:fs' +import fsPromises from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import { SpotifyApi } from '@spotify/web-api-ts-sdk' import type { ServerConfig } from '../models/server.model' @@ -25,6 +28,32 @@ export class SpotifyApiService { search: 6 * 60 * 60 * 1000, // 6 hours for Search Results } + // H7: Hard upper bound on cache-file count. With unbounded user-controlled + // pagination cache-keys could fill the SD-card. Limits: at 1000 files the + // pruner runs and evicts the oldest 200 by mtime. + private static readonly CACHE_MAX_FILES = 1000 + private static readonly CACHE_PRUNE_BATCH = 200 + + // M4: In-memory LRU layer sitting in front of the SD-backed JSON cache. + // getFromCache used to cost 3 sync syscalls (existsSync + statSync + + // readFileSync + JSON.parse) on every hit -- 5-30 ms of event-loop block + // on SD per call, hundreds of calls during a single artist click. + // The Map preserves insertion order; on every get/set we delete-and- + // reinsert to keep the most-recent at the tail, so eviction (delete first + // key) drops the least-recently-used. memCacheCap is auto-sized to 5% of + // available RAM (capped at 500 entries) so the same code stays safe on a + // Pi 3 (~100 MB free -> ~50 entries) and a Pi 4 (~3 GB free -> 500). + private memCache = new Map() + private memCacheCap: number = Math.max( + 50, + Math.min( + 500, + Math.floor((os.freemem() * 0.05) / (10 * 1024)), // estimate ~10KB per cached entry + ), + ) + private memHits = 0 + private memMisses = 0 + // Rate limiting private lastRequestTime = 0 private readonly minRequestInterval = 100 // 100ms between requests @@ -71,8 +100,64 @@ export class SpotifyApiService { } } + // H7: limit/offset come from user-controlled query string. The SDK call + // already gets `Math.min(limit, 10)` later, but the cache-key was built + // with the raw value — `?limit=999999` would produce a unique cache file + // for every request and the cache directory would grow without bound. + // Normalise once here so the cache-key sees the clamped form. + private normalizePagination(limit: number, offset: number): { limit: number; offset: number } { + const l = Math.floor(Number(limit)) + const o = Math.floor(Number(offset)) + return { + limit: Number.isFinite(l) ? Math.min(Math.max(l, 1), 50) : 10, + offset: Number.isFinite(o) ? Math.max(o, 0) : 0, + } + } + + // H7: Lightweight LRU eviction. Called from saveToCache; runs only when + // the directory exceeds CACHE_MAX_FILES. We sort by mtime (oldest first) + // and unlink CACHE_PRUNE_BATCH files. Cheap enough to do inline. + private pruneCacheIfNeeded(): void { + try { + const files = fs.readdirSync(this.cacheDir) + if (files.length <= SpotifyApiService.CACHE_MAX_FILES) return + const stats = files + .map((name) => { + try { + return { name, mtime: fs.statSync(path.join(this.cacheDir, name)).mtimeMs } + } catch { + return null + } + }) + .filter((x): x is { name: string; mtime: number } => x !== null) + .sort((a, b) => a.mtime - b.mtime) + const victims = stats.slice(0, SpotifyApiService.CACHE_PRUNE_BATCH) + for (const v of victims) { + try { + fs.unlinkSync(path.join(this.cacheDir, v.name)) + } catch { + // ignore unlink errors — file may have been pruned in parallel + } + } + console.info(`🗑️ Cache pruned: removed ${victims.length} oldest entries (was ${files.length})`) + } catch (error) { + console.error('Error pruning cache:', error) + } + } + + // MED-5: cacheKey is concatenated from user-controlled input — search + // queries, playlist IDs, etc. The previous implementation just appended + // `.json` and joined with cacheDir, so a search for `../../etc/passwd_x` + // would produce a path that path.join could resolve outside the cache + // directory (and fs.writeFile would happily write there as the dietpi + // user). Hash the user-controlled portion via SHA-256; the resulting + // 64-char hex is filesystem-safe and impossible to traverse with. + // Keep getCacheExpiryForKey() reading the original cacheKey since it + // only inspects the prefix — the on-disk filename uses the hashed + // form via this helper. private getCacheFilePath(cacheKey: string): string { - return path.join(this.cacheDir, `${cacheKey}.json`) + const hashed = createHash('sha256').update(cacheKey).digest('hex') + return path.join(this.cacheDir, `${hashed}.json`) } private getCacheExpiryForKey(cacheKey: string): number { @@ -97,16 +182,52 @@ export class SpotifyApiService { return this.cacheExpiry.dynamic // Fallback } + // M4: LRU touch -- delete then re-insert so the entry moves to the tail. + // Map iteration order is insertion order in V8, so the first key is the + // least-recently-used and evictable. + private memCacheTouch(cacheKey: string, value: CachedSpotifyData): void { + if (this.memCache.has(cacheKey)) { + this.memCache.delete(cacheKey) + } + this.memCache.set(cacheKey, value) + while (this.memCache.size > this.memCacheCap) { + const oldestKey = this.memCache.keys().next().value + if (oldestKey === undefined) break + this.memCache.delete(oldestKey) + } + } + private async getFromCache(cacheKey: string): Promise<{ data: any | null; isStale: boolean }> { + // M4: in-memory hit first. Touch-on-get keeps the LRU ordering correct. + const memEntry = this.memCache.get(cacheKey) + if (memEntry !== undefined) { + this.memCache.delete(cacheKey) + this.memCache.set(cacheKey, memEntry) + this.memHits++ + const isStale = Date.now() > (memEntry.expiresAt || Date.now()) + if (isStale) { + console.info(`📦 Cache stale (mem) for ${cacheKey}, will update in background`) + } + // No "Fresh cache hit" log on mem-hit to keep the log volume sane — + // mem-hits are the common path; only stale-mem and SD reads log. + return { data: memEntry.data, isStale } + } + this.memMisses++ try { const cacheFile = this.getCacheFilePath(cacheKey) - if (!fs.existsSync(cacheFile)) { - return { data: null, isStale: false } + // M3: async read so the event loop stays free while the SD seeks. + // ENOENT is the "no cache yet" path -- swallow it and report as miss. + let raw: string + try { + raw = await fsPromises.readFile(cacheFile, 'utf8') + } catch (readErr) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + return { data: null, isStale: false } + } + throw readErr } - - const _stats = fs.statSync(cacheFile) - const cachedData: CachedSpotifyData = JSON.parse(fs.readFileSync(cacheFile, 'utf8')) + const cachedData: CachedSpotifyData = JSON.parse(raw) const isStale = Date.now() > (cachedData.expiresAt || Date.now()) @@ -116,6 +237,10 @@ export class SpotifyApiService { console.info(`✅ Fresh cache hit for ${cacheKey}`) } + // M4: populate the in-mem layer so the next hit of the same key skips + // the SD round-trip entirely. + this.memCacheTouch(cacheKey, cachedData) + return { data: cachedData.data, isStale } } catch (error) { console.error(`Error reading cache for ${cacheKey}:`, error) @@ -135,13 +260,42 @@ export class SpotifyApiService { expiresAt: Date.now() + expiryTime, } - fs.writeFileSync(cacheFile, JSON.stringify(cachedData, null, 2)) + // M3: async write -- the previous writeFileSync blocked the event loop + // 5-30 ms per save on SD, which during a "fetch all albums of an artist" + // burst added up to seconds of stutter under high cache-miss load. + // M4: also populate the in-memory layer so the next read of the same + // key skips the SD round-trip. + await fsPromises.writeFile(cacheFile, JSON.stringify(cachedData, null, 2)) + this.memCacheTouch(cacheKey, cachedData) console.info(`💾 Cached data for ${cacheKey}`) + this.pruneCacheIfNeeded() } catch (error) { console.error(`Error saving cache for ${cacheKey}:`, error) } } + // B6: hard upper bound on a single Spotify SDK call. The SDK's + // underlying fetch has no built-in timeout, and a TCP-level stall + // (no FIN, no RST, just silence from the upstream) would leave this + // promise pending forever. The pendingRequests entry in queueRequest + // never settles, so every subsequent same-key request also hangs — + // and the queue stops processing because isProcessingQueue stays + // true. 20s is generous: the slowest legitimate response we see is + // ~3-4s for an audiobook with hundreds of chapters. + private static readonly SPOTIFY_REQUEST_TIMEOUT_MS = 20000 + + private async withTimeout(operation: () => Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Spotify request timed out after ${ms}ms`)), ms) + }) + try { + return await Promise.race([operation(), timeoutPromise]) + } finally { + if (timer) clearTimeout(timer) + } + } + private async rateLimitedRequest(operation: () => Promise): Promise { // Implement simple rate limiting const now = Date.now() @@ -153,7 +307,7 @@ export class SpotifyApiService { try { this.lastRequestTime = Date.now() - return await operation() + return await this.withTimeout(operation, SpotifyApiService.SPOTIFY_REQUEST_TIMEOUT_MS) } catch (error: any) { if (error.statusCode === 429) { // Rate limited - wait and retry @@ -361,10 +515,11 @@ export class SpotifyApiService { limit = 10, offset = 0, ): Promise<{ items: SpotifyApiAlbumSearchResult[]; total: number; limit: number; offset: number }> { - const cacheKey = `search_albums_${query}_${limit}_${offset}` + const { limit: l, offset: o } = this.normalizePagination(limit, offset) + const cacheKey = `search_albums_${query}_${l}_${o}` return this.executeWithCache(cacheKey, async () => { - const result = await this.spotifyApi.search(query, ['album'], 'DE', Math.min(limit, 10) as any, offset) + const result = await this.spotifyApi.search(query, ['album'], 'DE', Math.min(l, 10) as any, o) return { items: result.albums.items.map((item) => ({ @@ -375,8 +530,8 @@ export class SpotifyApiService { release_date: item.release_date, })) || [], total: result.albums.total || 0, - limit: result.albums.limit || limit, - offset: result.albums.offset || offset, + limit: result.albums.limit || l, + offset: result.albums.offset || o, } }) } @@ -387,15 +542,16 @@ export class SpotifyApiService { limit = 10, offset = 0, ): Promise<{ items: SpotifyApiArtistAlbumsResult[]; total: number; limit: number; offset: number }> { - const cacheKey = `artist_albums_${artistId}_${albumTypes}_${limit}_${offset}` + const { limit: l, offset: o } = this.normalizePagination(limit, offset) + const cacheKey = `artist_albums_${artistId}_${albumTypes}_${l}_${o}` return this.executeWithCache(cacheKey, async () => { const result = await this.spotifyApi.artists.albums( artistId, 'album,single,compilation', 'DE', - Math.min(limit, 10) as any, - offset, + Math.min(l, 10) as any, + o, ) return { items: (result.items || []).map((item: any) => ({ @@ -406,8 +562,8 @@ export class SpotifyApiService { release_date: item.release_date, })), total: result.total || 0, - limit: result.limit || limit, - offset: result.offset || offset, + limit: result.limit || l, + offset: result.offset || o, } }) } @@ -417,10 +573,11 @@ export class SpotifyApiService { limit = 10, offset = 0, ): Promise<{ items: SpotifyApiShowEpisodesResult[]; total: number; limit: number; offset: number }> { - const cacheKey = `show_episodes_${showId}_${limit}_${offset}` + const { limit: l, offset: o } = this.normalizePagination(limit, offset) + const cacheKey = `show_episodes_${showId}_${l}_${o}` return this.executeWithCache(cacheKey, async () => { - const result = await this.spotifyApi.shows.episodes(showId, 'DE', Math.min(limit, 10) as any, offset) + const result = await this.spotifyApi.shows.episodes(showId, 'DE', Math.min(l, 10) as any, o) return { items: result.items.map((item) => ({ id: item.id, @@ -429,8 +586,8 @@ export class SpotifyApiService { release_date: item.release_date, })), total: result.total || 0, - limit: result.limit || limit, - offset: result.offset || offset, + limit: result.limit || l, + offset: result.offset || o, } }) } @@ -474,7 +631,8 @@ export class SpotifyApiService { } async getPlaylistTracks(playlistId: string, limit = 10, offset = 0, forceBackgroundRefresh = false): Promise { - const cacheKey = `playlist_tracks_${playlistId}_${limit}_${offset}` + const { limit: l, offset: o } = this.normalizePagination(limit, offset) + const cacheKey = `playlist_tracks_${playlistId}_${l}_${o}` return this.executeWithCache( cacheKey, @@ -483,8 +641,8 @@ export class SpotifyApiService { playlistId, 'DE', 'items(track(id,uri,name))', - Math.min(limit, 10) as any, - offset, + Math.min(l, 10) as any, + o, ) return result.items }, diff --git a/src/frontend-box/src/app/add/add.page.ts b/src/frontend-box/src/app/add/add.page.ts index 12e15303..f97735c1 100644 --- a/src/frontend-box/src/app/add/add.page.ts +++ b/src/frontend-box/src/app/add/add.page.ts @@ -270,7 +270,7 @@ export class AddPage implements OnInit, AfterViewInit { this.validate() } - handleLayoutChange(button) { + handleLayoutChange(button: string) { const currentLayout = this.keyboard.options.layoutName let layout: string diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts index 794b5148..9dbffadd 100644 --- a/src/frontend-box/src/app/app.component.ts +++ b/src/frontend-box/src/app/app.component.ts @@ -1,36 +1,114 @@ import { HttpClient } from '@angular/common/http' -import { ChangeDetectionStrategy, Component, Signal } from '@angular/core' +import { ChangeDetectionStrategy, Component, computed, effect, Signal } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' import { IonApp, IonRouterOutlet } from '@ionic/angular/standalone' -import { distinctUntilChanged, interval, map, Observable, switchMap } from 'rxjs' +import { catchError, distinctUntilChanged, firstValueFrom, interval, map, Observable, of, switchMap, timeout } from 'rxjs' +import { take } from 'rxjs/operators' import { environment } from 'src/environments/environment' +import { CurrentMediaService } from './current-media.service' import { DisplayManagerService } from './display-manager.service' import { ExternalPlaybackNavigatorService } from './external-playback-navigator.service' +import { MediaService } from './media.service' import { Monitor } from './monitor' +import type { PlaytimePlayState } from './playtime.model' +import { PlaytimeService } from './playtime.service' +import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component' +import { PlaytimeChipComponent } from './playtime-chip/playtime-chip.component' +import { buildResumeMedia } from './resume-builder' @Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrls: ['app.component.scss'], - imports: [IonApp, IonRouterOutlet], + imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent, PlaytimeChipComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { protected monitorOff: Signal + protected playtimeBlocked: Signal + + // Track previous playtime state to detect normal -> grace/blocked transitions. + // 'unknown' on first tick avoids spurious save before we know the baseline. + private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown' public constructor( private http: HttpClient, _externalPlaybackNavigator: ExternalPlaybackNavigatorService, _displayManager: DisplayManagerService, + playtimeService: PlaytimeService, + private mediaService: MediaService, + private currentMediaService: CurrentMediaService, ) { this.monitorOff = toSignal( // 1.5s should be enough to be somewhat "recent". + // M1: per-tick timeout + catchError so a single 5xx or stalled response + // doesn't kill the toSignal observable forever. B11-pattern shared with + // media.service's polling streams. Empty Monitor on failure means + // monitorOff stays at its last good distinctUntilChanged value (or the + // initial false), no spurious overlay. interval(1500).pipe( - switchMap((): Observable => this.http.get(`${environment.backend.apiUrl}/monitor`)), - map((monitor) => monitor.monitor !== 'On'), + switchMap((): Observable => + this.http.get(`${environment.backend.apiUrl}/monitor`).pipe( + timeout(1000), + catchError(() => of({} as Monitor)), + ), + ), + map((monitor) => monitor.monitor !== undefined && monitor.monitor !== 'On'), distinctUntilChanged(), ), { initialValue: false }, ) + this.playtimeBlocked = computed(() => { + const s = playtimeService.status() + return s.enabled === true && s.state === 'blocked' + }) + + // Global resume-on-cap: when playtime/quiet hours transitions + // normal -> grace or normal -> blocked, persist a resume entry for the + // currently-playing Media. This is what makes "weiterhören wo aufgehört" + // work even if the user listens from the home page (player page unmounted, + // its in-page saver inert). Backend's composite-key dedup means the entry + // overwrites any existing resume for the same item. + // + // Player-state snapshots (current$/local$) are read on-demand via + // firstValueFrom — eagerly subscribing here previously kept the shared + // mediaService observables (and the Spotify SDK's getCurrentState + // polling) hot from app bootstrap, which interfered with Spotify Connect + // device activation. Now the upstream is only subscribed during the + // brief moment of saving on cap. + // + // Gated on shouldPersistResume() so a wrong-cover-touch right before a + // cap doesn't leave a stale entry in the resume swiper. + effect(() => { + const status = playtimeService.status() + if (!status.enabled) { + this.prevPlaytimeState = 'unknown' + return + } + const cur = status.state + const prev = this.prevPlaytimeState + this.prevPlaytimeState = cur + if (prev === 'unknown' || prev === cur) return + if (cur !== 'grace' && cur !== 'blocked') return + if (!this.currentMediaService.shouldPersistResume()) return + void this.persistResumeOnCap() + }) + } + + private async persistResumeOnCap(): Promise { + const source = this.currentMediaService.get() + if (!source) return + // One-shot reads: subscribe long enough to grab the latest cached + // emission (or the next one if the upstream isn't running) and + // unsubscribe. Player.page keeps the upstream hot while it's mounted, + // so when the user is in the player view this resolves immediately + // from the shareReplay buffer; from the home page it spins the + // upstream up briefly (one tick of interval(1000)) and tears it down. + const [spotify, local] = await Promise.all([ + firstValueFrom(this.mediaService.current$.pipe(take(1))).catch((): null => null), + firstValueFrom(this.mediaService.local$.pipe(take(1))).catch((): null => null), + ]) + const resumeMedia = buildResumeMedia(source, spotify, local) + this.mediaService.addRawResume(resumeMedia) } } diff --git a/src/frontend-box/src/app/media.service.ts b/src/frontend-box/src/app/media.service.ts index e4452f2f..cf3ea438 100644 --- a/src/frontend-box/src/app/media.service.ts +++ b/src/frontend-box/src/app/media.service.ts @@ -1,18 +1,19 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { firstValueFrom, from, iif, interval, Observable, of, Subject } from 'rxjs' -import { map, mergeAll, mergeMap, shareReplay, switchMap, toArray } from 'rxjs/operators' +import { catchError, map, mergeAll, mergeMap, shareReplay, switchMap, toArray } from 'rxjs/operators' import { environment } from '../environments/environment' import type { AlbumStop } from './albumstop' import type { Artist } from './artist' import type { CurrentMPlayer } from './current.mplayer' import type { CurrentSpotify } from './current.spotify' -import type { CategoryType, Media, MediaInfoCache } from './media' +import { isResumeEntry, type CategoryType, type Media, type MediaInfoCache } from './media' import { Mupihat } from './mupihat' import type { Network } from './network' import { NetworkService } from './network.service' import { RssFeedService } from './rssfeed.service' import { SpotifyService } from './spotify.service' +import type { ExtraDataMedia } from './utils' import type { WLAN } from './wlan' @Injectable({ @@ -158,26 +159,60 @@ export class MediaService { }), shareReplay({ bufferSize: 1, refCount: true }), ) - : // Remote: HTTP polling + : // Remote: HTTP polling. + // B11: a single HTTP failure (network blip, backend restart) + // would error the source observable, and shareReplay would + // forever replay that error to subscribers — UI stops getting + // state updates until the page is reloaded. Wrap the inner + // get in catchError(of({})) so transient failures show as + // "no current state" without tearing down the polling stream. interval(10000).pipe( switchMap( - (): Observable => this.http.get(`${this.getPlayerBackendUrl()}/state`), + (): Observable => + this.http + .get(`${this.getPlayerBackendUrl()}/state`) + .pipe(catchError(() => of({} as CurrentSpotify))), ), shareReplay({ bufferSize: 1, refCount: true }), ) + // Same B11 pattern for local$ / albumStop$ / mupihat$ — all polling + // streams that should swallow transient errors instead of becoming + // permanently broken. this.local$ = interval(1000).pipe( - switchMap((): Observable => this.http.get(`${this.getPlayerBackendUrl()}/local`)), + switchMap( + (): Observable => + this.http + .get(`${this.getPlayerBackendUrl()}/local`) + .pipe(catchError(() => of({} as CurrentMPlayer))), + ), shareReplay({ bufferSize: 1, refCount: true }), ) this.albumStop$ = interval(1000).pipe( - switchMap((): Observable => this.http.get(`${this.getApiBackendUrl()}/albumstop`)), + switchMap( + (): Observable => + this.http + .get(`${this.getApiBackendUrl()}/albumstop`) + .pipe(catchError(() => of({} as AlbumStop))), + ), shareReplay({ bufferSize: 1, refCount: false }), ) // Every 2 seconds should be enough for timely charging update. + // M2: refCount=true so the polling stops when no UI is subscribed. + // Previously the stream kept hitting /api/mupihat every 2s for the + // entire app lifetime even when the mupihat-icon wasn't rendered + // (~1800 wasted req/h). The only consumer is mupihat-icon.component, + // which switchMaps in only when hat_active === true — so refCount + // ensures the upstream interval idles when that icon is unmounted + // (any page without the toolbar/footer rendering it). this.mupihat$ = interval(2000).pipe( - switchMap((): Observable => this.http.get(`${this.getApiBackendUrl()}/mupihat`)), - shareReplay({ bufferSize: 1, refCount: false }), + switchMap( + (): Observable => + this.http + .get(`${this.getApiBackendUrl()}/mupihat`) + .pipe(catchError(() => of({} as Mupihat))), + ), + shareReplay({ bufferSize: 1, refCount: true }), ) this.initTelegramNotifications() @@ -283,18 +318,6 @@ export class MediaService { }) } - editRawResumeAtIndex(index: number, data: Media) { - const url = `${this.getApiBackendUrl()}/editresume` - const body = { - index, - data, - } - - this.http.post(url, body, { responseType: 'text' }).subscribe((response) => { - this.response = response - }) - } - addRawResume(media: Media) { const url = `${this.getApiBackendUrl()}/addresume` @@ -314,6 +337,30 @@ export class MediaService { // Collect albums from a given artist in the current category public fetchMediaFromArtist(artist: Artist, category: CategoryType): Observable { + // Fast path for Spotify artists: we already know the artistid from the + // navigation state, so call getMediaByArtistID directly. Previously this + // ran fetchMedia(category) which loads EVERY item in the category — for + // a library with 8 spotify-artists + 7 spotify-albums + 143 library + // entries the page fired ~55 backend calls just to filter Benjamin + // Blümchen out at the end. Direct call is 6 backend calls (with QW1+QW2 + // pagination) and no other items contend for the event loop. + const cm = artist.coverMedia + if (cm.type === 'spotify' && cm.artistid && cm.artistid.length > 0) { + const extra: ExtraDataMedia = { + artistcover: cm.artistcover, + shuffle: cm.shuffle, + aPartOfAll: cm.aPartOfAll, + aPartOfAllMin: cm.aPartOfAllMin, + aPartOfAllMax: cm.aPartOfAllMax, + sorting: cm.sorting, + lastPlayedAt: cm.lastPlayedAt, + } + return this.spotifyService.getMediaByArtistID(cm.artistid, category, 0, extra) + } + // Library/local entries fall back to the original load-then-filter path + // because multiple data.json rows may share the same artist name (each + // album its own row), and there's no single API call that retrieves + // them as a group. return this.fetchMedia(category).pipe( map((media: Media[]) => { return media.filter((currentMedia) => currentMedia.artist === artist.name) @@ -411,9 +458,18 @@ export class MediaService { public fetchActiveResumeData(): Observable { // Category is irrelevant if 'resume' is set to true. + // Sort by lastPlayedAt DESC so "most recently played" is at position 1. + // Previously the page used a blind `.reverse()` of the array, which + // matches the file insertion order — but addresume updates existing + // entries in place (preserving their position) so a freshly-played + // album never moved to the top until it was a *new* entry. Items + // without a timestamp (legacy entries pre-migration) sort to 0 and + // land at the bottom; the backend back-fills synthetic stamps + // preserving original order on the next addresume so this is + // self-healing. return this.updateMedia(`${this.getApiBackendUrl()}/activeresume`, true, 'resume').pipe( map((media: Media[]) => { - return media.reverse() + return [...media].sort((a, b) => (b.lastPlayedAt ?? 0) - (a.lastPlayedAt ?? 0)) }), ) } @@ -424,18 +480,25 @@ export class MediaService { // Get the media data for the current category from the server private updateMedia(url: string, resume: boolean, category: CategoryType): Observable { - // Custom rxjs pipe to override artist. + // Custom rxjs pipe applied to every iif-branch's service-call output. + // Carries the original item's user-relevant fields onto the Media that + // the spotify/rss/library service builds out of upstream API data: + // - artist: optional user-defined override + // - lastPlayedAt: ResumePage sorts DESC by this; spotify.service's + // getMediaByID etc. don't accept it as a param, so without this carry + // the field gets dropped on every resume entry that goes through a + // service call. fetchActiveResumeData's sort then sees zeros and the + // user's most-recently-played item ends up at a random swiper position. + // - isResume: marks resume entries; same loss-on-service-call risk. const overwriteArtist = (item: Media) => (source$: Observable): Observable => { return source$.pipe( - // If the user entered an user-defined artist name in addition to a query, - // overwrite orignal artist from spotify. map((items) => { - if (item.artist?.length > 0) { - for (const currentItem of items) { - currentItem.artist = item.artist - } + for (const currentItem of items) { + if (item.artist?.length > 0) currentItem.artist = item.artist + if (typeof item.lastPlayedAt === 'number') currentItem.lastPlayedAt = item.lastPlayedAt + if (item.isResume === true) currentItem.isResume = true } return items }), @@ -472,13 +535,13 @@ export class MediaService { .pipe(overwriteArtist(item)), iif( // Get media by show - () => !!(item.showid && item.showid.length > 0 && item.category !== 'resume'), + () => !!(item.showid && item.showid.length > 0 && !isResumeEntry(item)), this.spotifyService .getMediaByShowID(item.showid, item.category, item.index, item) .pipe(overwriteArtist(item)), iif( // Get media by show supporting resume - () => !!(item.showid && item.showid.length > 0 && item.category === 'resume'), + () => !!(item.showid && item.showid.length > 0 && isResumeEntry(item)), this.spotifyService .getMediaByEpisode( item.showid, @@ -513,8 +576,19 @@ export class MediaService { overwriteArtist(item), ), iif( - // Get media by rss feed - () => !!(item.type === 'rss' && item.id.length > 0 && item.category !== 'resume'), + // Get media by rss feed. + // MED-10 attempted to enrich RSS resume entries with + // fresh feed data, but the `id` of a RSS resume entry + // is the *episode's MP3 URL*, not the channel feed + // URL — the enrichment fetch streamed the MP3 audio + // (multi-MB) into the rss-parser path before MED-2's + // size-cap aborted with 413. Six RSS resume entries + // = ~24 s freeze on the resume page. Reinstate the + // resume-skip gate: every persisted field needed for + // the resume tile (title, cover, artistcover, release + // date, duration, progress) is already on disk; no + // network round-trip needed for resume rendering. + () => !!(item.type === 'rss' && item.id.length > 0 && !isResumeEntry(item)), this.rssFeedService .getRssFeed(item.id, item.category, item.index, item) .pipe(overwriteArtist(item)), @@ -699,7 +773,13 @@ export class MediaService { mediaType, } - return mediaInfo + // MED-7: cache-hit branch (line 645+) returns this.mediaInfoCache + // which has currentId + mediaType, but the previous miss-branch + // returned the raw mediaInfo without those fields. Callers that + // checked `result.mediaType` saw different shapes depending on + // whether the entry was already cached. Return the cache object + // we just wrote so the shape is consistent across hits and misses. + return this.mediaInfoCache } } catch (error) { console.warn('Failed to get media info for URI:', contextUri, error) diff --git a/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.html b/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.html index 2d58fab3..2d485b22 100644 --- a/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.html +++ b/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.html @@ -1,22 +1,51 @@ @if (hat_active && mupihat() !== undefined) { @if (mupihat().BatteryConnected === 1) { @let svgBaseName = mupihat().IBus > 0 ? 'Charging' : 'Battery'; - @switch (mupihat().Bat_SOC) { - @case ('100%') { - + + @let pct = mupihat().Bat_Percent; + + @if (pct !== undefined && pct !== null) { + @if (pct >= 95) { + + } @else if (pct >= 70) { + + } @else if (pct >= 45) { + + } @else if (pct >= 20) { + + } @else { + + } + + @if (mupihat().Bat_PercentSource === 'charging') {⚡}{{ pct }}% + + } @else { + + @switch (mupihat().Bat_SOC) { + @case ('100%') { + + } + @case ('75%') { + + } + @case ('50%') { + + } + @case ('25%') { + + } + @default { + + } + } } - @case ('75%') { - - } - @case ('50%') { - - } - @case ('25%') { - - } - @default { - - } - } + } } diff --git a/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.scss b/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.scss index e69de29b..58e6cc3f 100644 --- a/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.scss +++ b/src/frontend-box/src/app/mupihat-icon/mupihat-icon.component.scss @@ -0,0 +1,38 @@ +// Phase-12 follow-up: stack icon over percent text instead of side-by-side. +// Reason: the home.page toolbar slot is a fixed 70x70 px ion-button that +// already holds the cloud-on/cloud-off internet icon + this component. A +// horizontal " " expansion of mupihat-icon pushed the cloud +// icon off the 70 px width. Vertical stacking keeps the whole mupihat +// component inside its old horizontal footprint while still showing the +// granular percent below the bucket icon. +// +// :host is set to inline-flex too so the mupihat-icon component element +// itself participates in its parent's flex layout (e.g. the ion-button +// children area) as a single inline cell, no extra wrapping needed at +// the call sites. +:host { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.mupihat-icon-row { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1px; + line-height: 1; +} + +.bat-percent { + font-size: 0.55rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + // Tiny -1px margin pulls the percent label snugly under the icon's + // base — without it the line-height of the ion-icon's baseline plus + // the percent's own line-height left a visible gap. + margin-top: -2px; +} diff --git a/src/frontend-box/src/app/mupihat.ts b/src/frontend-box/src/app/mupihat.ts index 52b7b43b..20223dfc 100644 --- a/src/frontend-box/src/app/mupihat.ts +++ b/src/frontend-box/src/app/mupihat.ts @@ -9,4 +9,9 @@ export interface Mupihat { Bat_SOC?: string Bat_Stat?: string Bat_Type?: string + // Phase-12: granular 5%-step SoC + charging-state hint. Backend computes + // these via piecewise-linear interpolation over the same v_100..v_0 config + // thresholds, smoothed over a ~32 s VBAT window. + Bat_Percent?: number + Bat_PercentSource?: 'voltage' | 'charging' } diff --git a/src/frontend-box/src/app/player.service.ts b/src/frontend-box/src/app/player.service.ts index 42059219..a96c9879 100644 --- a/src/frontend-box/src/app/player.service.ts +++ b/src/frontend-box/src/app/player.service.ts @@ -74,7 +74,7 @@ export class PlayerService { this.sendRequest(cmd) } - seekPosition(pos) { + seekPosition(pos: number) { const seekpos = `seekpos:${pos}` this.sendRequest(seekpos) } diff --git a/src/frontend-box/src/app/spotify.service.ts b/src/frontend-box/src/app/spotify.service.ts index 8d053250..bca8d1a2 100644 --- a/src/frontend-box/src/app/spotify.service.ts +++ b/src/frontend-box/src/app/spotify.service.ts @@ -1,13 +1,13 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { catchError, EMPTY, firstValueFrom, from, Observable, of } from 'rxjs' -import { concatMap, map, scan, switchMap, take, takeLast, timeout } from 'rxjs/operators' +import { map, mergeMap, scan, switchMap, take, takeLast, timeout } from 'rxjs/operators' import { environment } from 'src/environments/environment' import { LogService } from './log.service' import type { CategoryType, Media } from './media' import { SpotifyConfig } from './spotify' import { SpotifyPlayerService } from './spotify-player.service' -import { ExtraDataMedia, Utils } from './utils' +import { ExtraDataMedia, localizeCoverUrl, Utils } from './utils' @Injectable({ providedIn: 'root', @@ -137,9 +137,16 @@ export class SpotifyService { ) } - // Combine first page with all additional pages + // Combine first page with all additional pages. + // Phase-7.6: was concatMap (strict sequential) — for an artist + // with ~28 albums (pageSize=10) this was 28 round-trips of + // ~40ms each, blocking the medialist render. mergeMap with + // concurrency 8 pipelines the requests through the backend + // event-loop. Resulting array order is no longer page-aligned, + // but medialist.page sorts on the client (localeCompare) before + // render so the user-visible order is unchanged. return from(additionalPageObservables).pipe( - concatMap((obs) => obs), + mergeMap((obs) => obs, 8), scan((acc: T[], pageItems: T[]) => [...acc, ...pageItems], firstPageItems), takeLast(1), ) @@ -170,7 +177,7 @@ export class SpotifyService { id: album.id, artist: album.artists?.[0]?.name || 'Unknown Artist', title: album.name, - cover: album.images?.[0]?.url || '../assets/images/nocover_mupi.png', + cover: localizeCoverUrl(album.images?.[0]?.url), release_date: album.release_date, type: 'spotify', category, @@ -201,7 +208,7 @@ export class SpotifyService { return this.http.get(artistUrl).pipe( switchMap((artist) => { - const artistcover = artist.images?.[0]?.url || '../assets/images/nocover_mupi.png' + const artistcover = localizeCoverUrl(artist.images?.[0]?.url) return this.fetchAllPaginatedResults(artistAlbumsUrl, {}).pipe( map((albums) => { @@ -210,7 +217,7 @@ export class SpotifyService { id: album.id, artist: album.artists?.[0]?.name || 'Unknown Artist', title: album.name, - cover: album.images?.[0]?.url || '../assets/images/nocover_mupi.png', + cover: localizeCoverUrl(album.images?.[0]?.url), artistcover: artistcover, release_date: album.release_date, type: 'spotify', @@ -245,7 +252,7 @@ export class SpotifyService { return this.http.get(showUrl).pipe( switchMap((show) => { const showName = show.name || 'Unknown Show' - const showcover = show.images?.[0]?.url || '../assets/images/nocover_mupi.png' + const showcover = localizeCoverUrl(show.images?.[0]?.url) return this.fetchAllPaginatedResults(showEpisodesUrl, {}).pipe( map((episodes) => { @@ -256,7 +263,7 @@ export class SpotifyService { showid: episode.id, artist: showName, title: episode.name, - cover: episode.images?.[0]?.url || showcover, + cover: episode.images?.[0]?.url ? localizeCoverUrl(episode.images[0].url) : showcover, artistcover: showcover, type: 'spotify', category, @@ -297,7 +304,7 @@ export class SpotifyService { id: album.id, artist: album.artists?.[0]?.name || 'Unknown Artist', title: album.name, - cover: album.images?.[0]?.url || '../assets/images/nocover_mupi.png', + cover: localizeCoverUrl(album.images?.[0]?.url), type: 'spotify', release_date: album.release_date, category, @@ -349,7 +356,7 @@ export class SpotifyService { audiobookid: audiobook.id, artist: audiobook.authors?.[0]?.name || 'Unknown Author', title: audiobook.name, - cover: audiobook.images?.[0]?.url || '../assets/images/nocover_mupi.png', + cover: localizeCoverUrl(audiobook.images?.[0]?.url), type: 'spotify', category, index, @@ -400,7 +407,7 @@ export class SpotifyService { showid: episode.id, artist: episode.show?.[0]?.name || 'Unknown Show', title: episode.name, - cover: episode.images?.[0]?.url || '../assets/images/nocover_mupi.png', + cover: localizeCoverUrl(episode.images?.[0]?.url), type: 'spotify', release_date: episode.release_date, category, @@ -460,7 +467,7 @@ export class SpotifyService { const media: Media = { playlistid: isFromBackend ? id : response.id, title: isFromBackend ? response.playlist.name : response.name, - cover: isFromBackend ? response.playlist.images?.[0]?.url : response?.images?.[0]?.url, + cover: localizeCoverUrl(isFromBackend ? response.playlist.images?.[0]?.url : response?.images?.[0]?.url), type: 'spotify', category, index, diff --git a/src/frontend-box/src/app/swiper/swiper.component.html b/src/frontend-box/src/app/swiper/swiper.component.html index 2db9d2ef..71c254ed 100644 --- a/src/frontend-box/src/app/swiper/swiper.component.html +++ b/src/frontend-box/src/app/swiper/swiper.component.html @@ -1,11 +1,36 @@ - - @for (currentData of shownData(); track currentData) { + + + @for (currentData of shownData(); track currentData.name) { - + + diff --git a/src/frontend-box/src/app/swiper/swiper.component.ts b/src/frontend-box/src/app/swiper/swiper.component.ts index eca2f978..6df52f7c 100644 --- a/src/frontend-box/src/app/swiper/swiper.component.ts +++ b/src/frontend-box/src/app/swiper/swiper.component.ts @@ -10,11 +10,11 @@ import { output, Signal, signal, + untracked, viewChild, WritableSignal, } from '@angular/core' import { IonCard, IonCardHeader, IonCardTitle, IonCol, IonGrid, IonRow } from '@ionic/angular/standalone' -import { cloneDeep } from 'lodash-es' import { Observable } from 'rxjs' import Swiper from 'swiper' import { PlayerService } from '../player.service' @@ -41,6 +41,17 @@ export class SwiperComponent { protected swiperContainer = viewChild('swiper') protected swiper: Signal = computed(() => this.swiperContainer()?.nativeElement.swiper) protected pageIsShown: WritableSignal = signal(false) + // Progressive-render cap. Starts small (~viewport + swipe-buffer) and + // grows in deterministic timeout-driven chunks until the full list is + // in the DOM. Used to use requestIdleCallback for the late chunks but + // it didn't fire reliably on the Pi kiosk (the browser's Spotify-SDK + // polling kept the main thread non-idle), so very large lists never + // grew past stage 2 — Benjamin Blümchen rendered only 60 of 276 albums. + private renderableLimit: WritableSignal = signal(15) + private static readonly RENDER_INITIAL = 15 + private static readonly RENDER_CHUNK_SIZE = 30 + private static readonly RENDER_CHUNK_DELAY_MS = 80 + private renderTimer: number | undefined // This is a hacky workaround for the problem that the swiper doesn't allow to scroll // after an ionic navigation event if the data is not updated. Thus, we copy the given @@ -53,28 +64,162 @@ export class SwiperComponent { // manually cache / restore the swiper position. private cachedSwiperPosition = 0 + // Track URLs we've already triggered a preload-fetch for, so we don't + // re-create Image() objects for the same cover on every slide change. + // Set lives for the lifetime of the component instance — that matches + // the underlying Spotify-CDN cache lifetime well enough. + private readonly preloadedSrcs = new Set() + private static readonly PRELOAD_LOOKAHEAD = 12 + private static readonly PRELOAD_LOOKBEHIND = 4 + public constructor(private playerService: PlayerService) { this.shownData = computed(() => { - if (this.pageIsShown()) { - return cloneDeep(this.data()) - } - return [] + if (!this.pageIsShown()) return [] + // Progressive render for very long lists (276 albums for a prolific + // artist like Benjamin Blümchen). Rendering all slides at once meant + // ~1400 DOM nodes (276 × ion-card/grid/row/col/img) plus 276 + // simultaneous Spotify-CDN image fetches — Chromium needed several + // seconds before the first paint. Cap the initial render at a + // viewport-sized slice; the effect below incrementally grows it + // until the full list is in the DOM. structuredClone is 5-10× + // faster than lodash.cloneDeep on plain-object arrays; Observables + // on SwiperData.imgSrc aren't cloneable so keep them by reference. + const src = this.data() + const limit = Math.min(this.renderableLimit(), src.length) + const cloned = src + .slice(0, limit) + .map((d) => ({ name: d.name, imgSrc: d.imgSrc, data: structuredClone(d.data) })) + return cloned + }) + + // Restore cached scroll position when page becomes visible. Tracks + // pageIsShown only — must not track shownData (would re-fire on every + // render-chunk and snap to the cached index mid-swipe). + effect(() => { + if (!this.pageIsShown()) return + Promise.resolve().then(() => { + const sw = this.swiper() + if (!sw) return + const slidesEl = (sw as unknown as { slides?: HTMLElement[] }).slides + const len = slidesEl?.length ?? 0 + if (len > 0) { + sw.slideTo(Math.min(this.cachedSwiperPosition, len - 1), 0) + } + }) }) + // Drive progressive expansion. Tracks pageIsShown + data().length. + // When the input data grows (typical: empty array → full array once + // the parent's HTTP fetch resolves), kick off the chunked render + // loop. Without this, the first ionViewDidEnter saw data().length=0, + // bailed immediately, and never restarted when the real data arrived + // — user saw only the initial 15 slides for the rest of the visit. effect(() => { - if (this.pageIsShown()) { - this.swiper()?.slideTo(this.cachedSwiperPosition, 0) + if (!this.pageIsShown()) { + if (this.renderTimer !== undefined) { + clearTimeout(this.renderTimer) + this.renderTimer = undefined + } + return + } + const target = this.data()?.length ?? 0 + const cur = untracked(() => this.renderableLimit()) + if (target > cur && this.renderTimer === undefined) { + this.scheduleNextChunk() } }) } + /** + * Tracks the user's current scroll position so progressive-render + * expansions don't lose it. Wired up via the (slidechange) event in + * the template. Without this, the cachedSwiperPosition stays at 0 + * for the entire page visit and any unintended slideTo would jump + * back to the start. + * + * Also pre-fetches the next few cover URLs into the browser's image + * cache, so by the time the user actually swipes there the + * tag finds the response already buffered instead of waiting for a + * Spotify-CDN round-trip. The lazy-loading attribute on means + * the browser otherwise wouldn't kick off those requests until the + * slide enters the viewport. + */ + protected onSlideChange(event: Event): void { + const swiper = (event.target as unknown as { swiper?: Swiper })?.swiper + if (!swiper || typeof swiper.activeIndex !== 'number') return + this.cachedSwiperPosition = swiper.activeIndex + this.preloadCoversNear(swiper.activeIndex) + } + + private preloadCoversNear(activeIndex: number): void { + const data = this.shownData() + if (!data || data.length === 0) return + // Window covers a few back-slides too: a fast leftward swipe past + // the start triggers no slidechange-event for individual back- + // slides, so the user sees blank tiles when bouncing back. The + // forward window is wider because forward-swiping is the dominant + // motion in the kid UI. + const start = Math.max(0, activeIndex - SwiperComponent.PRELOAD_LOOKBEHIND) + const end = Math.min(activeIndex + 3 + SwiperComponent.PRELOAD_LOOKAHEAD, data.length) + for (let i = start; i < end; i++) { + const item = data[i] + if (!item?.imgSrc) continue + // imgSrc is `of(url)` — a single-emit completing observable, so + // the subscription self-cleans. No takeUntilDestroyed needed. + item.imgSrc.subscribe((url) => { + if (!url || this.preloadedSrcs.has(url)) return + this.preloadedSrcs.add(url) + const img = new Image() + img.src = url + }) + } + } + public ionViewDidEnter(): void { this.pageIsShown.set(true) + this.renderableLimit.set(SwiperComponent.RENDER_INITIAL) + // Don't kick the render timer here — the effect tracking pageIsShown + + // data().length will start it as soon as data has arrived. + // Eager preload of the initial window so the first few swipes + // don't catch the user with blank tiles. preloadCoversNear is + // safe with empty data (early-returns). + Promise.resolve().then(() => this.preloadCoversNear(0)) + } + + private scheduleNextChunk(): void { + // Single in-flight timer guard. The effect calls this whenever + // data grows; we only want one chunked loop running at a time. + if (this.renderTimer !== undefined) return + this.renderTimer = window.setTimeout(() => { + this.renderTimer = undefined + if (!this.pageIsShown()) return + const cur = this.renderableLimit() + const target = this.data()?.length ?? 0 + if (cur >= target) return + this.renderableLimit.set(Math.min(cur + SwiperComponent.RENDER_CHUNK_SIZE, target)) + // Tell the swiper element about its new slides — without an + // explicit update() call the element's internal Swiper instance + // can keep counting only the slides it saw at first init, which + // means navigating past the original visible range silently + // refuses to advance. Defer one tick so Angular has actually + // committed the @for changes to the DOM. + Promise.resolve().then(() => { + const swiper = this.swiper() + if (swiper && typeof (swiper as unknown as { update?: () => void }).update === 'function') { + ;(swiper as unknown as { update: () => void }).update() + } + }) + this.scheduleNextChunk() + }, SwiperComponent.RENDER_CHUNK_DELAY_MS) as unknown as number } public ionViewWillLeave(): void { this.cachedSwiperPosition = this.swiper()?.activeIndex ?? 0 this.pageIsShown.set(false) + if (this.renderTimer !== undefined) { + clearTimeout(this.renderTimer) + this.renderTimer = undefined + } } public resetSwiperPosition(): void { diff --git a/src/frontend-box/src/app/utils.spec.ts b/src/frontend-box/src/app/utils.spec.ts index 08c71b7b..5266a2e5 100644 --- a/src/frontend-box/src/app/utils.spec.ts +++ b/src/frontend-box/src/app/utils.spec.ts @@ -1,6 +1,6 @@ import { createMedia } from './fixtures' import { MediaSorting } from './media' -import { Utils } from './utils' +import { type ExtraDataMedia, Utils } from './utils' describe('Utils', () => { it('should copy data if provided', () => { @@ -37,11 +37,12 @@ describe('Utils', () => { aPartOfAllMax: 10, sorting: MediaSorting.AlphabeticalAscending, }) - const source = { + // biome-ignore lint/suspicious/noExplicitAny: deliberately passing null/undefined values to exercise copyExtraMediaData's skip-on-nullish behaviour, which the strict ExtraDataMedia signature forbids + const source: ExtraDataMedia = { artistcover: 'b', - shuffle: null, + shuffle: null as any, aPartOfAll: undefined, - aPartOfAllMin: null, + aPartOfAllMin: null as any, aPartOfAllMax: undefined, sorting: MediaSorting.ReleaseDateAscending, } diff --git a/src/frontend-box/src/app/utils.ts b/src/frontend-box/src/app/utils.ts index b6c6fcd4..250e1ade 100644 --- a/src/frontend-box/src/app/utils.ts +++ b/src/frontend-box/src/app/utils.ts @@ -2,7 +2,7 @@ import type { Media } from './media' export type ExtraDataMedia = Pick< Media, - 'artistcover' | 'shuffle' | 'aPartOfAll' | 'aPartOfAllMin' | 'aPartOfAllMax' | 'sorting' + 'artistcover' | 'shuffle' | 'aPartOfAll' | 'aPartOfAllMin' | 'aPartOfAllMax' | 'sorting' | 'lastPlayedAt' > export namespace Utils { @@ -13,10 +13,25 @@ export namespace Utils { * @param target - The target to which the values of the properties will be copied. */ export const copyExtraMediaData = (source: ExtraDataMedia, target: Media): void => { - const keys = ['artistcover', 'shuffle', 'aPartOfAll', 'aPartOfAllMin', 'aPartOfAllMax', 'sorting'] + // lastPlayedAt MUST be in this list: media.service.updateMedia replaces + // every resume entry with a Spotify/RSS-derived Media. If lastPlayedAt + // doesn't survive the round-trip, fetchActiveResumeData's DESC sort + // sees only zeros and the resume page falls back to mergeMap-completion + // order — which makes the most-recently-played item appear at a random + // position (typically the right end of the swiper). + const keys: (keyof ExtraDataMedia)[] = [ + 'artistcover', + 'shuffle', + 'aPartOfAll', + 'aPartOfAllMin', + 'aPartOfAllMax', + 'sorting', + 'lastPlayedAt', + ] for (const key of keys) { if (source[key] != null) { - target[key] = source[key] + // biome-ignore lint/suspicious/noExplicitAny: copying typed-key values between Media subsets — narrow union is verbose without runtime benefit + ;(target as any)[key] = source[key] } } } diff --git a/src/frontend-box/src/app/wifi/wifi.page.ts b/src/frontend-box/src/app/wifi/wifi.page.ts index 08a83f43..7b04cfae 100644 --- a/src/frontend-box/src/app/wifi/wifi.page.ts +++ b/src/frontend-box/src/app/wifi/wifi.page.ts @@ -139,7 +139,7 @@ export class WifiPage implements OnInit, AfterViewInit { this.validate() } - handleLayoutChange(button) { + handleLayoutChange(button: string) { const currentLayout = this.keyboard.options.layoutName let layout: string diff --git a/src/frontend-box/tsconfig.json b/src/frontend-box/tsconfig.json index 5c9d6246..b93eb3b6 100644 --- a/src/frontend-box/tsconfig.json +++ b/src/frontend-box/tsconfig.json @@ -13,6 +13,7 @@ "target": "ES2022", "lib": ["es2020", "dom"], "useDefineForClassFields": false, + "noImplicitAny": true, "paths": { "@backend-api/*": ["../backend-api/src/models/*"] } diff --git a/update/conf_update.sh b/update/conf_update.sh index 9098b8da..555d8f52 100644 --- a/update/conf_update.sh +++ b/update/conf_update.sh @@ -5,328 +5,286 @@ #SRC="https://mupibox.de/version/latest" CONFIG="/etc/mupibox/mupiboxconfig.json" +# H4: Phase-3-Pattern. Every previous update step used +# `cat <<< $(jq ) > ${CONFIG}` which truncates CONFIG before jq's +# output is appended. If jq errors (or the script is killed mid-update), +# CONFIG ends up empty/corrupted — and an empty mupiboxconfig.json bricks +# the box. Wrap every write in a tmpfile + atomic mv. Same pattern that +# Phase-3 applied to 12 other scripts; conf_update was missed. +update_config() { + # Usage: update_config '' [--arg name value ...] + local filter=$1 + shift + local tmp="${CONFIG}.tmp.$$" + if /usr/bin/jq "$@" "$filter" "${CONFIG}" > "${tmp}"; then + mv "${tmp}" "${CONFIG}" + else + rm -f "${tmp}" + echo "WARN: conf_update jq filter failed: $filter" >&2 + fi +} + +# H4: previous theme-insert used `cat ${CONFIG} | grep ` to test +# whether a theme is already installed. That matches anywhere in the +# JSON file as substring — e.g. checking for "matrix" matches the literal +# "matrix" wherever it appears in the config, including inside other +# values. Use jq's array index check against the actual installedThemes +# list instead — exact-match, no false positives. +ensure_theme() { + local theme=$1 + if /usr/bin/jq -e --arg v "$theme" \ + '(.mupibox.installedThemes // []) | index($v) != null' \ + "${CONFIG}" >/dev/null 2>&1; then + return 0 # already installed + fi + update_config '.mupibox.installedThemes? += [$v]' --arg v "$theme" +} + # 1.0.8 -/usr/bin/cat <<< $(/usr/bin/jq 'del(.mupibox.googlettslanguages)' ${CONFIG}) > ${CONFIG} -/usr/bin/cat <<< $(/usr/bin/jq 'del(.mupibox.mediaCheckTimer)' ${CONFIG}) > ${CONFIG} -/usr/bin/cat <<< $(/usr/bin/jq 'del(.mupibox.AudioDevices)' ${CONFIG}) > ${CONFIG} +update_config 'del(.mupibox.googlettslanguages)' +update_config 'del(.mupibox.mediaCheckTimer)' +update_config 'del(.mupibox.AudioDevices)' # 1.0.8 -/usr/bin/cat <<< $(/usr/bin/jq '.mupibox.googlettslanguages = [{"iso639-1": "ar", "Language": "Arabic"},{"iso639-1": "zh", "Language": "Chinese"},{"iso639-1": "cs","Language": "Czech"},{"iso639-1": "da","Language": "Danish"},{"iso639-1": "nl","Language": "Dutch"},{"iso639-1": "en","Language": "English"},{"iso639-1": "fi","Language": "Finnish"},{"iso639-1": "fr","Language": "French"},{"iso639-1": "de","Language": "German"},{"iso639-1": "el","Language": "Greek"},{"iso639-1": "hi","Language": "Hindi"},{"iso639-1": "it","Language": "Italian"},{"iso639-1": "ja","Language": "Japanese"},{"iso639-1": "no","Language": "Norwegian"},{"iso639-1": "pl","Language": "Polish"},{"iso639-1": "pt","Language": "Portuguese"},{"iso639-1": "ru","Language": "Russian"},{"iso639-1": "es","Language": "Spanish, Castilian"},{"iso639-1": "sv","Language": "Swedish"},{"iso639-1": "tr","Language": "Turkish"},{"iso639-1": "uk","Language": "Ukrainian"}]' ${CONFIG}) > ${CONFIG} +update_config '.mupibox.googlettslanguages = [{"iso639-1": "ar", "Language": "Arabic"},{"iso639-1": "zh", "Language": "Chinese"},{"iso639-1": "cs","Language": "Czech"},{"iso639-1": "da","Language": "Danish"},{"iso639-1": "nl","Language": "Dutch"},{"iso639-1": "en","Language": "English"},{"iso639-1": "fi","Language": "Finnish"},{"iso639-1": "fr","Language": "French"},{"iso639-1": "de","Language": "German"},{"iso639-1": "el","Language": "Greek"},{"iso639-1": "hi","Language": "Hindi"},{"iso639-1": "it","Language": "Italian"},{"iso639-1": "ja","Language": "Japanese"},{"iso639-1": "no","Language": "Norwegian"},{"iso639-1": "pl","Language": "Polish"},{"iso639-1": "pt","Language": "Portuguese"},{"iso639-1": "ru","Language": "Russian"},{"iso639-1": "es","Language": "Spanish, Castilian"},{"iso639-1": "sv","Language": "Swedish"},{"iso639-1": "tr","Language": "Turkish"},{"iso639-1": "uk","Language": "Ukrainian"}]' # 1.0.8 DEVICE=$(/usr/bin/jq -r .spotify.physicalDevice ${CONFIG}) -if [ "$DEVICE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "hifiberry-dac" '.mupibox.physicalDevice = $v' ${CONFIG}) > ${CONFIG} +if [ "$DEVICE" == "null" ]; then + update_config '.mupibox.physicalDevice = $v' --arg v "hifiberry-dac" fi # 1.0.8 MAXVOL=$(/usr/bin/jq -r .mupibox.maxVolume ${CONFIG}) -if [ "$MAXVOL" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "100" '.mupibox.maxVolume = $v' ${CONFIG}) > ${CONFIG} +if [ "$MAXVOL" == "null" ]; then + update_config '.mupibox.maxVolume = $v' --arg v "100" fi # 2.0.0 -XMAS=$(/usr/bin/cat ${CONFIG} | grep xmas) -if [[ -z ${XMAS} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "xmas" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -IMAN=$(/usr/bin/cat ${CONFIG} | grep ironman) -if [[ -z ${IMAN} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "ironman" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -CAP=$(/usr/bin/cat ${CONFIG} | grep captainamerica) -if [[ -z ${CAP} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "captainamerica" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -WOOD=$(/usr/bin/cat ${CONFIG} | grep wood) -if [[ -z ${WOOD} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "wood" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -MATRIX=$(/usr/bin/cat ${CONFIG} | grep matrix) -if [[ -z ${MATRIX} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "matrix" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -MINT=$(/usr/bin/cat ${CONFIG} | grep mint) -if [[ -z ${MINT} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "mint" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -DANGER=$(/usr/bin/cat ${CONFIG} | grep danger) -if [[ -z ${DANGER} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "danger" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi -CINEMA=$(/usr/bin/cat ${CONFIG} | grep cinema) -if [[ -z ${CINEMA} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "cinema" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi +ensure_theme xmas +ensure_theme ironman +ensure_theme captainamerica +ensure_theme wood +ensure_theme matrix +ensure_theme mint +ensure_theme danger +ensure_theme cinema #2.1.0 LEDMAX=$(/usr/bin/jq -r .shim.ledBrightnessMax ${CONFIG}) -if [ "$LEDMAX" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "100" '.shim.ledBrightnessMax = $v' ${CONFIG}) > ${CONFIG} +if [ "$LEDMAX" == "null" ]; then + update_config '.shim.ledBrightnessMax = $v' --arg v "100" fi LEDMIN=$(/usr/bin/jq -r .shim.ledBrightnessMin ${CONFIG}) -if [ "$LEDMIN" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "10" '.shim.ledBrightnessMin = $v' ${CONFIG}) > ${CONFIG} +if [ "$LEDMIN" == "null" ]; then + update_config '.shim.ledBrightnessMin = $v' --arg v "10" fi #3.0.0 PM2RAMLOG=$(/usr/bin/jq -r .pm2.ramlog ${CONFIG}) -if [ "$PM2RAMLOG" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "0" '.pm2.ramlog = $v' ${CONFIG}) > ${CONFIG} +if [ "$PM2RAMLOG" == "null" ]; then + update_config '.pm2.ramlog = $v' --arg v "0" fi -EARTH=$(/usr/bin/cat ${CONFIG} | grep earth) -if [[ -z ${EARTH} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "earth" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -STEAMPUNK=$(/usr/bin/cat ${CONFIG} | grep steampunk) -if [[ -z ${STEAMPUNK} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "steampunk" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -FANTASY_BUTTERFLIES=$(/usr/bin/cat ${CONFIG} | grep fantasybutterflies) -if [[ -z ${FANTASY_BUTTERFLIES} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "fantasybutterflies" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -LINES=$(/usr/bin/cat ${CONFIG} | grep lines) -if [[ -z ${LINES} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "lines" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi +ensure_theme earth +ensure_theme steampunk +ensure_theme fantasybutterflies +ensure_theme lines #3.0.2 -TELEGRAM=$(/usr/bin/cat ${CONFIG} | grep telegram) +TELEGRAM=$(/usr/bin/cat ${CONFIG} | grep -E '"telegram"\s*:') if [[ -z ${TELEGRAM} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "" '.telegram.token = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.telegram.active = false' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "" '.telegram.chatId = $v' ${CONFIG}) > ${CONFIG} + update_config '.telegram.token = $v' --arg v "" + update_config '.telegram.active = false' + update_config '.telegram.chatId = $v' --arg v "" fi #3.0.2 -WLED=$(/usr/bin/cat ${CONFIG} | grep wled) +WLED=$(/usr/bin/cat ${CONFIG} | grep -E '"wled"\s*:') if [[ -z ${WLED} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq '.wled.active = false' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "" '.wled.startup_id = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "" '.wled.main_id = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "" '.wled.shutdown_id = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "255" '.wled.brightness_default = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "128" '.wled.brightness_dimmed = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "true" '.wled.boot_active = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "true" '.wled.shutdown_active = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "115200" '.wled.baud_rate = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "/dev/ttyUSB0" '.wled.com_port = $v' ${CONFIG}) > ${CONFIG} + update_config '.wled.active = false' + update_config '.wled.startup_id = $v' --arg v "" + update_config '.wled.main_id = $v' --arg v "" + update_config '.wled.shutdown_id = $v' --arg v "" + update_config '.wled.brightness_default = $v' --arg v "255" + update_config '.wled.brightness_dimmed = $v' --arg v "128" + update_config '.wled.boot_active = $v' --arg v "true" + update_config '.wled.shutdown_active = $v' --arg v "true" + update_config '.wled.baud_rate = $v' --arg v "115200" + update_config '.wled.com_port = $v' --arg v "/dev/ttyUSB0" fi #3.2.6 -IPCONTROL=$(/usr/bin/cat ${CONFIG} | grep ip_control_backend) -if [[ -z ${IPCONTROL} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "false" '.mupibox.ip_control_backend = $v' ${CONFIG}) > ${CONFIG} -fi -/usr/bin/cat <<< $(/usr/bin/jq 'del(.wled.ip)' ${CONFIG}) > ${CONFIG} -WLED=$(/usr/bin/cat ${CONFIG} | grep com_port) - -if [[ -z ${WLED} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "" '.wled.startup_id = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "255" '.wled.brightness_default = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "128" '.wled.brightness_dimmed = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "true" '.wled.boot_active = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "true" '.wled.shutdown_active = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "115200" '.wled.baud_rate = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "/dev/ttyUSB0" '.wled.com_port = $v' ${CONFIG}) > ${CONFIG} - +IPCONTROL=$(/usr/bin/jq -r '.mupibox.ip_control_backend' ${CONFIG}) +if [ "$IPCONTROL" == "null" ]; then + update_config '.mupibox.ip_control_backend = $v' --arg v "false" +fi +update_config 'del(.wled.ip)' +WLED_COM=$(/usr/bin/jq -r '.wled.com_port' ${CONFIG}) +if [ "$WLED_COM" == "null" ]; then + update_config '.wled.startup_id = $v' --arg v "" + update_config '.wled.brightness_default = $v' --arg v "255" + update_config '.wled.brightness_dimmed = $v' --arg v "128" + update_config '.wled.boot_active = $v' --arg v "true" + update_config '.wled.shutdown_active = $v' --arg v "true" + update_config '.wled.baud_rate = $v' --arg v "115200" + update_config '.wled.com_port = $v' --arg v "/dev/ttyUSB0" fi #3.3.4 GPU=$(/usr/bin/jq -r .chromium.gpu ${CONFIG}) -if [ "$GPU" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.chromium.gpu = false' ${CONFIG}) > ${CONFIG} +if [ "$GPU" == "null" ]; then + update_config '.chromium.gpu = false' fi SCROLLANI=$(/usr/bin/jq -r .chromium.sccrollanimation ${CONFIG}) -if [ "$SCROLLANI" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.chromium.sccrollanimation = false' ${CONFIG}) > ${CONFIG} +if [ "$SCROLLANI" == "null" ]; then + update_config '.chromium.sccrollanimation = false' fi CACHEPATH=$(/usr/bin/jq -r .chromium.cachepath ${CONFIG}) -if [ "$CACHEPATH" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "/home/dietpi/.mupibox/chromium_cache" '.chromium.cachepath = $v' ${CONFIG}) > ${CONFIG} +if [ "$CACHEPATH" == "null" ]; then + update_config '.chromium.cachepath = $v' --arg v "/home/dietpi/.mupibox/chromium_cache" fi CACHESIZE=$(/usr/bin/jq -r .chromium.cachesize ${CONFIG}) -if [ "$CACHESIZE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "128" '.chromium.cachesize = $v' ${CONFIG}) > ${CONFIG} +if [ "$CACHESIZE" == "null" ]; then + update_config '.chromium.cachesize = $v' --arg v "128" fi KIOSKMODE=$(/usr/bin/jq -r .chromium.kiosk ${CONFIG}) -if [ "$KIOSKMODE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.chromium.kiosk = true' ${CONFIG}) > ${CONFIG} +if [ "$KIOSKMODE" == "null" ]; then + update_config '.chromium.kiosk = true' fi MAXCACHE=$(/usr/bin/jq -r .spotify.maxcachesize ${CONFIG}) -if [ "$MAXCACHE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "1073741824" '.spotify.maxcachesize = $v' ${CONFIG}) > ${CONFIG} +if [ "$MAXCACHE" == "null" ]; then + update_config '.spotify.maxcachesize = $v' --arg v "1073741824" fi CACHEPATH=$(/usr/bin/jq -r .spotify.cachepath ${CONFIG}) -if [ "$CACHEPATH" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "/home/dietpi/.cache/spotifyd" '.spotify.cachepath = $v' ${CONFIG}) > ${CONFIG} +if [ "$CACHEPATH" == "null" ]; then + update_config '.spotify.cachepath = $v' --arg v "/home/dietpi/.cache/spotifyd" fi CACHESTATE=$(/usr/bin/jq -r .spotify.cachestate ${CONFIG}) -if [ "$CACHESTATE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.spotify.cachestate = true' ${CONFIG}) > ${CONFIG} +if [ "$CACHESTATE" == "null" ]; then + update_config '.spotify.cachestate = true' fi MQTTDEBUG=$(/usr/bin/jq -r .mqtt.debug ${CONFIG}) -if [ "$MQTTDEBUG" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.mqtt.debug = false' ${CONFIG}) > ${CONFIG} +if [ "$MQTTDEBUG" == "null" ]; then + update_config '.mqtt.debug = false' fi MQTTACTIVE=$(/usr/bin/jq -r .mqtt.active ${CONFIG}) -if [ "$MQTTACTIVE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.mqtt.active = false' ${CONFIG}) > ${CONFIG} +if [ "$MQTTACTIVE" == "null" ]; then + update_config '.mqtt.active = false' fi MQTTBROKER=$(/usr/bin/jq -r .mqtt.broker ${CONFIG}) -if [ "$MQTTBROKER" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "mqtt-example-broker.com" '.mqtt.broker = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTBROKER" == "null" ]; then + update_config '.mqtt.broker = $v' --arg v "mqtt-example-broker.com" fi MQTTPORT=$(/usr/bin/jq -r .mqtt.port ${CONFIG}) -if [ "$MQTTPORT" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "1883" '.mqtt.port = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTPORT" == "null" ]; then + update_config '.mqtt.port = $v' --arg v "1883" fi MQTTTOPIC=$(/usr/bin/jq -r .mqtt.topic ${CONFIG}) -if [ "$MQTTTOPIC" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "MuPiBox/Boxname" '.mqtt.topic = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTTOPIC" == "null" ]; then + update_config '.mqtt.topic = $v' --arg v "MuPiBox/Boxname" fi MQTTBOXNAME=$(/usr/bin/jq -r .mqtt.clientId ${CONFIG}) -if [ "$MQTTBOXNAME" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "Boxname" '.mqtt.clientId = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTBOXNAME" == "null" ]; then + update_config '.mqtt.clientId = $v' --arg v "Boxname" fi MQTTUSERNAME=$(/usr/bin/jq -r .mqtt.username ${CONFIG}) -if [ "$MQTTUSERNAME" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "username" '.mqtt.username = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTUSERNAME" == "null" ]; then + update_config '.mqtt.username = $v' --arg v "username" fi MQTTPASSWORD=$(/usr/bin/jq -r .mqtt.password ${CONFIG}) -if [ "$MQTTPASSWORD" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "password" '.mqtt.password = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTPASSWORD" == "null" ]; then + update_config '.mqtt.password = $v' --arg v "password" fi MQTTREFRESH=$(/usr/bin/jq -r .mqtt.refresh ${CONFIG}) -if [ "$MQTTREFRESH" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "5" '.mqtt.refresh = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTREFRESH" == "null" ]; then + update_config '.mqtt.refresh = $v' --arg v "5" fi MQTTREFRESHIDLE=$(/usr/bin/jq -r .mqtt.refreshIdle ${CONFIG}) -if [ "$MQTTREFRESHIDLE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "30" '.mqtt.refreshIdle = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTREFRESHIDLE" == "null" ]; then + update_config '.mqtt.refreshIdle = $v' --arg v "30" fi MQTTTIMEOUT=$(/usr/bin/jq -r .mqtt.timeout ${CONFIG}) -if [ "$MQTTTIMEOUT" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "60" '.mqtt.timeout = $v' ${CONFIG}) > ${CONFIG} +if [ "$MQTTTIMEOUT" == "null" ]; then + update_config '.mqtt.timeout = $v' --arg v "60" fi HA_MQTT=$(/usr/bin/jq -r .mqtt.ha_topic ${CONFIG}) -if [ "$HA_MQTT" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.mqtt.ha_active = false' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "homeassistant" '.mqtt.ha_topic = $v' ${CONFIG}) > ${CONFIG} +if [ "$HA_MQTT" == "null" ]; then + update_config '.mqtt.ha_active = false' + update_config '.mqtt.ha_topic = $v' --arg v "homeassistant" fi +# H4: the original mupihat-init blob had a malformed jq filter — a +# trailing string literal `"ENERpower 2S2P 10.000mAh"` inside the object +# that produced a jq parse error. It only fired when selected_battery +# was missing on a fresh box, so existing boxes weren't affected, but +# any new install would trip it and (with the old truncate-race) could +# leave CONFIG empty. Rewrite as a clean nested assign. BATTERYCONFIG=$(/usr/bin/jq -r .mupihat.selected_battery ${CONFIG}) -if [ "$BATTERYCONFIG" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "ENERpower 2S2P 10.000mAh" '.mupihat.selected_battery = $v' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '. += {"mupihat": { "battery_types": [{ "name": "Ansmann 2S1P", "config": { "v_100": "8100", "v_75": "7800", "v_50": "7400", "v_25": "7000", "v_0": "6700", "th_warning": "7000", "th_shutdown": "6800" }}, { "name": "ENERpower 2S2P 10.000mAh", "config": { "v_100": "8000", "v_75": "7700", "v_50": "7300", "v_25": "6900", "v_0": "6000", "th_warning": "6500", "th_shutdown": "6150" }}, { "name": "USB-C mode (no battery)", "config": { "v_100": "1", "v_75": "1", "v_50": "1", "v_25": "1", "v_0": "1", "th_warning": "0", "th_shutdown": "0"}}, { "name": "Custom", "config": { "v_100": "8100", "v_75": "7800", "v_50": "7400", "v_25": "7000", "v_0": "6700", "th_warning": "7000", "th_shutdown": "6800"}}], "ENERpower 2S2P 10.000mAh" }}' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.mupihat.hat_active = false' ${CONFIG}) > ${CONFIG} +if [ "$BATTERYCONFIG" == "null" ]; then + update_config '.mupihat.selected_battery = $v' --arg v "ENERpower 2S2P 10.000mAh" + update_config '.mupihat.battery_types = [ + { "name": "Ansmann 2S1P", "config": { "v_100": "8100", "v_75": "7800", "v_50": "7400", "v_25": "7000", "v_0": "6700", "th_warning": "7000", "th_shutdown": "6800" }}, + { "name": "ENERpower 2S2P 10.000mAh","config": { "v_100": "8000", "v_75": "7700", "v_50": "7300", "v_25": "6900", "v_0": "6000", "th_warning": "6500", "th_shutdown": "6150" }}, + { "name": "USB-C mode (no battery)", "config": { "v_100": "1", "v_75": "1", "v_50": "1", "v_25": "1", "v_0": "1", "th_warning": "0", "th_shutdown": "0" }}, + { "name": "Custom", "config": { "v_100": "8100", "v_75": "7800", "v_50": "7400", "v_25": "7000", "v_0": "6700", "th_warning": "7000", "th_shutdown": "6800" }} + ]' + update_config '.mupihat.hat_active = false' fi -LINES=$(/usr/bin/cat ${CONFIG} | grep lines) -if [[ -z ${LINES} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "lines" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi +ensure_theme lines HAT_ACTIVE=$(/usr/bin/jq -r .mupihat.hat_active ${CONFIG}) -if [ "$HAT_ACTIVE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.mupihat.hat_active = false' ${CONFIG}) > ${CONFIG} +if [ "$HAT_ACTIVE" == "null" ]; then + update_config '.mupihat.hat_active = false' fi FAN_ACTIVE=$(/usr/bin/jq -r .fan.fan_active ${CONFIG}) -if [ "$FAN_ACTIVE" == "null" ]; then - /usr/bin/cat <<< $(/usr/bin/jq '.fan.fan_active = false' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.fan.fan_gpio = "13"' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.fan.fan_temp_100 = "75"' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.fan.fan_temp_75 = "65"' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.fan.fan_temp_50 = "55"' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.fan.fan_temp_25 = "45"' ${CONFIG}) > ${CONFIG} +if [ "$FAN_ACTIVE" == "null" ]; then + update_config '.fan.fan_active = false' + update_config '.fan.fan_gpio = "13"' + update_config '.fan.fan_temp_100 = "75"' + update_config '.fan.fan_temp_75 = "65"' + update_config '.fan.fan_temp_50 = "55"' + update_config '.fan.fan_temp_25 = "45"' fi -FORMS=$(/usr/bin/cat ${CONFIG} | grep forms) -if [[ -z ${FORMS} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "forms" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi +ensure_theme forms +ensure_theme comic +ensure_theme mystic -COMIC=$(/usr/bin/cat ${CONFIG} | grep comic) -if [[ -z ${COMIC} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "comic" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} +RESUME=$(/usr/bin/jq -r '.mupibox.resume' ${CONFIG}) +if [ "$RESUME" == "null" ]; then + update_config '.mupibox.resume = 9' fi -MYSTIC=$(/usr/bin/cat ${CONFIG} | grep mystic) -if [[ -z ${MYSTIC} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "mystic" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -RESUME=$(/usr/bin/cat ${CONFIG} | grep resume) -if [[ -z ${RESUME} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq '.mupibox.resume = 9' ${CONFIG}) > ${CONFIG} -fi - -CLONE=$(/usr/bin/cat ${CONFIG} | grep clone-wars) -if [[ -z ${CLONE} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "clone-wars" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -ENTERPRISE=$(/usr/bin/cat ${CONFIG} | grep enterprise) -if [[ -z ${ENTERPRISE} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "enterprise" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -SPIDERMAN=$(/usr/bin/cat ${CONFIG} | grep spiderman) -if [[ -z ${SPIDERMAN} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "spiderman" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -PIKACHU=$(/usr/bin/cat ${CONFIG} | grep pikachu) -if [[ -z ${PIKACHU} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "pikachu" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -SUPERMARIO=$(/usr/bin/cat ${CONFIG} | grep supermario) -if [[ -z ${SUPERMARIO} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "supermario" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi - -DINO=$(/usr/bin/cat ${CONFIG} | grep dinosaur) -if [[ -z ${DINO} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "dinosaur" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} -fi +ensure_theme clone-wars +ensure_theme enterprise +ensure_theme spiderman +ensure_theme pikachu +ensure_theme supermario +ensure_theme dinosaur +ensure_theme unicorn +ensure_theme axolotl -UNICORN=$(/usr/bin/cat ${CONFIG} | grep unicorn) -if [[ -z ${UNICORN} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "unicorn" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} +CUSTOMTHEME=$(/usr/bin/jq -r '.mupibox.customTheme' ${CONFIG}) +if [ "$CUSTOMTHEME" == "null" ]; then + ensure_theme custom + update_config '.mupibox.customTheme = ""' fi -AXOLOTL=$(/usr/bin/cat ${CONFIG} | grep axolotl) -if [[ -z ${AXOLOTL} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "axolotl" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} +ADMININTERFACE=$(/usr/bin/jq -r '.interfacelogin.state' ${CONFIG}) +if [ "$ADMININTERFACE" == "null" ]; then + update_config '.interfacelogin.state = false' + update_config '.interfacelogin.password = $v' --arg v '$2y$10$tA27/5vXFUPgjfjfi7dpTuk.1yOffsg6kuSDQBGTv4sjpVkRlhd76' fi -CUSTOMTHEME=$(/usr/bin/cat ${CONFIG} | grep customTheme) -if [[ -z ${CUSTOMTHEME} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq --arg v "custom" '.mupibox.installedThemes? += [$v]' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq '.mupibox.customTheme = ""' ${CONFIG}) > ${CONFIG} -fi - -ADMININTERFACE=$(/usr/bin/cat ${CONFIG} | grep interfacelogin) -if [[ -z ${ADMININTERFACE} ]]; then - /usr/bin/cat <<< $(/usr/bin/jq '.interfacelogin.state = false' ${CONFIG}) > ${CONFIG} - /usr/bin/cat <<< $(/usr/bin/jq --arg v "$2y$10$tA27/5vXFUPgjfjfi7dpTuk.1yOffsg6kuSDQBGTv4sjpVkRlhd76" '.interfacelogin.password = $v' ${CONFIG}) > ${CONFIG} -fi - -#/usr/bin/cat <<< $(/usr/bin/jq '.mupibox.AudioDevices += [{"tname": "MAX98357A bcm2835-i2s-HiFi HiFi-0","ufname": "MAX98357A bcm2835-i2s-HiFi HiFi-0"},{"tname": "rpi-bcm2835-3.5mm","ufname": "Onboard 3.5mm output"},{"tname": "rpi-bcm2835-hdmi","ufname": "Onboard HDMI output"},{"tname": "hifiberry-amp","ufname": "HifiBerry AMP / AMP+"},{"tname": "hifiberry-dac","ufname": "HifiBerry DAC / MiniAmp"},{"tname": "hifiberry-dacplus","ufname": "HifiBerry DAC+ / DAC+ Pro / AMP2"},{"tname": "usb-dac","ufname": "Any USB Audio DAC (Auto detection)"}]' ${CONFIG}) > ${CONFIG} -/usr/bin/cat <<< $(/usr/bin/jq '.mupibox.AudioDevices = [{"tname": "MAX98357A bcm2835-i2s-HiFi HiFi-0","ufname": "MAX98357A bcm2835-i2s-HiFi HiFi-0"},{"tname": "rpi-bcm2835-3.5mm","ufname": "Onboard 3.5mm output"},{"tname": "rpi-bcm2835-hdmi","ufname": "Onboard HDMI output"},{"tname": "allo-boss-dac-pcm512x-audio","ufname": "Allo Boss DAC"},{"tname": "allo-boss2-dac-audio","ufname": "Allo Boss2 DAC"},{"tname": "allo-digione","ufname": "Allo DigiOne"},{"tname": "allo-katana-dac-audio","ufname": "Allo Katana DAC"},{"tname": "allo-piano-dac-pcm512x-audio","ufname": "Allo Piano DAC"},{"tname": "allo-piano-dac-plus-pcm512x-audio","ufname": "Allo Piano DAC 2.1"},{"tname": "applepi-dac","ufname": "ApplePi DAC (Orchard Audio)"},{"tname": "dionaudio-loco","ufname": "Dion Audio LOCO"},{"tname": "dionaudio-loco-v2","ufname": "Dion Audio LOCO V2"},{"tname": "googlevoicehat-soundcard","ufname": "Google AIY voice kit"},{"tname": "hifiberry-amp","ufname": "HifiBerry AMP / AMP+"},{"tname": "hifiberry-dac","ufname": "HifiBerry DAC / MiniAmp"},{"tname": "hifiberry-dacplus","ufname": "HifiBerry DAC+ / DAC+ Pro / AMP2"},{"tname": "hifiberry-dacplusadc","ufname": "HifiBerry DAC+ADC"},{"tname": "hifiberry-dacplusadcpro","ufname": "HifiBerry DAC+ADC Pro"},{"tname": "hifiberry-dacplusdsp","ufname": "HifiBerry DAC+DSP"},{"tname": "hifiberry-dacplushd","ufname": "HifiBerry DAC+ HD"},{"tname": "hifiberry-digi","ufname": "HifiBerry Digi / Digi+"},{"tname": "hifiberry-digi-pro","ufname": "HifiBerry Digi+ Pro"},{"tname": "i-sabre-q2m","ufname": "AudioPhonics I-Sabre ES9028Q2M / ES9038Q2M"},{"tname": "iqaudio-codec","ufname": "IQaudIO Pi-Codec HAT"},{"tname": "iqaudio-dac","ufname": "IQaudIO DAC audio card"},{"tname": "iqaudio-dacplus","ufname": "Pi-DAC+, Pi-DACZero, Pi-DAC+ Pro, Pi-DigiAMP+"},{"tname": "iqaudio-digi-wm8804-audio","ufname": "Pi-Digi+"},{"tname": "usb-dac","ufname": "Any USB Audio DAC (Auto detection)"}]' ${CONFIG}) > ${CONFIG} +update_config '.mupibox.AudioDevices = [{"tname": "MAX98357A bcm2835-i2s-HiFi HiFi-0","ufname": "MAX98357A bcm2835-i2s-HiFi HiFi-0"},{"tname": "rpi-bcm2835-3.5mm","ufname": "Onboard 3.5mm output"},{"tname": "rpi-bcm2835-hdmi","ufname": "Onboard HDMI output"},{"tname": "allo-boss-dac-pcm512x-audio","ufname": "Allo Boss DAC"},{"tname": "allo-boss2-dac-audio","ufname": "Allo Boss2 DAC"},{"tname": "allo-digione","ufname": "Allo DigiOne"},{"tname": "allo-katana-dac-audio","ufname": "Allo Katana DAC"},{"tname": "allo-piano-dac-pcm512x-audio","ufname": "Allo Piano DAC"},{"tname": "allo-piano-dac-plus-pcm512x-audio","ufname": "Allo Piano DAC 2.1"},{"tname": "applepi-dac","ufname": "ApplePi DAC (Orchard Audio)"},{"tname": "dionaudio-loco","ufname": "Dion Audio LOCO"},{"tname": "dionaudio-loco-v2","ufname": "Dion Audio LOCO V2"},{"tname": "googlevoicehat-soundcard","ufname": "Google AIY voice kit"},{"tname": "hifiberry-amp","ufname": "HifiBerry AMP / AMP+"},{"tname": "hifiberry-dac","ufname": "HifiBerry DAC / MiniAmp"},{"tname": "hifiberry-dacplus","ufname": "HifiBerry DAC+ / DAC+ Pro / AMP2"},{"tname": "hifiberry-dacplusadc","ufname": "HifiBerry DAC+ADC"},{"tname": "hifiberry-dacplusadcpro","ufname": "HifiBerry DAC+ADC Pro"},{"tname": "hifiberry-dacplusdsp","ufname": "HifiBerry DAC+DSP"},{"tname": "hifiberry-dacplushd","ufname": "HifiBerry DAC+ HD"},{"tname": "hifiberry-digi","ufname": "HifiBerry Digi / Digi+"},{"tname": "hifiberry-digi-pro","ufname": "HifiBerry Digi+ Pro"},{"tname": "i-sabre-q2m","ufname": "AudioPhonics I-Sabre ES9028Q2M / ES9038Q2M"},{"tname": "iqaudio-codec","ufname": "IQaudIO Pi-Codec HAT"},{"tname": "iqaudio-dac","ufname": "IQaudIO DAC audio card"},{"tname": "iqaudio-dacplus","ufname": "Pi-DAC+, Pi-DACZero, Pi-DAC+ Pro, Pi-DigiAMP+"},{"tname": "iqaudio-digi-wm8804-audio","ufname": "Pi-Digi+"},{"tname": "usb-dac","ufname": "Any USB Audio DAC (Auto detection)"}]' # delete old entries -/usr/bin/jq 'del(.spotify.username)' ${CONFIG} > /tmp/tmp.$$.json && mv /tmp/tmp.$$.json ${CONFIG} -/usr/bin/jq 'del(.spotify.password)' ${CONFIG} > /tmp/tmp.$$.json && mv /tmp/tmp.$$.json ${CONFIG} - +update_config 'del(.spotify.username)' +update_config 'del(.spotify.password)'