From 8ea772cf6141f34551e80eecb39cbfc1e3ac8144 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 20:25:33 +0100 Subject: [PATCH 1/9] fix(codex): add music ability Signed-off-by: Joost Buskermolen --- config.yaml | 87 +++++----- scripts/config.sh | 6 + scripts/edit_usb.sh | 100 ++++++++++- scripts/present_usb.sh | 109 +++++++++++- scripts/web/blueprints/__init__.py | 2 + scripts/web/blueprints/music.py | 147 ++++++++++++++++ scripts/web/config.py | 18 +- scripts/web/requirements.txt | 6 + scripts/web/services/mode_service.py | 8 +- scripts/web/services/music_service.py | 235 ++++++++++++++++++++++++++ scripts/web/static/js/music.js | 229 +++++++++++++++++++++++++ scripts/web/templates/base.html | 55 +++--- scripts/web/templates/music.html | 195 +++++++++++++++++++++ scripts/web/web_control.py | 8 +- setup_usb.sh | 186 ++++++++++++++++---- 15 files changed, 1280 insertions(+), 111 deletions(-) create mode 100644 scripts/web/blueprints/music.py create mode 100644 scripts/web/requirements.txt create mode 100644 scripts/web/services/music_service.py create mode 100644 scripts/web/static/js/music.js create mode 100644 scripts/web/templates/music.html diff --git a/config.yaml b/config.yaml index f25b71e..321ae44 100644 --- a/config.yaml +++ b/config.yaml @@ -9,19 +9,23 @@ # Installation & Paths # ============================================================================ installation: - gadget_dir: /home/pi/TeslaUSB # Installation directory where TeslaUSB is installed - target_user: pi # Linux user running the TeslaUSB services - mount_dir: /mnt/gadget # Mount directory for USB drives + gadget_dir: /home/pi/TeslaUSB # Installation directory where TeslaUSB is installed + target_user: pi # Linux user running the TeslaUSB services + mount_dir: /mnt/gadget # Mount directory for USB drives # ============================================================================ # Disk Images # ============================================================================ disk_images: - cam_name: usb_cam.img # TeslaCam disk image filename + cam_name: usb_cam.img # TeslaCam disk image filename lightshow_name: usb_lightshow.img # LightShow disk image filename - cam_label: TeslaCam # Filesystem label for TeslaCam drive - lightshow_label: Lightshow # Filesystem label for LightShow drive - boot_fsck_enabled: true # Auto-repair filesystems on boot (recommended) + cam_label: TeslaCam # Filesystem label for TeslaCam drive + lightshow_label: Lightshow # Filesystem label for LightShow drive + music_name: usb_music.img # Music disk image filename (optional third LUN) + music_label: Music # Filesystem label for Music drive (Tesla expects FAT32) + music_enabled: true # Create and present music partition (LUN2) + music_fs: fat32 # Filesystem for music image (fat32 recommended for Tesla) + boot_fsck_enabled: true # Auto-repair filesystems on boot (recommended) # ============================================================================ # Setup Configuration (used only by setup_usb.sh) @@ -29,57 +33,60 @@ disk_images: # These values are specific to the setup process and can be configured here # or left empty ("") for interactive prompts during setup. setup: - part1_size: "" # TeslaCam drive size (e.g., "50G" or leave empty for interactive) - part2_size: "" # LightShow drive size (e.g., "10G" or leave empty for interactive) - reserve_size: "" # Headroom to leave free on Pi filesystem (default: 5G) + part1_size: "" # TeslaCam drive size (e.g., "50G" or leave empty for interactive) + part2_size: "" # LightShow drive size (e.g., "10G" or leave empty for interactive) + part3_size: "" # Music drive size (e.g., "32G" or leave empty to skip/interactive) + reserve_size: "" # Headroom to leave free on Pi filesystem (default: 5G) # ============================================================================ # Network & Security # ============================================================================ network: - samba_password: tesla # Samba password for authenticated user (CHANGE THIS!) - web_port: 80 # Web interface port (80 required for captive portal - do not change) + samba_password: tesla # Samba password for authenticated user (CHANGE THIS!) + web_port: 80 # Web interface port (80 required for captive portal - do not change) # ============================================================================ # Offline Access Point Configuration # ============================================================================ # Fallback WiFi access point for in-car/mobile access when STA WiFi is unavailable offline_ap: - enabled: true # Set to false to disable fallback AP - interface: wlan0 # WiFi interface used for AP/STA - ssid: TeslaUSB # SSID broadcast when AP is active (CHANGE THIS!) - passphrase: teslausb1234 # WPA2 passphrase 8-63 chars (CHANGE THIS!) - channel: 6 # 2.4GHz channel (1-11) - ipv4_cidr: 192.168.4.1/24 # Static IP for AP interface - dhcp_start: 192.168.4.10 # DHCP range start - dhcp_end: 192.168.4.50 # DHCP range end - check_interval: 20 # Seconds between health checks - disconnect_grace: 30 # Seconds offline before starting AP - min_rssi: -70 # Minimum RSSI (dBm) to tear down AP - stable_seconds: 20 # Seconds of good link before stopping AP - ping_target: 8.8.8.8 # Ping target to confirm WAN reachability - retry_seconds: 300 # While AP is active, retry STA join every N seconds - virtual_interface: uap0 # Virtual AP interface name (concurrent mode always enabled) - force_mode: auto # Persistent force mode: auto, force_on, force_off + enabled: true # Set to false to disable fallback AP + interface: wlan0 # WiFi interface used for AP/STA + ssid: TeslaUSB # SSID broadcast when AP is active (CHANGE THIS!) + passphrase: teslausb1234 # WPA2 passphrase 8-63 chars (CHANGE THIS!) + channel: 6 # 2.4GHz channel (1-11) + ipv4_cidr: 192.168.4.1/24 # Static IP for AP interface + dhcp_start: 192.168.4.10 # DHCP range start + dhcp_end: 192.168.4.50 # DHCP range end + check_interval: 20 # Seconds between health checks + disconnect_grace: 30 # Seconds offline before starting AP + min_rssi: -70 # Minimum RSSI (dBm) to tear down AP + stable_seconds: 20 # Seconds of good link before stopping AP + ping_target: 8.8.8.8 # Ping target to confirm WAN reachability + retry_seconds: 300 # While AP is active, retry STA join every N seconds + virtual_interface: uap0 # Virtual AP interface name (concurrent mode always enabled) + force_mode: auto # Persistent force mode: auto, force_on, force_off # ============================================================================ # System Configuration File Paths # ============================================================================ system: - config_file: /boot/firmware/config.txt # Raspberry Pi boot configuration - samba_conf: /etc/samba/smb.conf # Samba configuration file + config_file: /boot/firmware/config.txt # Raspberry Pi boot configuration + samba_conf: /etc/samba/smb.conf # Samba configuration file # ============================================================================ # Web Application Configuration # ============================================================================ web: - secret_key: CHANGE-THIS-TO-A-RANDOM-SECRET-KEY-ON-FIRST-INSTALL # Flask secret key (auto-generated if default) - max_lock_chime_size: 1048576 # 1 MiB (1024 * 1024 bytes) - max_lock_chime_duration: 10.0 # 10 seconds (configurable per Tesla model) - min_lock_chime_duration: 0.3 # 300ms minimum - speed_range_min: 0.5 # Half speed (audio trimmer) - speed_range_max: 2.0 # Double speed (audio trimmer) - speed_step: 0.05 # Fine-grained control (audio trimmer) - lock_chime_filename: LockChime.wav # Active lock chime filename - chimes_folder: Chimes # Folder on part2 where custom chimes are stored - lightshow_folder: LightShow # Folder on part2 where light shows are stored + secret_key: CHANGE-THIS-TO-A-RANDOM-SECRET-KEY-ON-FIRST-INSTALL # Flask secret key (auto-generated if default) + max_lock_chime_size: 1048576 # 1 MiB (1024 * 1024 bytes) + max_lock_chime_duration: 10.0 # 10 seconds (configurable per Tesla model) + min_lock_chime_duration: 0.3 # 300ms minimum + speed_range_min: 0.5 # Half speed (audio trimmer) + speed_range_max: 2.0 # Double speed (audio trimmer) + speed_step: 0.05 # Fine-grained control (audio trimmer) + lock_chime_filename: LockChime.wav # Active lock chime filename + chimes_folder: Chimes # Folder on part2 where custom chimes are stored + lightshow_folder: LightShow # Folder on part2 where light shows are stored + max_upload_size_mb: 2048 # Max accepted upload size for music/lightshow (MiB) + max_upload_chunk_mb: 16 # Chunk size for streaming uploads (MiB) diff --git a/scripts/config.sh b/scripts/config.sh index 06d9a8e..26e3332 100644 --- a/scripts/config.sh +++ b/scripts/config.sh @@ -41,11 +41,16 @@ eval "$(yq -r ' "MNT_DIR=\"" + .installation.mount_dir + "\"", "IMG_CAM_NAME=\"" + .disk_images.cam_name + "\"", "IMG_LIGHTSHOW_NAME=\"" + .disk_images.lightshow_name + "\"", + "IMG_MUSIC_NAME=\"" + (.disk_images.music_name // "usb_music.img") + "\"", "LABEL1=\"" + .disk_images.cam_label + "\"", "LABEL2=\"" + .disk_images.lightshow_label + "\"", + "LABEL3=\"" + (.disk_images.music_label // "Music") + "\"", + "MUSIC_ENABLED=\"" + (.disk_images.music_enabled // true | tostring) + "\"", + "MUSIC_FS=\"" + (.disk_images.music_fs // "fat32") + "\"", "BOOT_FSCK_ENABLED=\"" + (.disk_images.boot_fsck_enabled | tostring) + "\"", "PART1_SIZE=\"" + .setup.part1_size + "\"", "PART2_SIZE=\"" + .setup.part2_size + "\"", + "PART3_SIZE=\"" + (.setup.part3_size // "") + "\"", "RESERVE_SIZE=\"" + .setup.reserve_size + "\"", "SAMBA_PASS=\"" + .network.samba_password + "\"", "WEB_PORT=\"" + (.network.web_port | tostring) + "\"", @@ -74,4 +79,5 @@ eval "$(yq -r ' # ============================================================================ IMG_CAM="$GADGET_DIR/$IMG_CAM_NAME" IMG_LIGHTSHOW="$GADGET_DIR/$IMG_LIGHTSHOW_NAME" +IMG_MUSIC="$GADGET_DIR/$IMG_MUSIC_NAME" STATE_FILE="$GADGET_DIR/state.txt" diff --git a/scripts/edit_usb.sh b/scripts/edit_usb.sh index 7531b91..b477f53 100644 --- a/scripts/edit_usb.sh +++ b/scripts/edit_usb.sh @@ -47,6 +47,10 @@ log_timing "Script start" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/config.sh" +MUSIC_ENABLED_LC="$(printf '%s' "${MUSIC_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')" +MUSIC_ENABLED_BOOL=0 +[ "$MUSIC_ENABLED_LC" = "true" ] && MUSIC_ENABLED_BOOL=1 + # Check for active file operations before proceeding LOCK_FILE="$GADGET_DIR/.quick_edit_part2.lock" LOCK_TIMEOUT=30 @@ -171,7 +175,11 @@ if [ -d "$CONFIGFS_GADGET" ]; then # NOW unmount read-only mounts after gadget is fully disconnected echo "Unmounting read-only mounts from present mode..." RO_MNT_DIR="/mnt/gadget" - for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do + RO_UNMOUNT_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro") + if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + RO_UNMOUNT_TARGETS+=("$RO_MNT_DIR/part3-ro") + fi + for mp in "${RO_UNMOUNT_TARGETS[@]}"; do if mountpoint -q "$mp" 2>/dev/null; then echo " Unmounting $mp..." if ! safe_unmount_dir "$mp"; then @@ -191,7 +199,11 @@ elif lsmod | grep -q '^g_mass_storage'; then # Unmount any read-only mounts from present mode first echo "Unmounting read-only mounts from present mode..." RO_MNT_DIR="/mnt/gadget" - for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do + LEGACY_RO_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro") + if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + LEGACY_RO_TARGETS+=("$RO_MNT_DIR/part3-ro") + fi + for mp in "${LEGACY_RO_TARGETS[@]}"; do if mountpoint -q "$mp" 2>/dev/null; then echo " Unmounting $mp..." if ! safe_unmount_dir "$mp"; then @@ -229,7 +241,11 @@ fi # Verify all mounts are released (quick check - already unmounted above) RO_MNT_DIR="/mnt/gadget" -for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do +VERIFY_RO_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro") +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + VERIFY_RO_TARGETS+=("$RO_MNT_DIR/part3-ro") +fi +for mp in "${VERIFY_RO_TARGETS[@]}"; do if sudo nsenter --mount=/proc/1/ns/mnt mountpoint -q "$mp" 2>/dev/null; then echo " Clearing remaining mount: $mp" safe_unmount_dir "$mp" || true @@ -241,7 +257,11 @@ log_timing "Mounts released" # After clearing LUN files and unmounting, loop devices may still exist # We must detach them before creating fresh ones to avoid accumulation echo "Cleaning up existing loop devices..." -for img in "$IMG_CAM" "$IMG_LIGHTSHOW"; do +LOOP_IMAGES=("$IMG_CAM" "$IMG_LIGHTSHOW") +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + LOOP_IMAGES+=("$IMG_MUSIC") +fi +for img in "${LOOP_IMAGES[@]}"; do for loop in $(losetup -j "$img" 2>/dev/null | cut -d: -f1); do if [ -n "$loop" ]; then echo " Detaching $loop..." @@ -260,7 +280,12 @@ sudo chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" # Ensure previous mounts are cleared before setting up new loop devices # This prevents remounting while drives are still in use -for PART_NUM in 1 2; do +PART_RANGE=(1 2) +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + PART_RANGE+=(3) +fi + +for PART_NUM in "${PART_RANGE[@]}"; do MP="$MNT_DIR/part${PART_NUM}" if mountpoint -q "$MP" 2>/dev/null; then echo "Unmounting existing mount at $MP" @@ -314,6 +339,28 @@ if [ -z "$VERIFY" ]; then fi echo "Verified: $LOOP_LIGHTSHOW is attached to $IMG_LIGHTSHOW" +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo "Setting up loop device for Music..." + LOOP_MUSIC=$(create_loop "$IMG_MUSIC") + if [ -z "$LOOP_MUSIC" ]; then + echo "ERROR: Failed to get/create loop device for $IMG_MUSIC" + sudo losetup -d "$LOOP_CAM" 2>/dev/null || true + sudo losetup -d "$LOOP_LIGHTSHOW" 2>/dev/null || true + exit 1 + fi + echo "Using loop device for Music: $LOOP_MUSIC" + + VERIFY=$(sudo losetup -l | grep "$LOOP_MUSIC" | grep "$IMG_MUSIC" || true) + if [ -z "$VERIFY" ]; then + echo "ERROR: Loop device $LOOP_MUSIC is not attached to $IMG_MUSIC" + sudo losetup -d "$LOOP_CAM" 2>/dev/null || true + sudo losetup -d "$LOOP_LIGHTSHOW" 2>/dev/null || true + sudo losetup -d "$LOOP_MUSIC" 2>/dev/null || true + exit 1 + fi + echo "Verified: $LOOP_MUSIC is attached to $IMG_MUSIC" +fi + sleep 0.5 # Trap to log on failure but NOT detach loop devices (they may be reused/shared) @@ -333,6 +380,17 @@ trap log_failure_on_exit EXIT # Mount drives echo "Mounting drives..." +# Ensure mount points exist (present mode may remove them) +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + sudo mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" +else + sudo mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" +fi +sudo chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" 2>/dev/null || true +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + sudo chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part3" 2>/dev/null || true +fi + # Mount TeslaCam drive (part1) in system mount namespace MP="$MNT_DIR/part1" FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_CAM" 2>/dev/null || echo "unknown") @@ -373,11 +431,36 @@ if ! sudo nsenter --mount=/proc/1/ns/mnt mountpoint -q "$MP"; then fi echo " Mounted $LOOP_LIGHTSHOW at $MP (filesystem: $FS_TYPE)" +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo "Mounting Music drive (part3) in system mount namespace" + MP="$MNT_DIR/part3" + FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_MUSIC" 2>/dev/null || echo "unknown") + echo " Mounting $LOOP_MUSIC at $MP..." + + if [ "$FS_TYPE" = "exfat" ]; then + sudo nsenter --mount=/proc/1/ns/mnt mount -t exfat -o rw,uid=$UID_VAL,gid=$GID_VAL,umask=000 "$LOOP_MUSIC" "$MP" + elif [ "$FS_TYPE" = "vfat" ]; then + sudo nsenter --mount=/proc/1/ns/mnt mount -t vfat -o rw,uid=$UID_VAL,gid=$GID_VAL,umask=000 "$LOOP_MUSIC" "$MP" + else + echo " Warning: Unknown filesystem type '$FS_TYPE', attempting generic mount" + sudo nsenter --mount=/proc/1/ns/mnt mount -o rw "$LOOP_MUSIC" "$MP" + fi + + if ! sudo nsenter --mount=/proc/1/ns/mnt mountpoint -q "$MP"; then + echo "Error: Failed to mount $LOOP_MUSIC at $MP" >&2 + exit 1 + fi + echo " Mounted $LOOP_MUSIC at $MP (filesystem: $FS_TYPE)" +fi + # Refresh Samba so shares expose the freshly mounted drives echo "Refreshing Samba shares..." # Close any cached shares and reload config (faster than full restart) sudo smbcontrol all close-share gadget_part1 2>/dev/null || true sudo smbcontrol all close-share gadget_part2 2>/dev/null || true +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + sudo smbcontrol all close-share gadget_part3 2>/dev/null || true +fi # If Samba is running, reload config is sufficient; otherwise start it if systemctl is-active --quiet smbd; then sudo smbcontrol all reload-config 2>/dev/null || true @@ -393,6 +476,9 @@ fi if [ -d "$MNT_DIR/part2" ]; then echo " Part2 files: $(ls -A "$MNT_DIR/part2" 2>/dev/null | wc -l) items" fi +if [ $MUSIC_ENABLED_BOOL -eq 1 ] && [ -d "$MNT_DIR/part3" ]; then + echo " Part3 files: $(ls -A "$MNT_DIR/part3" 2>/dev/null | wc -l) items" +fi echo "Updating mode state..." echo "edit" > "$STATE_FILE" @@ -406,6 +492,10 @@ echo "Drives are now mounted locally and accessible via Samba shares:" echo " - Part 1: $MNT_DIR/part1" echo " - Part 2: $MNT_DIR/part2" echo " - Samba shares: gadget_part1, gadget_part2" +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo " - Part 3: $MNT_DIR/part3" + echo " - Samba shares: gadget_part3 (music)" +fi log_timing "Script completed successfully" echo "[PERFORMANCE] Total execution time: $(($(date +%s%3N) - SCRIPT_START))ms" diff --git a/scripts/present_usb.sh b/scripts/present_usb.sh index 924a9cc..09b04d4 100644 --- a/scripts/present_usb.sh +++ b/scripts/present_usb.sh @@ -47,6 +47,10 @@ log_timing "Script start" source "$SCRIPT_DIR/config.sh" log_timing "Config loaded" +MUSIC_ENABLED_LC="$(printf '%s' "${MUSIC_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')" +MUSIC_ENABLED_BOOL=0 +[ "$MUSIC_ENABLED_LC" = "true" ] && MUSIC_ENABLED_BOOL=1 + # Check for active file operations before proceeding LOCK_FILE="$GADGET_DIR/.quick_edit_part2.lock" LOCK_TIMEOUT=30 @@ -138,7 +142,11 @@ unmount_with_retry() { # Unmount drives if mounted log_timing "Starting unmount sequence" echo "Unmounting drives..." -for mp in "$MNT_DIR/part1" "$MNT_DIR/part2"; do +UNMOUNT_TARGETS=("$MNT_DIR/part1" "$MNT_DIR/part2") +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + UNMOUNT_TARGETS+=("$MNT_DIR/part3") +fi +for mp in "${UNMOUNT_TARGETS[@]}"; do # Sync each partition before unmounting if mountpoint -q "$mp" 2>/dev/null; then echo " Syncing $mp..." @@ -155,7 +163,11 @@ log_timing "Drives unmounted" # Also unmount any existing read-only mounts from previous present mode echo "Unmounting any existing read-only mounts..." RO_MNT_DIR="/mnt/gadget" -for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do +RO_UNMOUNT_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro") +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + RO_UNMOUNT_TARGETS+=("$RO_MNT_DIR/part3-ro") +fi +for mp in "${RO_UNMOUNT_TARGETS[@]}"; do if mountpoint -q "$mp" 2>/dev/null || sudo nsenter --mount=/proc/1/ns/mnt -- mountpoint -q "$mp" 2>/dev/null; then echo " Unmounting $mp..." unmount_with_retry "$mp" || true @@ -169,7 +181,11 @@ log_timing "Final sync completed" # Clean up existing loop devices for our images # After unmounting, detach any lingering loop devices to avoid accumulation echo "Cleaning up existing loop devices..." -for img in "$IMG_CAM" "$IMG_LIGHTSHOW"; do +LOOP_IMAGES=("$IMG_CAM" "$IMG_LIGHTSHOW") +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + LOOP_IMAGES+=("$IMG_MUSIC") +fi +for img in "${LOOP_IMAGES[@]}"; do for loop in $(losetup -j "$img" 2>/dev/null | cut -d: -f1); do if [ -n "$loop" ]; then echo " Detaching $loop..." @@ -187,6 +203,7 @@ log_timing "Loop devices cleaned up" # These variables will hold loop devices for reuse later in the script LOOP_CAM="" LOOP_LIGHTSHOW="" +LOOP_MUSIC="" if [ "${BOOT_FSCK_ENABLED:-false}" = "true" ]; then echo "Running boot-time filesystem check and repair..." @@ -194,10 +211,16 @@ if [ "${BOOT_FSCK_ENABLED:-false}" = "true" ]; then # Create loop devices for fsck (will be reused for local mounts too) LOOP_CAM=$(create_loop "$IMG_CAM") LOOP_LIGHTSHOW=$(create_loop "$IMG_LIGHTSHOW") + if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + LOOP_MUSIC=$(create_loop "$IMG_MUSIC") + fi # Detect filesystem types FS_TYPE_CAM=$(sudo blkid -o value -s TYPE "$LOOP_CAM" 2>/dev/null || echo "exfat") FS_TYPE_LIGHTSHOW=$(sudo blkid -o value -s TYPE "$LOOP_LIGHTSHOW" 2>/dev/null || echo "vfat") + if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + FS_TYPE_MUSIC=$(sudo blkid -o value -s TYPE "$LOOP_MUSIC" 2>/dev/null || echo "vfat") + fi # Run fsck on TeslaCam (part1) echo " Checking TeslaCam ($FS_TYPE_CAM)..." @@ -231,7 +254,24 @@ if [ "${BOOT_FSCK_ENABLED:-false}" = "true" ]; then fi fi - # Note: Loop devices (LOOP_CAM, LOOP_LIGHTSHOW) preserved for later reuse in local mounts + if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo " Checking Music ($FS_TYPE_MUSIC)..." + if [ "$FS_TYPE_MUSIC" = "exfat" ]; then + if sudo fsck.exfat -p "$LOOP_MUSIC" 2>&1; then + echo " ✓ Music: clean" + else + echo " ⚠ Music: repaired or has issues" + fi + else + if sudo fsck.vfat -p "$LOOP_MUSIC" 2>&1; then + echo " ✓ Music: clean" + else + echo " ⚠ Music: repaired or has issues" + fi + fi + fi + + # Note: Loop devices (LOOP_CAM, LOOP_LIGHTSHOW, LOOP_MUSIC) preserved for later reuse in local mounts echo " Loop devices preserved for local mount reuse" log_timing "Boot fsck completed" @@ -241,7 +281,11 @@ fi # Remove mount directories to avoid accidental access when unmounted echo "Removing mount directories..." -for mp in "$MNT_DIR/part1" "$MNT_DIR/part2"; do +REMOVE_TARGETS=("$MNT_DIR/part1" "$MNT_DIR/part2") +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + REMOVE_TARGETS+=("$MNT_DIR/part3") +fi +for mp in "${REMOVE_TARGETS[@]}"; do # Check if mounted in host namespace if sudo nsenter --mount=/proc/1/ns/mnt -- mountpoint -q "$mp" 2>/dev/null || mountpoint -q "$mp" 2>/dev/null; then echo " Skipping removal of $mp (still mounted)" >&2 @@ -358,6 +402,15 @@ echo 1 | sudo tee functions/mass_storage.usb0/lun.1/ro > /dev/null # Read-only echo 0 | sudo tee functions/mass_storage.usb0/lun.1/cdrom > /dev/null echo "$IMG_LIGHTSHOW" | sudo tee functions/mass_storage.usb0/lun.1/file > /dev/null +# Configure LUN 2: Music (READ-ONLY to Tesla) +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + sudo mkdir -p functions/mass_storage.usb0/lun.2 + echo 1 | sudo tee functions/mass_storage.usb0/lun.2/removable > /dev/null + echo 1 | sudo tee functions/mass_storage.usb0/lun.2/ro > /dev/null + echo 0 | sudo tee functions/mass_storage.usb0/lun.2/cdrom > /dev/null + echo "$IMG_MUSIC" | sudo tee functions/mass_storage.usb0/lun.2/file > /dev/null +fi + # Link function to configuration sudo ln -s functions/mass_storage.usb0 configs/c.1/ @@ -382,7 +435,11 @@ chown "$TARGET_USER:$TARGET_USER" "$STATE_FILE" 2>/dev/null || true # - Best used when Tesla is not actively recording (e.g., after driving) echo "Mounting partitions locally in read-only mode..." RO_MNT_DIR="/mnt/gadget" -sudo mkdir -p "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro" +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + sudo mkdir -p "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro" "$RO_MNT_DIR/part3-ro" +else + sudo mkdir -p "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro" +fi # Get user IDs for mounting UID_VAL=$(id -u "$TARGET_USER") @@ -435,13 +492,49 @@ if [ -n "$LOOP_LIGHTSHOW" ] && [ -e "$LOOP_LIGHTSHOW" ]; then else echo " Warning: Unable to attach loop device for Lightshow read-only mounting" fi + +# Mount Music image (part3) when enabled - reuse fsck loop device if available, otherwise create +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + if [ -z "$LOOP_MUSIC" ] || [ ! -e "$LOOP_MUSIC" ]; then + LOOP_MUSIC=$(create_loop "$IMG_MUSIC") + fi + + if [ -n "$LOOP_MUSIC" ] && [ -e "$LOOP_MUSIC" ]; then + FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_MUSIC" 2>/dev/null || echo "vfat") + + echo " Mounting ${LOOP_MUSIC} (Music) at $RO_MNT_DIR/part3-ro (read-only)..." + + if [ "$FS_TYPE" = "vfat" ]; then + sudo nsenter --mount=/proc/1/ns/mnt mount -t vfat -o ro,uid=$UID_VAL,gid=$GID_VAL,umask=022 "$LOOP_MUSIC" "$RO_MNT_DIR/part3-ro" + elif [ "$FS_TYPE" = "exfat" ]; then + sudo nsenter --mount=/proc/1/ns/mnt mount -t exfat -o ro,uid=$UID_VAL,gid=$GID_VAL,umask=022 "$LOOP_MUSIC" "$RO_MNT_DIR/part3-ro" + else + sudo nsenter --mount=/proc/1/ns/mnt mount -o ro "$LOOP_MUSIC" "$RO_MNT_DIR/part3-ro" + fi + + echo " Mounted successfully at $RO_MNT_DIR/part3-ro" + else + echo " Warning: Unable to attach loop device for Music read-only mounting" + fi +fi log_timing "USB gadget fully configured and mounted" echo "USB gadget presented successfully!" -echo "The Pi should now appear as TWO USB storage devices when connected:" +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo "The Pi should now appear as THREE USB storage devices when connected:" +else + echo "The Pi should now appear as TWO USB storage devices when connected:" +fi echo " - LUN 0: TeslaCam (Read-Write) - Tesla can record dashcam footage" echo " - LUN 1: Lightshow (Read-Only) - Optimized read performance for Tesla" -echo "Read-only mounts available at: $RO_MNT_DIR/part1-ro and $RO_MNT_DIR/part2-ro" +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo " - LUN 2: Music (Read-Only) - Media files for Tesla audio" +fi +if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then + echo "Read-only mounts available at: $RO_MNT_DIR/part1-ro, $RO_MNT_DIR/part2-ro, $RO_MNT_DIR/part3-ro" +else + echo "Read-only mounts available at: $RO_MNT_DIR/part1-ro and $RO_MNT_DIR/part2-ro" +fi log_timing "Script completed successfully" echo "[PERFORMANCE] Total execution time: $(($(date +%s%3N) - SCRIPT_START))ms" diff --git a/scripts/web/blueprints/__init__.py b/scripts/web/blueprints/__init__.py index 32cb9a4..4b2a630 100644 --- a/scripts/web/blueprints/__init__.py +++ b/scripts/web/blueprints/__init__.py @@ -9,6 +9,7 @@ from .cleanup import cleanup_bp from .api import api_bp from .fsck import fsck_bp +from .music import music_bp from .captive_portal import captive_portal_bp, catch_all_redirect __all__ = [ @@ -21,6 +22,7 @@ 'cleanup_bp', 'api_bp', 'fsck_bp', + 'music_bp', 'captive_portal_bp', 'catch_all_redirect' ] diff --git a/scripts/web/blueprints/music.py b/scripts/web/blueprints/music.py new file mode 100644 index 0000000..c009aac --- /dev/null +++ b/scripts/web/blueprints/music.py @@ -0,0 +1,147 @@ +"""Blueprint for music library management.""" + +import logging +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app + +from utils import get_base_context, format_file_size +from services.music_service import ( + list_music_files, + save_file, + handle_chunk, + delete_music_file, + UploadError, + generate_upload_id, + require_edit_mode, +) + +music_bp = Blueprint("music", __name__, url_prefix="/music") +logger = logging.getLogger(__name__) + + +@music_bp.route("/") +def music_home(): + ctx = get_base_context() + files, error, total_size, free_bytes = list_music_files() + return render_template( + "music.html", + page="music", + **ctx, + files=files, + error=error, + total_size=total_size, + free_bytes=free_bytes, + format_file_size=format_file_size, + max_upload_size_mb=current_app.config.get("MAX_CONTENT_LENGTH", 0) // (1024 * 1024), + max_upload_chunk_mb=current_app.config.get("MAX_FORM_MEMORY_SIZE", 0) // (1024 * 1024), + ) + + +@music_bp.route("/upload", methods=["POST"]) +def upload_music(): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + try: + require_edit_mode() + except UploadError as exc: + if is_ajax: + return jsonify({"success": False, "error": str(exc)}), 400 + flash(str(exc), "error") + return redirect(url_for("music.music_home")) + + files = request.files.getlist("music_files") + if not files: + if is_ajax: + return jsonify({"success": False, "error": "No files selected"}), 400 + flash("No files selected", "error") + return redirect(url_for("music.music_home")) + + successes = 0 + messages = [] + for file in files: + if not file or not file.filename: + continue + ok, msg = save_file(file) + if ok: + successes += 1 + messages.append(msg) + + if is_ajax: + status = 200 if successes else 400 + return jsonify({ + "success": successes > 0, + "messages": messages, + "uploaded": successes, + }), status + + if successes: + flash(f"Uploaded {successes} file(s)", "success") + else: + flash("Failed to upload files", "error") + return redirect(url_for("music.music_home", _=request.args.get('_', 0))) + + +@music_bp.route("/upload_chunk", methods=["POST"]) +def upload_chunk(): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + try: + require_edit_mode() + except UploadError as exc: + if is_ajax: + return jsonify({"success": False, "error": str(exc)}), 400 + flash(str(exc), "error") + return redirect(url_for("music.music_home")) + + try: + upload_id = request.args.get("upload_id") or request.form.get("upload_id") or generate_upload_id() + filename = request.args.get("filename") or request.form.get("filename") + chunk_index = int(request.args.get("chunk_index") or request.form.get("chunk_index") or 0) + total_chunks = int(request.args.get("total_chunks") or request.form.get("total_chunks") or 1) + total_size = int(request.args.get("total_size") or request.form.get("total_size") or request.headers.get("X-File-Size") or 0) + except (TypeError, ValueError): + return jsonify({"success": False, "error": "Invalid chunk metadata"}), 400 + + if not filename: + return jsonify({"success": False, "error": "Missing filename"}), 400 + if total_size <= 0: + return jsonify({"success": False, "error": "Missing file size"}), 400 + + try: + success, message, finalized = handle_chunk( + upload_id=upload_id, + filename=filename, + chunk_index=chunk_index, + total_chunks=total_chunks, + total_size=total_size, + stream=request.stream, + ) + except UploadError as exc: + logger.warning("Chunk upload rejected: %s", exc) + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: # pylint: disable=broad-except + logger.error("Chunk upload failed: %s", exc, exc_info=True) + return jsonify({"success": False, "error": "Server error"}), 500 + + return jsonify({"success": success, "message": message, "finalized": finalized}) + + +@music_bp.route("/delete/", methods=["POST"]) +def delete_music(filename): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + try: + require_edit_mode() + except UploadError as exc: + if is_ajax: + return jsonify({"success": False, "error": str(exc)}), 400 + flash(str(exc), "error") + return redirect(url_for("music.music_home")) + + try: + ok, msg = delete_music_file(filename) + except UploadError as exc: + ok, msg = False, str(exc) + + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + return redirect(url_for("music.music_home", _=request.args.get('_', 0))) diff --git a/scripts/web/config.py b/scripts/web/config.py index 9d35e8c..eed3c11 100644 --- a/scripts/web/config.py +++ b/scripts/web/config.py @@ -36,6 +36,10 @@ # Disk Images IMG_CAM_NAME = config['disk_images']['cam_name'] IMG_LIGHTSHOW_NAME = config['disk_images']['lightshow_name'] +IMG_MUSIC_NAME = config['disk_images'].get('music_name', 'usb_music.img') +MUSIC_ENABLED = bool(config['disk_images'].get('music_enabled', False)) +MUSIC_FS = config['disk_images'].get('music_fs', 'fat32') +MUSIC_LABEL = config['disk_images'].get('music_label', 'Music') # Network & Security WEB_PORT = config['network']['web_port'] @@ -63,6 +67,8 @@ # Light Show Configuration LIGHT_SHOW_FOLDER = config['web']['lightshow_folder'] +MAX_UPLOAD_SIZE_MB = int(config['web'].get('max_upload_size_mb', 2048)) +MAX_UPLOAD_CHUNK_MB = int(config['web'].get('max_upload_chunk_mb', 16)) # ============================================================================ # ADVANCED SETTINGS - Computed values (don't modify these) @@ -75,8 +81,16 @@ STATE_FILE = os.path.join(GADGET_DIR, "state.txt") # USB drive configuration -USB_PARTITIONS = ("part1", "part2") -PART_LABEL_MAP = {"part1": "gadget_part1", "part2": "gadget_part2"} +USB_PARTITIONS = tuple( + part for part in ("part1", "part2", "part3") + if part != "part3" or MUSIC_ENABLED +) + +PART_LABEL_MAP = { + "part1": "gadget_part1", + "part2": "gadget_part2", + **({"part3": "gadget_part3"} if MUSIC_ENABLED else {}), +} # Thumbnail configuration THUMBNAIL_CACHE_DIR = os.path.join(GADGET_DIR, "thumbnails") diff --git a/scripts/web/requirements.txt b/scripts/web/requirements.txt new file mode 100644 index 0000000..6eb9eef --- /dev/null +++ b/scripts/web/requirements.txt @@ -0,0 +1,6 @@ +flask +waitress +pyyaml +werkzeug +av +pillow diff --git a/scripts/web/services/mode_service.py b/scripts/web/services/mode_service.py index 02da039..ef2c2f4 100644 --- a/scripts/web/services/mode_service.py +++ b/scripts/web/services/mode_service.py @@ -21,6 +21,7 @@ MNT_DIR, USB_PARTITIONS, MODE_DISPLAY, + PART_LABEL_MAP, ) logger = logging.getLogger(__name__) @@ -97,10 +98,9 @@ def mode_display(): if token == "edit": hostname = socket.gethostname() - share_paths = [ - f"\\\\{hostname}\\gadget_part1", - f"\\\\{hostname}\\gadget_part2", - ] + for part in USB_PARTITIONS: + share_name = PART_LABEL_MAP.get(part, f"gadget_{part}") + share_paths.append(f"\\\\{hostname}\\{share_name}") return token, label, css_class, share_paths diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py new file mode 100644 index 0000000..3630c5c --- /dev/null +++ b/scripts/web/services/music_service.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Music upload and management helpers.""" + +import os +import uuid +import logging +from typing import Tuple + +from werkzeug.utils import secure_filename + +from config import MAX_UPLOAD_CHUNK_MB, MAX_UPLOAD_SIZE_MB +from services.partition_service import get_mount_path +from services.samba_service import close_samba_share +from services.mode_service import current_mode + +logger = logging.getLogger(__name__) + +# Allow common Tesla-friendly audio formats +ALLOWED_EXTS = {".mp3", ".flac", ".wav", ".aac", ".m4a"} +CHUNK_SIZE = MAX_UPLOAD_CHUNK_MB * 1024 * 1024 +MAX_UPLOAD_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024 + + +class UploadError(Exception): + """Raised for user-facing upload errors.""" + + +def _fs_free_bytes(path: str) -> int: + """Return available bytes for the filesystem containing path.""" + stat = os.statvfs(path) + return stat.f_bavail * stat.f_frsize + + +def _fsync_path(path: str) -> None: + """fsync a file path safely.""" + with open(path, "rb") as fh: + os.fsync(fh.fileno()) + + +def _fsync_dir(path: str) -> None: + """fsync a directory to persist renames.""" + fd = os.open(path, os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _stream_to_file(stream, dest_path: str) -> int: + """Stream request data to dest_path without loading into memory.""" + total = 0 + with open(dest_path, "ab", buffering=0) as fh: + while True: + chunk = stream.read(CHUNK_SIZE) + if not chunk: + break + total += len(chunk) + fh.write(chunk) + return total + + +def _ensure_music_mount() -> Tuple[str, str]: + """Return the music mount path or an error string.""" + mount_path = get_mount_path("part3") + if not mount_path: + return "", "Music drive not mounted. Switch to Edit mode and try again." + if not os.path.isdir(mount_path): + return "", "Music drive is unavailable." + return mount_path, "" + + +def _validate_filename(name: str) -> str: + safe = secure_filename(name) + if not safe: + raise UploadError("Invalid filename") + ext = os.path.splitext(safe)[1].lower() + if ext not in ALLOWED_EXTS: + raise UploadError("Unsupported file type. Allowed: mp3, flac, wav, aac, m4a") + return safe + + +def list_music_files(): + mount_path, err = _ensure_music_mount() + if err: + return [], err, 0, 0 + + music_files = [] + total_size = 0 + try: + for entry in os.scandir(mount_path): + if not entry.is_file(): + continue + ext = os.path.splitext(entry.name)[1].lower() + if ext not in ALLOWED_EXTS: + continue + stat = entry.stat() + music_files.append({ + "name": entry.name, + "size": stat.st_size, + "mtime": int(stat.st_mtime), + }) + total_size += stat.st_size + except OSError as e: + logger.warning("Could not read music directory: %s", e) + return [], "Unable to read music directory", 0, 0 + + free_bytes = _fs_free_bytes(mount_path) + music_files.sort(key=lambda x: x["name"].lower()) + return music_files, "", total_size, free_bytes + + +def _prepare_paths(filename: str, mount_path: str): + music_dir = mount_path + tmp_dir = os.path.join(music_dir, ".uploads") + os.makedirs(tmp_dir, exist_ok=True) + tmp_path = os.path.join(tmp_dir, f"{filename}.upload") + final_path = os.path.join(music_dir, filename) + return tmp_dir, tmp_path, final_path + + +def save_file(file_storage) -> Tuple[bool, str]: + """Stream a Werkzeug FileStorage to the music partition with fsync + atomic rename.""" + mount_path, err = _ensure_music_mount() + if err: + return False, err + + filename = _validate_filename(file_storage.filename) + tmp_dir, tmp_path, final_path = _prepare_paths(filename, mount_path) + + # Free space check + file_storage.stream.seek(0, os.SEEK_END) + incoming_size = file_storage.stream.tell() + file_storage.stream.seek(0) + if incoming_size > MAX_UPLOAD_BYTES: + return False, f"File too large (>{MAX_UPLOAD_SIZE_MB} MiB limit)" + + free_bytes = _fs_free_bytes(mount_path) + if free_bytes <= incoming_size + (4 * 1024 * 1024): + return False, "Not enough free space on Music drive" + + # Stream to temp then atomically move + if os.path.exists(tmp_path): + os.remove(tmp_path) + + _stream_to_file(file_storage.stream, tmp_path) + _fsync_path(tmp_path) + _fsync_dir(tmp_dir) + + os.replace(tmp_path, final_path) + _fsync_dir(mount_path) + try: + close_samba_share("part3") + except Exception: + pass + return True, f"Uploaded {filename}" + + +def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: int, total_size: int, stream) -> Tuple[bool, str, bool]: + """ + Append a chunk to the staged upload file. + + Returns (success, message, is_finalized) + """ + if total_size > MAX_UPLOAD_BYTES: + raise UploadError(f"File too large (>{MAX_UPLOAD_SIZE_MB} MiB limit)") + + mount_path, err = _ensure_music_mount() + if err: + return False, err, False + + filename = _validate_filename(filename) + tmp_dir = os.path.join(mount_path, ".uploads") + os.makedirs(tmp_dir, exist_ok=True) + staged_path = os.path.join(tmp_dir, f"{upload_id}.part") + final_path = os.path.join(mount_path, filename) + + # On first chunk, ensure space and clear any stale parts + if chunk_index == 0: + if os.path.exists(staged_path): + os.remove(staged_path) + free_bytes = _fs_free_bytes(mount_path) + if free_bytes <= total_size + (4 * 1024 * 1024): + raise UploadError("Not enough free space on Music drive") + + written = _stream_to_file(stream, staged_path) + logger.debug("Chunk %s/%s wrote %s bytes", chunk_index + 1, total_chunks, written) + + if chunk_index < total_chunks - 1: + return True, "Chunk stored", False + + # Final chunk: validate size then atomically move + actual_size = os.path.getsize(staged_path) + if actual_size != total_size: + raise UploadError(f"Size mismatch. Expected {total_size} bytes, got {actual_size}") + + _fsync_path(staged_path) + _fsync_dir(tmp_dir) + os.replace(staged_path, final_path) + _fsync_dir(mount_path) + + try: + close_samba_share("part3") + except Exception: + pass + + return True, f"Uploaded {filename}", True + + +def delete_music_file(filename: str) -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + filename = _validate_filename(filename) + target = os.path.join(mount_path, filename) + if not os.path.isfile(target): + return False, "File not found" + + try: + os.remove(target) + _fsync_dir(mount_path) + close_samba_share("part3") + except Exception as exc: + logger.error("Failed to delete %s: %s", filename, exc) + return False, "Unable to delete file" + return True, f"Deleted {filename}" + + +def require_edit_mode(): + if current_mode() != "edit": + raise UploadError("Switch to Edit mode to upload music.") + + +def generate_upload_id() -> str: + return uuid.uuid4().hex diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js new file mode 100644 index 0000000..54c0924 --- /dev/null +++ b/scripts/web/static/js/music.js @@ -0,0 +1,229 @@ +(function () { + const page = document.getElementById('music-page'); + if (!page) return; + + const chunkMb = parseInt(page.dataset.chunkMb || '8', 10) || 8; + const maxMb = parseInt(page.dataset.maxMb || '0', 10) || 0; + const uploadUrl = page.dataset.uploadUrl; + const deleteTemplate = page.dataset.deleteUrl; + const chunkSize = Math.max(1, chunkMb) * 1024 * 1024; + const maxBytes = maxMb > 0 ? maxMb * 1024 * 1024 : null; + + const dropZone = document.getElementById('dropZone'); + const fileInput = document.getElementById('fileInput'); + const fileList = document.getElementById('fileList'); + const startBtn = document.getElementById('startUpload'); + const clearBtn = document.getElementById('clearSelection'); + const statusEl = document.getElementById('uploadStatus'); + const table = page.querySelector('table'); + + let queue = []; + let uploading = false; + + function setStatus(msg, isError) { + statusEl.textContent = msg || ''; + statusEl.className = isError ? 'error' : 'muted'; + } + + function renderQueue() { + fileList.innerHTML = ''; + queue.forEach((item, idx) => { + const row = document.createElement('div'); + row.className = 'file-row'; + row.innerHTML = ` +
+
${item.file.name}
+
${formatBytes(item.file.size)}
+
+ + `; + const progress = document.createElement('div'); + progress.className = 'progress-bar'; + const bar = document.createElement('span'); + progress.appendChild(bar); + row.appendChild(progress); + item.progressEl = bar; + fileList.appendChild(row); + }); + const disabled = queue.length === 0 || uploading; + startBtn.disabled = disabled; + clearBtn.disabled = queue.length === 0 || uploading; + } + + function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let val = bytes; + let i = 0; + while (val >= 1024 && i < units.length) { + val /= 1024; + i += 1; + } + return `${val.toFixed(val >= 10 ? 0 : 1)} ${units[i - 1] || 'KB'}`; + } + + function addFiles(files) { + const allowed = ['.mp3', '.flac', '.wav', '.aac', '.m4a']; + Array.from(files).forEach((file) => { + const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); + if (!allowed.includes(ext)) { + setStatus(`${file.name} skipped (unsupported type)`, true); + return; + } + if (maxBytes && file.size > maxBytes) { + setStatus(`${file.name} exceeds ${maxMb} MiB limit`, true); + return; + } + queue.push({ file, progressEl: null }); + }); + if (queue.length > 0) { + setStatus(`${queue.length} file(s) queued`, false); + } + renderQueue(); + } + + async function uploadFile(item) { + const file = item.file; + const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize)); + const uploadId = crypto.randomUUID ? crypto.randomUUID() : `upload-${Date.now()}-${Math.random()}`; + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { + const start = chunkIndex * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + const params = new URLSearchParams({ + upload_id: uploadId, + filename: file.name, + chunk_index: String(chunkIndex), + total_chunks: String(totalChunks), + total_size: String(file.size), + }); + + const res = await fetch(`${uploadUrl}?${params.toString()}`, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/octet-stream', + 'X-File-Size': String(file.size), + }, + body: chunk, + }); + + let data; + try { + data = await res.json(); + } catch (e) { + setStatus('Upload failed: invalid server response', true); + return false; + } + + if (!res.ok || !data.success) { + const message = data && data.error ? data.error : 'Upload failed'; + setStatus(`${file.name}: ${message}`, true); + return false; + } + + const pct = Math.round(((chunkIndex + 1) / totalChunks) * 100); + if (item.progressEl) item.progressEl.style.width = `${pct}%`; + } + + setStatus(`${file.name} uploaded`, false); + return true; + } + + async function uploadQueue() { + if (queue.length === 0 || uploading) return; + uploading = true; + startBtn.disabled = true; + clearBtn.disabled = true; + + for (const item of queue) { + if (item.progressEl) item.progressEl.style.width = '0%'; + const ok = await uploadFile(item); + if (!ok) break; + } + + uploading = false; + startBtn.disabled = queue.length === 0; + clearBtn.disabled = queue.length === 0; + // Refresh page to show new files + if (!statusEl.classList.contains('error')) { + setTimeout(() => window.location.reload(), 600); + } + } + + function clearQueue() { + if (uploading) return; + queue = []; + fileInput.value = ''; + renderQueue(); + setStatus('', false); + } + + dropZone.addEventListener('click', () => fileInput.click()); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('dragging'); + }); + + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragging')); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragging'); + if (e.dataTransfer.files) { + addFiles(e.dataTransfer.files); + } + }); + + fileInput.addEventListener('change', (e) => { + addFiles(e.target.files); + }); + + fileList.addEventListener('click', (e) => { + const removeIdx = e.target.getAttribute('data-remove'); + if (removeIdx !== null) { + queue.splice(Number(removeIdx), 1); + renderQueue(); + return; + } + }); + + startBtn.addEventListener('click', () => uploadQueue()); + clearBtn.addEventListener('click', () => clearQueue()); + + // Delete handling + if (table) { + table.addEventListener('click', async (e) => { + const target = e.target; + const filename = target.getAttribute('data-delete'); + if (!filename) return; + e.preventDefault(); + const url = deleteTemplate.replace('__NAME__', encodeURIComponent(filename)); + target.disabled = true; + const res = await fetch(url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Delete failed: bad response', true); + return; + } + if (!res.ok || !data.success) { + target.disabled = false; + setStatus(data && data.error ? data.error : 'Delete failed', true); + return; + } + const row = target.closest('tr'); + if (row) row.remove(); + setStatus(data.message || 'Deleted', false); + }); + } +})(); diff --git a/scripts/web/templates/base.html b/scripts/web/templates/base.html index 70894fc..25c5c2c 100644 --- a/scripts/web/templates/base.html +++ b/scripts/web/templates/base.html @@ -1,5 +1,6 @@ + @@ -9,7 +10,7 @@ Tesla USB Gadget Control {% endif %} + diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html new file mode 100644 index 0000000..d29880c --- /dev/null +++ b/scripts/web/templates/music.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Music Library

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+
Used
+
{{ format_file_size(total_size) }}
+
+
+
Free
+
{{ format_file_size(free_bytes) }}
+
+
+
Files
+
{{ files|length }}
+
+
+ {% set total_space = total_size + free_bytes %} + {% set used_pct = (100 * total_size / total_space) if total_space else 0 %} +
+ +
+

Files

+ {% if files %} + + + + + + + + + + {% for file in files %} + + + + + + {% endfor %} + +
NameSize
{{ file.name }}{{ format_file_size(file.size) }} + +
+ {% else %} +

No music files found on the drive.

+ {% endif %} +
+ +
+

Upload

+

Uploads require Edit mode. Files larger than {{ max_upload_chunk_mb }} MiB are sent in chunks + to keep memory low.

+
+
Drop files here or click to choose
+
Allowed: mp3, flac, wav, aac, m4a. Max {{ max_upload_size_mb }} + MiB per file.
+ +
+
+
+ + +
+
+
+
+ +{% endblock %} diff --git a/scripts/web/web_control.py b/scripts/web/web_control.py index 530c042..1fc97eb 100644 --- a/scripts/web/web_control.py +++ b/scripts/web/web_control.py @@ -10,12 +10,16 @@ import os # Import configuration -from config import SECRET_KEY, WEB_PORT, GADGET_DIR +from config import SECRET_KEY, WEB_PORT, GADGET_DIR, MAX_UPLOAD_SIZE_MB, MAX_UPLOAD_CHUNK_MB # Flask app initialization app = Flask(__name__) app.secret_key = SECRET_KEY +# Upload limits (protect RAM-constrained devices) +app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE_MB * 1024 * 1024 +app.config['MAX_FORM_MEMORY_SIZE'] = MAX_UPLOAD_CHUNK_MB * 1024 * 1024 + # Production optimizations app.config['USE_X_SENDFILE'] = False # Disabled - requires nginx/apache app.config['TEMPLATES_AUTO_RELOAD'] = False # Disable template watching - saves memory @@ -26,6 +30,7 @@ videos_bp, lock_chimes_bp, light_shows_bp, + music_bp, wraps_bp, analytics_bp, cleanup_bp, @@ -39,6 +44,7 @@ app.register_blueprint(videos_bp) app.register_blueprint(lock_chimes_bp) app.register_blueprint(light_shows_bp) +app.register_blueprint(music_bp) app.register_blueprint(wraps_bp) app.register_blueprint(analytics_bp) app.register_blueprint(cleanup_bp) diff --git a/setup_usb.sh b/setup_usb.sh index 3427213..e435380 100644 --- a/setup_usb.sh +++ b/setup_usb.sh @@ -59,13 +59,18 @@ fi IMG_CAM_PATH="$GADGET_DIR/$IMG_CAM_NAME" IMG_LIGHTSHOW_PATH="$GADGET_DIR/$IMG_LIGHTSHOW_NAME" +IMG_MUSIC_PATH="$GADGET_DIR/$IMG_MUSIC_NAME" # ===== Check if image files already exist ===== -# Skip sizing and creation if both images already exist -if [ -f "$IMG_CAM_PATH" ] && [ -f "$IMG_LIGHTSHOW_PATH" ]; then - echo "Both image files already exist:" +# Skip sizing and creation if all required images already exist +MUSIC_ENABLED_LC="$(printf '%s' "${MUSIC_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')" +MUSIC_REQUIRED=$([ "$MUSIC_ENABLED_LC" = "true" ] && echo 1 || echo 0) + +if [ -f "$IMG_CAM_PATH" ] && [ -f "$IMG_LIGHTSHOW_PATH" ] && { [ $MUSIC_REQUIRED -eq 0 ] || [ -f "$IMG_MUSIC_PATH" ]; }; then + echo "All required image files already exist:" echo " - TeslaCam: $IMG_CAM_PATH" echo " - Lightshow: $IMG_LIGHTSHOW_PATH" + [ $MUSIC_REQUIRED -eq 1 ] && echo " - Music: $IMG_MUSIC_PATH" echo "Skipping size configuration and image creation." echo "" SKIP_IMAGE_CREATION=1 @@ -75,6 +80,7 @@ else # Determine which images need to be created NEED_CAM_IMAGE=0 NEED_LIGHTSHOW_IMAGE=0 + NEED_MUSIC_IMAGE=0 if [ ! -f "$IMG_CAM_PATH" ]; then NEED_CAM_IMAGE=1 @@ -89,6 +95,15 @@ else else echo "Lightshow image already exists at $IMG_LIGHTSHOW_PATH" fi + + if [ $MUSIC_REQUIRED -eq 1 ]; then + if [ ! -f "$IMG_MUSIC_PATH" ]; then + NEED_MUSIC_IMAGE=1 + echo "Music image not found at $IMG_MUSIC_PATH - will create" + else + echo "Music image already exists at $IMG_MUSIC_PATH" + fi + fi echo "" fi @@ -135,7 +150,7 @@ size_to_bytes() { NEED_SIZE_VALIDATION=0 USABLE_MIB=0 -if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2_SIZE}" ]; }; then +if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2_SIZE}" ] || { [ $MUSIC_REQUIRED -eq 1 ] && [ -z "${PART3_SIZE}" ]; }; }; then # Ensure parent directory exists for df check mkdir -p "$GADGET_DIR" 2>/dev/null || true FS_AVAIL_BYTES="$(fs_avail_bytes_for_path "$GADGET_DIR")" @@ -161,19 +176,36 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 USABLE_BYTES=$(( FS_AVAIL_BYTES - RESERVE_BYTES )) USABLE_MIB=$(( USABLE_BYTES / 1024 / 1024 )) - # Default Lightshow to 10G + # Default sizes: Lightshow 10G, Music 32G (if enabled), remaining to TeslaCam DEFAULT_P2_MIB=10240 DEFAULT_P2_STR="10G" - if [ "$USABLE_MIB" -le "$DEFAULT_P2_MIB" ]; then - echo "ERROR: Not enough usable space for Lightshow default (${DEFAULT_P2_STR}) after safety reserve." - echo "Usable: ${USABLE_MIB} MiB, Lightshow: ${DEFAULT_P2_MIB} MiB" - echo "Free up space or reduce Lightshow size." + DEFAULT_P3_MIB=32768 + DEFAULT_P3_STR="32G" + + # Compute total baseline needed + BASELINE_MIB=$DEFAULT_P2_MIB + if [ $MUSIC_REQUIRED -eq 1 ]; then + BASELINE_MIB=$(( BASELINE_MIB + DEFAULT_P3_MIB )) + fi + + if [ "$USABLE_MIB" -le "$BASELINE_MIB" ]; then + echo "ERROR: Not enough usable space for defaults after safety reserve." + echo "Usable: ${USABLE_MIB} MiB, Baseline required: ${BASELINE_MIB} MiB" + echo "Free up space or reduce Lightshow/Music size." exit 1 fi SUG_P2_STR="$DEFAULT_P2_STR" - SUG_P1_MIB="$(round_down_gib_mib $(( USABLE_MIB - DEFAULT_P2_MIB )))" + + if [ $MUSIC_REQUIRED -eq 1 ]; then + SUG_P3_STR="$DEFAULT_P3_STR" + SUG_P1_MIB="$(round_down_gib_mib $(( USABLE_MIB - DEFAULT_P2_MIB - DEFAULT_P3_MIB )))" + else + SUG_P3_STR="" + SUG_P1_MIB="$(round_down_gib_mib $(( USABLE_MIB - DEFAULT_P2_MIB )))" + fi + SUG_P1_STR="$(mib_to_gib_str "$SUG_P1_MIB")" echo "" @@ -187,6 +219,7 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 echo "" echo "Recommended sizes (safe, leaves headroom for Raspberry Pi OS):" echo " Lightshow (PART2_SIZE): $SUG_P2_STR (default)" + [ $MUSIC_REQUIRED -eq 1 ] && echo " Music (PART3_SIZE): ${SUG_P3_STR:-custom}" || true echo " TeslaCam (PART1_SIZE): $SUG_P1_STR (uses remaining usable space)" echo "" @@ -205,6 +238,18 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 PART2_SIZE="${PART2_SIZE:-1G}" fi + if [ $MUSIC_REQUIRED -eq 1 ] && [ "$NEED_MUSIC_IMAGE" = "1" ] && [ -z "${PART3_SIZE}" ]; then + read -r -p "Enter Music size (default ${SUG_P3_STR}): " PART3_SIZE_INPUT + PART3_SIZE="${PART3_SIZE_INPUT:-$SUG_P3_STR}" + if ! size_to_bytes "$PART3_SIZE" >/dev/null 2>&1; then + echo "ERROR: Invalid size format for Music: $PART3_SIZE" + echo "Use format like 512M or 5G (whole numbers only)" + exit 2 + fi + elif [ $MUSIC_REQUIRED -eq 1 ] && [ "$NEED_MUSIC_IMAGE" = "0" ]; then + PART3_SIZE="${PART3_SIZE:-1G}" + fi + if [ "$NEED_CAM_IMAGE" = "1" ] && [ -z "${PART1_SIZE}" ]; then read -r -p "Enter TeslaCam size (default ${SUG_P1_STR}): " PART1_SIZE_INPUT PART1_SIZE="${PART1_SIZE_INPUT:-$SUG_P1_STR}" @@ -223,6 +268,7 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 echo "Selected sizes:" echo " PART1_SIZE=$PART1_SIZE" echo " PART2_SIZE=$PART2_SIZE" + [ $MUSIC_REQUIRED -eq 1 ] && echo " PART3_SIZE=$PART3_SIZE" echo "" NEED_SIZE_VALIDATION=1 @@ -232,6 +278,7 @@ fi if [ "$SKIP_IMAGE_CREATION" = "1" ]; then PART1_SIZE="${PART1_SIZE:-1G}" # Dummy value - image already exists PART2_SIZE="${PART2_SIZE:-1G}" # Dummy value - image already exists + [ $MUSIC_REQUIRED -eq 1 ] && PART3_SIZE="${PART3_SIZE:-1G}" fi # Validate user exists @@ -257,17 +304,22 @@ to_mib() { } P1_MB=$(to_mib "$PART1_SIZE") P2_MB=$(to_mib "$PART2_SIZE") +if [ $MUSIC_REQUIRED -eq 1 ]; then + P3_MB=$(to_mib "$PART3_SIZE") +else + P3_MB=0 +fi # Note: We no longer need TOTAL_MB since we're creating separate images # Validate selected sizes against usable space (if computed and images need creation) if [ "${NEED_SIZE_VALIDATION:-0}" = "1" ] && [ "$SKIP_IMAGE_CREATION" = "0" ]; then - TOTAL_MIB=$(( P1_MB + P2_MB )) + TOTAL_MIB=$(( P1_MB + P2_MB + P3_MB )) if [ "$TOTAL_MIB" -gt "$USABLE_MIB" ]; then echo "ERROR: Selected sizes exceed safe usable space under $GADGET_DIR." echo "Usable: ${USABLE_MIB} MiB (after safety reserve)" - echo "Chosen: ${TOTAL_MIB} MiB (PART1=${P1_MB} MiB, PART2=${P2_MB} MiB)" - echo "Reduce TeslaCam and/or Lightshow sizes." + echo "Chosen: ${TOTAL_MIB} MiB (PART1=${P1_MB} MiB, PART2=${P2_MB} MiB, PART3=${P3_MB} MiB)" + echo "Reduce TeslaCam, Lightshow, and/or Music sizes." exit 1 fi fi @@ -277,20 +329,17 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ]; then echo "============================================" echo "Preview" echo "============================================" - if [ "$NEED_CAM_IMAGE" = "1" ] && [ "$NEED_LIGHTSHOW_IMAGE" = "1" ]; then + if [ "$NEED_CAM_IMAGE" = "1" ] || [ "$NEED_LIGHTSHOW_IMAGE" = "1" ] || [ "$NEED_MUSIC_IMAGE" = "1" ]; then echo "This will create the following image files:" - echo " 1) TeslaCam : $IMG_CAM_PATH size=$PART1_SIZE label=$LABEL1 (read-write)" - echo " 2) Lightshow : $IMG_LIGHTSHOW_PATH size=$PART2_SIZE label=$LABEL2 (read-only)" - elif [ "$NEED_CAM_IMAGE" = "1" ]; then - echo "This will create the TeslaCam image file:" - echo " - TeslaCam : $IMG_CAM_PATH size=$PART1_SIZE label=$LABEL1 (read-write)" - echo "" - echo "Lightshow image already exists at: $IMG_LIGHTSHOW_PATH" - elif [ "$NEED_LIGHTSHOW_IMAGE" = "1" ]; then - echo "This will create the Lightshow image file:" - echo " - Lightshow : $IMG_LIGHTSHOW_PATH size=$PART2_SIZE label=$LABEL2 (read-only)" - echo "" - echo "TeslaCam image already exists at: $IMG_CAM_PATH" + [ "$NEED_CAM_IMAGE" = "1" ] && echo " - TeslaCam : $IMG_CAM_PATH size=$PART1_SIZE label=$LABEL1 (read-write)" || echo " - TeslaCam : already exists" + [ "$NEED_LIGHTSHOW_IMAGE" = "1" ] && echo " - Lightshow : $IMG_LIGHTSHOW_PATH size=$PART2_SIZE label=$LABEL2 (read-only)" || echo " - Lightshow : already exists" + if [ $MUSIC_REQUIRED -eq 1 ]; then + if [ "$NEED_MUSIC_IMAGE" = "1" ]; then + echo " - Music : $IMG_MUSIC_PATH size=$PART3_SIZE label=$LABEL3 (read-only by Tesla)" + else + echo " - Music : already exists" + fi + fi fi echo "" echo "Images are stored under: $GADGET_DIR" @@ -318,6 +367,7 @@ REQUIRED_PACKAGES=( python3-av python3-pil python3-yaml + python3-pip yq samba samba-common-bin @@ -566,6 +616,19 @@ else echo "All required packages already installed; skipping apt install." fi +# Ensure Python web dependencies from requirements.txt are present (pip will no-op if already satisfied) +REQ_FILE="$SCRIPT_DIR/scripts/web/requirements.txt" +if [ -f "$REQ_FILE" ]; then + if command -v pip3 >/dev/null 2>&1; then + echo "Installing Python web requirements from $REQ_FILE..." + if ! pip3 install --no-deps --requirement "$REQ_FILE" >/dev/null; then + echo "Warning: pip install of web requirements failed; continuing with apt-provided packages" + fi + else + echo "pip3 not found; skip pip install (apt packages cover required modules)" + fi +fi + # Ensure hostapd/dnsmasq don't auto-start outside our controller systemctl disable hostapd 2>/dev/null || true systemctl stop hostapd 2>/dev/null || true @@ -699,6 +762,11 @@ cleanup_loop_devices() { losetup -d "$LOOP_LIGHTSHOW" 2>/dev/null || true LOOP_LIGHTSHOW="" fi + if [ -n "${LOOP_MUSIC:-}" ]; then + echo "Cleaning up loop device: $LOOP_MUSIC" + losetup -d "$LOOP_MUSIC" 2>/dev/null || true + LOOP_MUSIC="" + fi } # Create TeslaCam image (if missing) @@ -795,14 +863,57 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && [ "$NEED_LIGHTSHOW_IMAGE" = "1" ]; then echo "Lightshow image created and formatted." fi +# Create Music image (if enabled and missing) +if [ "$SKIP_IMAGE_CREATION" = "0" ] && [ $MUSIC_REQUIRED -eq 1 ] && [ "${NEED_MUSIC_IMAGE:-0}" = "1" ]; then + trap cleanup_loop_devices EXIT INT TERM + + echo "Creating Music image $IMG_MUSIC_PATH (${P3_MB}M)..." + truncate -s "${P3_MB}M" "$IMG_MUSIC_PATH" || { + echo "Error: Failed to create Music image file" + exit 1 + } + + LOOP_MUSIC=$(losetup --find --show "$IMG_MUSIC_PATH") || { + echo "Error: Failed to create loop device for Music" + exit 1 + } + + if [ -z "$LOOP_MUSIC" ] || [ ! -e "$LOOP_MUSIC" ]; then + echo "Error: Loop device creation failed or device not accessible" + exit 1 + fi + + echo "Using loop device: $LOOP_MUSIC" + + # Format Music drive (Tesla prefers FAT32 for media) + echo "Formatting Music drive (${LABEL3})..." + FS_LOWER="$(printf '%s' "$MUSIC_FS" | tr '[:upper:]' '[:lower:]')" + if [ "$FS_LOWER" = "exfat" ]; then + mkfs.exfat -n "$LABEL3" "$LOOP_MUSIC" || { + echo "Error: Failed to format Music drive with exFAT" + exit 1 + } + else + mkfs.vfat -F 32 -n "$LABEL3" "$LOOP_MUSIC" || { + echo "Error: Failed to format Music drive with FAT32" + exit 1 + } + fi + + losetup -d "$LOOP_MUSIC" 2>/dev/null || true + LOOP_MUSIC="" + + echo "Music image created and formatted." +fi + # Clean up any remaining loop devices cleanup_loop_devices trap - EXIT INT TERM # Remove trap since we're done with image creation # Create mount points -mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" -chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" -chmod 775 "$MNT_DIR/part1" "$MNT_DIR/part2" +mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" +chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" +chmod 775 "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" # Create thumbnail cache directory in persistent location THUMBNAIL_CACHE_DIR="$GADGET_DIR/thumbnails" @@ -824,7 +935,8 @@ awk ' BEGIN{skip=0} /^\[gadget_part1\]/{skip=1} /^\[gadget_part2\]/{skip=1} - /^\[.*\]$/ { if(skip==1 && $0 !~ /^\[gadget_part1\]/ && $0 !~ /^\[gadget_part2\]/) { skip=0 } } + /^\[gadget_part3\]/{skip=1} + /^\[.*\]$/ { if(skip==1 && $0 !~ /^\[gadget_part1\]/ && $0 !~ /^\[gadget_part2\]/ && $0 !~ /^\[gadget_part3\]/) { skip=0 } } { if(skip==0) print } ' "$SMB_CONF" > "${SMB_CONF}.tmp" || cp "$SMB_CONF" "${SMB_CONF}.tmp" mv "${SMB_CONF}.tmp" "$SMB_CONF" @@ -879,6 +991,20 @@ cat >> "$SMB_CONF" <> "$SMB_CONF" </dev/null || systemctl restart smbd || true From 0c860a5cb39caf043e62d274dc71b79d59f4ba07 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 20:44:24 +0100 Subject: [PATCH 2/9] feat(music): enhance music management with folder creation and moving capabilities Signed-off-by: Joost Buskermolen --- scripts/web/blueprints/music.py | 65 ++++++++++- scripts/web/services/music_service.py | 152 ++++++++++++++++++++++---- scripts/web/static/js/music.js | 134 ++++++++++++++++++++--- scripts/web/templates/music.html | 88 +++++++++++++-- 4 files changed, 389 insertions(+), 50 deletions(-) diff --git a/scripts/web/blueprints/music.py b/scripts/web/blueprints/music.py index c009aac..b26499c 100644 --- a/scripts/web/blueprints/music.py +++ b/scripts/web/blueprints/music.py @@ -9,6 +9,8 @@ save_file, handle_chunk, delete_music_file, + create_directory, + move_music_file, UploadError, generate_upload_id, require_edit_mode, @@ -21,15 +23,18 @@ @music_bp.route("/") def music_home(): ctx = get_base_context() - files, error, total_size, free_bytes = list_music_files() + current_path = request.args.get("path", "") + dirs, files, error, total_size, free_bytes, current_path = list_music_files(current_path) return render_template( "music.html", page="music", **ctx, + dirs=dirs, files=files, error=error, total_size=total_size, free_bytes=free_bytes, + current_path=current_path, format_file_size=format_file_size, max_upload_size_mb=current_app.config.get("MAX_CONTENT_LENGTH", 0) // (1024 * 1024), max_upload_chunk_mb=current_app.config.get("MAX_FORM_MEMORY_SIZE", 0) // (1024 * 1024), @@ -39,6 +44,7 @@ def music_home(): @music_bp.route("/upload", methods=["POST"]) def upload_music(): is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + current_path = request.args.get("path") or request.form.get("path") or "" try: require_edit_mode() except UploadError as exc: @@ -59,7 +65,7 @@ def upload_music(): for file in files: if not file or not file.filename: continue - ok, msg = save_file(file) + ok, msg = save_file(file, current_path) if ok: successes += 1 messages.append(msg) @@ -76,12 +82,13 @@ def upload_music(): flash(f"Uploaded {successes} file(s)", "success") else: flash("Failed to upload files", "error") - return redirect(url_for("music.music_home", _=request.args.get('_', 0))) + return redirect(url_for("music.music_home", path=current_path, _=request.args.get('_', 0))) @music_bp.route("/upload_chunk", methods=["POST"]) def upload_chunk(): is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + current_path = request.args.get("path") or request.form.get("path") or "" try: require_edit_mode() except UploadError as exc: @@ -112,6 +119,7 @@ def upload_chunk(): total_chunks=total_chunks, total_size=total_size, stream=request.stream, + rel_path=current_path, ) except UploadError as exc: logger.warning("Chunk upload rejected: %s", exc) @@ -144,4 +152,53 @@ def delete_music(filename): return jsonify({"success": ok, "message": msg}), status flash(msg, "success" if ok else "error") - return redirect(url_for("music.music_home", _=request.args.get('_', 0))) + return redirect(url_for("music.music_home", path=request.args.get("path", ""), _=request.args.get('_', 0))) + + +@music_bp.route("/move", methods=["POST"]) +def move_music(): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + try: + require_edit_mode() + except UploadError as exc: + if is_ajax: + return jsonify({"success": False, "error": str(exc)}), 400 + flash(str(exc), "error") + return redirect(url_for("music.music_home")) + + data = request.get_json(silent=True) or request.form + source = data.get("source", "") if data else "" + dest_path = data.get("dest_path", "") if data else "" + new_name = data.get("new_name", "") if data else "" + + ok, msg = move_music_file(source, dest_path, new_name) + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + return redirect(url_for("music.music_home", path=dest_path)) + + +@music_bp.route("/mkdir", methods=["POST"]) +def create_music_folder(): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + try: + require_edit_mode() + except UploadError as exc: + if is_ajax: + return jsonify({"success": False, "error": str(exc)}), 400 + flash(str(exc), "error") + return redirect(url_for("music.music_home")) + + data = request.get_json(silent=True) or request.form + current_path = data.get("path", "") if data else "" + name = data.get("name", "") if data else "" + + ok, msg = create_directory(current_path, name) + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + return redirect(url_for("music.music_home", path=current_path)) diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py index 3630c5c..205dfb5 100644 --- a/scripts/web/services/music_service.py +++ b/scripts/web/services/music_service.py @@ -4,7 +4,7 @@ import os import uuid import logging -from typing import Tuple +from typing import Tuple, List from werkzeug.utils import secure_filename @@ -59,6 +59,34 @@ def _stream_to_file(stream, dest_path: str) -> int: return total +def _normalize_rel_path(rel_path: str) -> str: + rel = (rel_path or "").strip("/") + if not rel: + return "" + cleaned: List[str] = [] + for segment in rel.split("/"): + segment = segment.strip() + if segment in {"", ".", ".."}: + continue + safe_seg = secure_filename(segment) + if not safe_seg: + raise UploadError("Invalid folder name") + cleaned.append(safe_seg) + return "/".join(cleaned) + + +def _resolve_subpath(mount_path: str, rel_path: str) -> str: + """Return an absolute path within the music mount for the given relative path.""" + rel = _normalize_rel_path(rel_path) + if not rel: + return mount_path + target = os.path.join(mount_path, rel) + common = os.path.commonpath([mount_path, target]) + if common != os.path.abspath(mount_path): + raise UploadError("Invalid path") + return target + + def _ensure_music_mount() -> Tuple[str, str]: """Return the music mount path or an error string.""" mount_path = get_mount_path("part3") @@ -79,38 +107,56 @@ def _validate_filename(name: str) -> str: return safe -def list_music_files(): +def list_music_files(rel_path: str = ""): mount_path, err = _ensure_music_mount() if err: - return [], err, 0, 0 + return [], [], err, 0, 0, "" + + try: + current_rel = _normalize_rel_path(rel_path) + except UploadError as exc: + return [], [], str(exc), 0, 0, "" + target_dir = _resolve_subpath(mount_path, current_rel) + if not os.path.isdir(target_dir): + return [], [], "Folder not found", 0, 0, current_rel + + dirs = [] music_files = [] total_size = 0 try: - for entry in os.scandir(mount_path): + for entry in os.scandir(target_dir): + if entry.is_dir(): + dirs.append({ + "name": entry.name, + "path": f"{current_rel + '/' if current_rel else ''}{entry.name}", + }) + continue if not entry.is_file(): continue ext = os.path.splitext(entry.name)[1].lower() if ext not in ALLOWED_EXTS: continue stat = entry.stat() + rel_file = f"{current_rel + '/' if current_rel else ''}{entry.name}" music_files.append({ "name": entry.name, + "path": rel_file, "size": stat.st_size, "mtime": int(stat.st_mtime), }) total_size += stat.st_size except OSError as e: logger.warning("Could not read music directory: %s", e) - return [], "Unable to read music directory", 0, 0 + return [], [], "Unable to read music directory", 0, 0, current_rel free_bytes = _fs_free_bytes(mount_path) + dirs.sort(key=lambda x: x["name"].lower()) music_files.sort(key=lambda x: x["name"].lower()) - return music_files, "", total_size, free_bytes + return dirs, music_files, "", total_size, free_bytes, current_rel -def _prepare_paths(filename: str, mount_path: str): - music_dir = mount_path +def _prepare_paths(filename: str, music_dir: str): tmp_dir = os.path.join(music_dir, ".uploads") os.makedirs(tmp_dir, exist_ok=True) tmp_path = os.path.join(tmp_dir, f"{filename}.upload") @@ -118,14 +164,21 @@ def _prepare_paths(filename: str, mount_path: str): return tmp_dir, tmp_path, final_path -def save_file(file_storage) -> Tuple[bool, str]: +def save_file(file_storage, rel_path: str = "") -> Tuple[bool, str]: """Stream a Werkzeug FileStorage to the music partition with fsync + atomic rename.""" mount_path, err = _ensure_music_mount() if err: return False, err + target_dir = _resolve_subpath(mount_path, rel_path) + if not os.path.isdir(target_dir): + try: + os.makedirs(target_dir, exist_ok=True) + except OSError: + return False, "Target folder unavailable" + filename = _validate_filename(file_storage.filename) - tmp_dir, tmp_path, final_path = _prepare_paths(filename, mount_path) + tmp_dir, tmp_path, final_path = _prepare_paths(filename, target_dir) # Free space check file_storage.stream.seek(0, os.SEEK_END) @@ -147,7 +200,7 @@ def save_file(file_storage) -> Tuple[bool, str]: _fsync_dir(tmp_dir) os.replace(tmp_path, final_path) - _fsync_dir(mount_path) + _fsync_dir(target_dir) try: close_samba_share("part3") except Exception: @@ -155,7 +208,7 @@ def save_file(file_storage) -> Tuple[bool, str]: return True, f"Uploaded {filename}" -def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: int, total_size: int, stream) -> Tuple[bool, str, bool]: +def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: int, total_size: int, stream, rel_path: str = "") -> Tuple[bool, str, bool]: """ Append a chunk to the staged upload file. @@ -168,11 +221,14 @@ def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: if err: return False, err, False + target_dir = _resolve_subpath(mount_path, rel_path) + os.makedirs(target_dir, exist_ok=True) + filename = _validate_filename(filename) - tmp_dir = os.path.join(mount_path, ".uploads") + tmp_dir = os.path.join(target_dir, ".uploads") os.makedirs(tmp_dir, exist_ok=True) staged_path = os.path.join(tmp_dir, f"{upload_id}.part") - final_path = os.path.join(mount_path, filename) + final_path = os.path.join(target_dir, filename) # On first chunk, ensure space and clear any stale parts if chunk_index == 0: @@ -196,7 +252,7 @@ def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: _fsync_path(staged_path) _fsync_dir(tmp_dir) os.replace(staged_path, final_path) - _fsync_dir(mount_path) + _fsync_dir(target_dir) try: close_samba_share("part3") @@ -206,19 +262,19 @@ def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: return True, f"Uploaded {filename}", True -def delete_music_file(filename: str) -> Tuple[bool, str]: +def delete_music_file(rel_path: str) -> Tuple[bool, str]: mount_path, err = _ensure_music_mount() if err: return False, err - filename = _validate_filename(filename) - target = os.path.join(mount_path, filename) - if not os.path.isfile(target): + target_path = _resolve_subpath(mount_path, rel_path) + filename = os.path.basename(target_path) + if not os.path.isfile(target_path): return False, "File not found" try: - os.remove(target) - _fsync_dir(mount_path) + os.remove(target_path) + _fsync_dir(os.path.dirname(target_path)) close_samba_share("part3") except Exception as exc: logger.error("Failed to delete %s: %s", filename, exc) @@ -226,6 +282,60 @@ def delete_music_file(filename: str) -> Tuple[bool, str]: return True, f"Deleted {filename}" +def create_directory(rel_path: str, name: str) -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + base_dir = _resolve_subpath(mount_path, rel_path) + safe_name = secure_filename(name or "") + if not safe_name: + return False, "Invalid folder name" + + target_dir = os.path.join(base_dir, safe_name) + common = os.path.commonpath([mount_path, target_dir]) + if common != os.path.abspath(mount_path): + return False, "Invalid folder path" + + try: + os.makedirs(target_dir, exist_ok=False) + _fsync_dir(base_dir) + except FileExistsError: + return False, "Folder already exists" + except Exception: + return False, "Could not create folder" + return True, f"Created folder {safe_name}" + + +def move_music_file(source_rel: str, dest_rel: str, new_name: str = "") -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + src_path = _resolve_subpath(mount_path, source_rel) + if not os.path.isfile(src_path): + return False, "Source file not found" + + dest_dir = _resolve_subpath(mount_path, dest_rel) + try: + os.makedirs(dest_dir, exist_ok=True) + except OSError: + return False, "Destination unavailable" + + dest_name = _validate_filename(new_name) if new_name else os.path.basename(src_path) + dest_path = os.path.join(dest_dir, dest_name) + + try: + os.replace(src_path, dest_path) + _fsync_dir(os.path.dirname(src_path)) + _fsync_dir(dest_dir) + close_samba_share("part3") + except Exception as exc: + logger.error("Failed to move %s -> %s: %s", src_path, dest_path, exc) + return False, "Unable to move file" + return True, f"Moved to {dest_name}" + + def require_edit_mode(): if current_mode() != "edit": raise UploadError("Switch to Edit mode to upload music.") diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js index 54c0924..bd8fc2e 100644 --- a/scripts/web/static/js/music.js +++ b/scripts/web/static/js/music.js @@ -6,6 +6,10 @@ const maxMb = parseInt(page.dataset.maxMb || '0', 10) || 0; const uploadUrl = page.dataset.uploadUrl; const deleteTemplate = page.dataset.deleteUrl; + const moveUrl = page.dataset.moveUrl; + const browseUrl = page.dataset.browseUrl; + const mkdirUrl = page.dataset.mkdirUrl; + const currentPath = page.dataset.currentPath || ''; const chunkSize = Math.max(1, chunkMb) * 1024 * 1024; const maxBytes = maxMb > 0 ? maxMb * 1024 * 1024 : null; @@ -15,7 +19,11 @@ const startBtn = document.getElementById('startUpload'); const clearBtn = document.getElementById('clearSelection'); const statusEl = document.getElementById('uploadStatus'); - const table = page.querySelector('table'); + const fileTable = document.getElementById('fileTable'); + const folderTable = document.getElementById('folderTable'); + const folderNameInput = document.getElementById('folderName'); + const createFolderBtn = document.getElementById('createFolder'); + const currentPathLabel = document.getElementById('currentPathLabel'); let queue = []; let uploading = false; @@ -100,6 +108,7 @@ total_chunks: String(totalChunks), total_size: String(file.size), }); + if (currentPath) params.set('path', currentPath); const res = await fetch(`${uploadUrl}?${params.toString()}`, { method: 'POST', @@ -150,7 +159,8 @@ clearBtn.disabled = queue.length === 0; // Refresh page to show new files if (!statusEl.classList.contains('error')) { - setTimeout(() => window.location.reload(), 600); + const target = currentPath ? `${browseUrl}?path=${encodeURIComponent(currentPath)}` : browseUrl; + setTimeout(() => { window.location.href = target; }, 600); } } @@ -195,35 +205,125 @@ startBtn.addEventListener('click', () => uploadQueue()); clearBtn.addEventListener('click', () => clearQueue()); - // Delete handling - if (table) { - table.addEventListener('click', async (e) => { + const navigateTo = (path) => { + const target = path ? `${browseUrl}?path=${encodeURIComponent(path)}` : browseUrl; + window.location.href = target; + }; + + // File actions: delete or move + if (fileTable) { + fileTable.addEventListener('click', async (e) => { const target = e.target; const filename = target.getAttribute('data-delete'); - if (!filename) return; + const moveTarget = target.getAttribute('data-move'); + if (!filename && !moveTarget) return; e.preventDefault(); - const url = deleteTemplate.replace('__NAME__', encodeURIComponent(filename)); - target.disabled = true; - const res = await fetch(url, { + + if (filename) { + const url = deleteTemplate.replace('__NAME__', encodeURIComponent(filename)); + target.disabled = true; + const res = await fetch(url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Delete failed: bad response', true); + return; + } + if (!res.ok || !data.success) { + target.disabled = false; + setStatus(data && data.error ? data.error : 'Delete failed', true); + return; + } + const row = target.closest('tr'); + if (row) row.remove(); + setStatus(data.message || 'Deleted', false); + return; + } + + if (moveTarget) { + const dest = window.prompt('Move to folder (relative path):', currentPath); + if (dest === null) return; + const newName = window.prompt('Optional new filename (leave blank to keep name):', ''); + target.disabled = true; + const res = await fetch(moveUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ source: moveTarget, dest_path: dest || '', new_name: newName || '' }), + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Move failed: bad response', true); + return; + } + target.disabled = false; + if (!res.ok || !data.success) { + setStatus(data && data.error ? data.error : 'Move failed', true); + return; + } + const targetPath = dest ? `${browseUrl}?path=${encodeURIComponent(dest)}` : browseUrl; + window.location.href = targetPath; + } + }); + } + + if (folderTable) { + folderTable.addEventListener('click', (e) => { + const row = e.target.closest('tr'); + if (!row) return; + const dir = row.getAttribute('data-dir'); + if (dir !== null) { + navigateTo(dir); + } + }); + } + + if (createFolderBtn) { + createFolderBtn.addEventListener('click', async () => { + const name = (folderNameInput.value || '').trim(); + if (!name) { + setStatus('Folder name required', true); + return; + } + createFolderBtn.disabled = true; + const res = await fetch(mkdirUrl, { method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ path: currentPath, name }), }); let data; try { data = await res.json(); } catch (err) { - target.disabled = false; - setStatus('Delete failed: bad response', true); + createFolderBtn.disabled = false; + setStatus('Create folder failed: bad response', true); return; } + createFolderBtn.disabled = false; if (!res.ok || !data.success) { - target.disabled = false; - setStatus(data && data.error ? data.error : 'Delete failed', true); + setStatus(data && data.error ? data.error : 'Create folder failed', true); return; } - const row = target.closest('tr'); - if (row) row.remove(); - setStatus(data.message || 'Deleted', false); + const target = currentPath ? `${browseUrl}?path=${encodeURIComponent(currentPath)}` : browseUrl; + window.location.href = target; }); } + + if (currentPathLabel) { + const labelPath = currentPath ? `/${currentPath}` : '/'; + currentPathLabel.textContent = labelPath; + } })(); diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html index d29880c..0007def 100644 --- a/scripts/web/templates/music.html +++ b/scripts/web/templates/music.html @@ -109,6 +109,28 @@ color: var(--muted-text); } + .breadcrumb { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + margin: 10px 0; + color: var(--muted-text); + font-size: 0.95rem; + } + + .breadcrumb a { + color: var(--link-color); + } + + .folder-row { + cursor: pointer; + } + + .folder-row:hover { + background: var(--panel-bg); + } + @media (max-width: 960px) { .music-layout { grid-template-columns: 1fr; @@ -120,7 +142,9 @@ {% block content %}
+ data-delete-url="{{ url_for('music.delete_music', filename='__NAME__') }}" + data-move-url="{{ url_for('music.move_music') }}" data-current-path="{{ current_path }}" + data-browse-url="{{ url_for('music.music_home') }}" data-mkdir-url="{{ url_for('music.create_music_folder') }}">

Music Library

{% if error %} @@ -143,25 +167,60 @@

Music Library

{% set total_space = total_size + free_bytes %} {% set used_pct = (100 * total_size / total_space) if total_space else 0 %}
- + +
+

Files

+ {% if dirs %} + + + + + + + + {% if current_path %} + + + + {% endif %} + {% for d in dirs %} + + + + + {% endfor %} + +
Folders
⬆️ Up one level
📁 {{ d.name }}Open
+ {% endif %} {% if files %} - +
- + {% for file in files %} - - + + {% endfor %} @@ -175,7 +234,7 @@

Files

Upload

Uploads require Edit mode. Files larger than {{ max_upload_chunk_mb }} MiB are sent in chunks - to keep memory low.

+ to keep memory low. Target folder: /{{ current_path }}

Drop files here or click to choose
Allowed: mp3, flac, wav, aac, m4a. Max {{ max_upload_size_mb }} @@ -184,6 +243,10 @@

Upload

accept=".mp3,.flac,.wav,.aac,.m4a">
+
+ + +
@@ -191,5 +254,14 @@

Upload

+ {% endblock %} From c46ce8c1336167e2042df7ec8b6e8b2b19d85632 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 20:49:17 +0100 Subject: [PATCH 3/9] feat(music): enhance file upload by preserving subfolder structure and skipping hidden files Signed-off-by: Joost Buskermolen --- scripts/web/services/music_service.py | 3 +++ scripts/web/static/js/music.js | 15 ++++++++++++--- scripts/web/templates/music.html | 8 +++++--- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py index 205dfb5..6e67f34 100644 --- a/scripts/web/services/music_service.py +++ b/scripts/web/services/music_service.py @@ -126,6 +126,9 @@ def list_music_files(rel_path: str = ""): total_size = 0 try: for entry in os.scandir(target_dir): + if entry.name.startswith('.'): + # Skip temp/upload internals and hidden items + continue if entry.is_dir(): dirs.append({ "name": entry.name, diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js index bd8fc2e..402b036 100644 --- a/scripts/web/static/js/music.js +++ b/scripts/web/static/js/music.js @@ -41,7 +41,7 @@ row.innerHTML = `
${item.file.name}
-
${formatBytes(item.file.size)}
+
${formatBytes(item.file.size)} → /${item.targetPath || currentPath || ''}
`; @@ -71,6 +71,14 @@ return `${val.toFixed(val >= 10 ? 0 : 1)} ${units[i - 1] || 'KB'}`; } + function deriveTargetPath(file) { + const rel = (file.webkitRelativePath || file.relativePath || '').replace(/^\/+/, ''); + const relDir = rel.includes('/') ? rel.substring(0, rel.lastIndexOf('/')) : ''; + if (currentPath && relDir) return `${currentPath}/${relDir}`; + if (currentPath) return currentPath; + return relDir; + } + function addFiles(files) { const allowed = ['.mp3', '.flac', '.wav', '.aac', '.m4a']; Array.from(files).forEach((file) => { @@ -83,7 +91,7 @@ setStatus(`${file.name} exceeds ${maxMb} MiB limit`, true); return; } - queue.push({ file, progressEl: null }); + queue.push({ file, progressEl: null, targetPath: deriveTargetPath(file) }); }); if (queue.length > 0) { setStatus(`${queue.length} file(s) queued`, false); @@ -95,6 +103,7 @@ const file = item.file; const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize)); const uploadId = crypto.randomUUID ? crypto.randomUUID() : `upload-${Date.now()}-${Math.random()}`; + const pathForFile = item.targetPath || currentPath || ''; for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { const start = chunkIndex * chunkSize; @@ -108,7 +117,7 @@ total_chunks: String(totalChunks), total_size: String(file.size), }); - if (currentPath) params.set('path', currentPath); + if (pathForFile) params.set('path', pathForFile); const res = await fetch(`${uploadUrl}?${params.toString()}`, { method: 'POST', diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html index 0007def..7385978 100644 --- a/scripts/web/templates/music.html +++ b/scripts/web/templates/music.html @@ -234,13 +234,15 @@

Files

Upload

Uploads require Edit mode. Files larger than {{ max_upload_chunk_mb }} MiB are sent in chunks - to keep memory low. Target folder: /{{ current_path }}

+ to keep memory low. You can drop whole folders; we'll preserve subfolder structure. Target folder: + /{{ current_path }} +

Drop files here or click to choose
Allowed: mp3, flac, wav, aac, m4a. Max {{ max_upload_size_mb }} MiB per file.
- +
From d73a3122f0fb7e0a0554f2145537a78cb973c661 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 20:57:21 +0100 Subject: [PATCH 4/9] feat(music): enhance file upload by supporting directory structure and skipping hidden files Signed-off-by: Joost Buskermolen --- scripts/web/static/js/music.js | 57 ++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js index 402b036..d50d60b 100644 --- a/scripts/web/static/js/music.js +++ b/scripts/web/static/js/music.js @@ -81,12 +81,13 @@ function addFiles(files) { const allowed = ['.mp3', '.flac', '.wav', '.aac', '.m4a']; + let skipped = 0; Array.from(files).forEach((file) => { - const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); - if (!allowed.includes(ext)) { - setStatus(`${file.name} skipped (unsupported type)`, true); - return; - } + if (file.name.startsWith('.')) { skipped += 1; return; } + const lastDot = file.name.lastIndexOf('.'); + if (lastDot < 0) { skipped += 1; return; } + const ext = file.name.substring(lastDot).toLowerCase(); + if (!allowed.includes(ext)) { skipped += 1; return; } if (maxBytes && file.size > maxBytes) { setStatus(`${file.name} exceeds ${maxMb} MiB limit`, true); return; @@ -95,10 +96,38 @@ }); if (queue.length > 0) { setStatus(`${queue.length} file(s) queued`, false); + } else if (skipped > 0) { + setStatus(`${skipped} item(s) skipped (unsupported type)`, true); } renderQueue(); } + function readEntriesAsync(dirReader) { + return new Promise((resolve, reject) => { + dirReader.readEntries((entries) => resolve(entries), reject); + }); + } + + async function collectFromEntry(entry, basePath, out) { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + if (file.name.startsWith('.')) return; + file.relativePath = basePath ? `${basePath}/${file.name}` : file.name; + out.push(file); + return; + } + if (entry.isDirectory) { + const reader = entry.createReader(); + let entries; + do { + entries = await readEntriesAsync(reader); + for (const child of entries) { + await collectFromEntry(child, basePath ? `${basePath}/${entry.name}` : entry.name, out); + } + } while (entries.length > 0); + } + } + async function uploadFile(item) { const file = item.file; const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize)); @@ -190,9 +219,25 @@ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragging')); - dropZone.addEventListener('drop', (e) => { + dropZone.addEventListener('drop', async (e) => { e.preventDefault(); dropZone.classList.remove('dragging'); + + const items = e.dataTransfer.items; + if (items && items.length && items[0].webkitGetAsEntry) { + const collected = []; + for (const item of items) { + const entry = item.webkitGetAsEntry(); + if (entry) { + await collectFromEntry(entry, '', collected); + } + } + if (collected.length) { + addFiles(collected); + return; + } + } + if (e.dataTransfer.files) { addFiles(e.dataTransfer.files); } From 09984ac82bbc3c1bc0db32f9aa29a4a5e3c951db Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 21:03:55 +0100 Subject: [PATCH 5/9] feat(music): add upload status indicators and styling for uploaded files Signed-off-by: Joost Buskermolen --- scripts/web/static/js/music.js | 16 ++++++++++++++++ scripts/web/templates/music.html | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js index d50d60b..4fd8d89 100644 --- a/scripts/web/static/js/music.js +++ b/scripts/web/static/js/music.js @@ -50,7 +50,13 @@ const bar = document.createElement('span'); progress.appendChild(bar); row.appendChild(progress); + const status = document.createElement('div'); + status.className = 'meta'; + status.textContent = 'Waiting'; + row.appendChild(status); item.progressEl = bar; + item.statusEl = status; + item.rowEl = row; fileList.appendChild(row); }); const disabled = queue.length === 0 || uploading; @@ -134,6 +140,10 @@ const uploadId = crypto.randomUUID ? crypto.randomUUID() : `upload-${Date.now()}-${Math.random()}`; const pathForFile = item.targetPath || currentPath || ''; + if (item.statusEl) item.statusEl.textContent = 'Starting...'; + if (item.rowEl) item.rowEl.classList.remove('uploaded'); + if (item.progressEl) item.progressEl.style.width = '0%'; + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { const start = chunkIndex * chunkSize; const end = Math.min(start + chunkSize, file.size); @@ -162,20 +172,26 @@ try { data = await res.json(); } catch (e) { + if (item.statusEl) item.statusEl.textContent = 'Failed'; setStatus('Upload failed: invalid server response', true); return false; } if (!res.ok || !data.success) { const message = data && data.error ? data.error : 'Upload failed'; + if (item.statusEl) item.statusEl.textContent = 'Failed'; setStatus(`${file.name}: ${message}`, true); return false; } const pct = Math.round(((chunkIndex + 1) / totalChunks) * 100); if (item.progressEl) item.progressEl.style.width = `${pct}%`; + if (item.statusEl) item.statusEl.textContent = `${pct}%`; } + if (item.progressEl) item.progressEl.style.width = '100%'; + if (item.statusEl) item.statusEl.textContent = 'Uploaded'; + if (item.rowEl) item.rowEl.classList.add('uploaded'); setStatus(`${file.name} uploaded`, false); return true; } diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html index 7385978..00e9f84 100644 --- a/scripts/web/templates/music.html +++ b/scripts/web/templates/music.html @@ -69,6 +69,11 @@ background: var(--panel-bg); } + .file-row.uploaded { + border-color: #2ecc71; + box-shadow: 0 0 0 1px rgba(46, 204, 113, 0.3); + } + .file-row .meta { color: var(--muted-text); font-size: 0.9rem; From 3a6b9be7fdbccd0dc4580c861c15064174638e7d Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 21:08:28 +0100 Subject: [PATCH 6/9] feat(music): add directory deletion functionality and update UI for folder actions Signed-off-by: Joost Buskermolen --- scripts/web/blueprints/music.py | 26 ++++++++++++++++ scripts/web/services/music_service.py | 25 +++++++++++++++ scripts/web/static/js/music.js | 44 +++++++++++++++++++++++++-- scripts/web/templates/music.html | 11 +++++-- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/scripts/web/blueprints/music.py b/scripts/web/blueprints/music.py index b26499c..181192f 100644 --- a/scripts/web/blueprints/music.py +++ b/scripts/web/blueprints/music.py @@ -9,6 +9,7 @@ save_file, handle_chunk, delete_music_file, + delete_directory, create_directory, move_music_file, UploadError, @@ -155,6 +156,31 @@ def delete_music(filename): return redirect(url_for("music.music_home", path=request.args.get("path", ""), _=request.args.get('_', 0))) +@music_bp.route("/delete_dir/", methods=["POST"]) +def delete_music_dir(dirname): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + try: + require_edit_mode() + except UploadError as exc: + if is_ajax: + return jsonify({"success": False, "error": str(exc)}), 400 + flash(str(exc), "error") + return redirect(url_for("music.music_home")) + + try: + ok, msg = delete_directory(dirname) + except UploadError as exc: + ok, msg = False, str(exc) + + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + parent = dirname.rsplit("/", 1)[0] if "/" in dirname else "" + return redirect(url_for("music.music_home", path=parent, _=request.args.get('_', 0))) + + @music_bp.route("/move", methods=["POST"]) def move_music(): is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py index 6e67f34..f99d357 100644 --- a/scripts/web/services/music_service.py +++ b/scripts/web/services/music_service.py @@ -3,6 +3,7 @@ import os import uuid +import shutil import logging from typing import Tuple, List @@ -310,6 +311,30 @@ def create_directory(rel_path: str, name: str) -> Tuple[bool, str]: return True, f"Created folder {safe_name}" +def delete_directory(rel_path: str) -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + if not rel_path: + return False, "Invalid folder path" + + target_dir = _resolve_subpath(mount_path, rel_path) + if os.path.abspath(target_dir) == os.path.abspath(mount_path): + return False, "Cannot delete root folder" + if not os.path.isdir(target_dir): + return False, "Folder not found" + + try: + shutil.rmtree(target_dir) + _fsync_dir(os.path.dirname(target_dir)) + close_samba_share("part3") + except Exception as exc: # pylint: disable=broad-except + logger.error("Failed to delete folder %s: %s", rel_path, exc) + return False, "Unable to delete folder" + return True, "Deleted folder" + + def move_music_file(source_rel: str, dest_rel: str, new_name: str = "") -> Tuple[bool, str]: mount_path, err = _ensure_music_mount() if err: diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js index 4fd8d89..0d1be9c 100644 --- a/scripts/web/static/js/music.js +++ b/scripts/web/static/js/music.js @@ -6,6 +6,7 @@ const maxMb = parseInt(page.dataset.maxMb || '0', 10) || 0; const uploadUrl = page.dataset.uploadUrl; const deleteTemplate = page.dataset.deleteUrl; + const deleteDirTemplate = page.dataset.deleteDirUrl; const moveUrl = page.dataset.moveUrl; const browseUrl = page.dataset.browseUrl; const mkdirUrl = page.dataset.mkdirUrl; @@ -348,8 +349,47 @@ } if (folderTable) { - folderTable.addEventListener('click', (e) => { - const row = e.target.closest('tr'); + folderTable.addEventListener('click', async (e) => { + const target = e.target; + const deleteDir = target.getAttribute('data-delete-dir'); + const openDir = target.getAttribute('data-open'); + + if (deleteDir) { + e.preventDefault(); + e.stopPropagation(); + if (!deleteDirTemplate) return; + target.disabled = true; + const url = deleteDirTemplate.replace('__DIR__', encodeURIComponent(deleteDir)); + const res = await fetch(url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Delete folder failed: bad response', true); + return; + } + target.disabled = false; + if (!res.ok || !data.success) { + setStatus(data && data.error ? data.error : 'Delete folder failed', true); + return; + } + const row = target.closest('tr'); + if (row) row.remove(); + setStatus(data.message || 'Folder deleted', false); + return; + } + + if (openDir) { + e.preventDefault(); + navigateTo(openDir); + return; + } + + const row = target.closest('tr'); if (!row) return; const dir = row.getAttribute('data-dir'); if (dir !== null) { diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html index 00e9f84..77b9cb1 100644 --- a/scripts/web/templates/music.html +++ b/scripts/web/templates/music.html @@ -148,6 +148,7 @@
@@ -191,7 +192,8 @@

Files

Name SizeActions
{{ file.name }}
{{ file.path }} {{ format_file_size(file.size) }} - + +
- + + @@ -203,7 +205,10 @@

Files

{% for d in dirs %} - + {% endfor %} @@ -221,7 +226,7 @@

Files

{% for file in files %} - +
FoldersFoldersActions
📁 {{ d.name }}Open + + +
{{ file.path }}{{ file.name }} {{ format_file_size(file.size) }} From 6d39bb2d34272ee2f6c2a20fd3108fead0136866 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Mon, 16 Feb 2026 21:56:57 +0100 Subject: [PATCH 7/9] feat(music): update music file listing to include used and total bytes, and adjust UI accordingly Signed-off-by: Joost Buskermolen --- scripts/web/blueprints/music.py | 5 +++-- scripts/web/services/music_service.py | 20 ++++++++++++++------ scripts/web/templates/music.html | 6 +++--- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/scripts/web/blueprints/music.py b/scripts/web/blueprints/music.py index 181192f..76f5945 100644 --- a/scripts/web/blueprints/music.py +++ b/scripts/web/blueprints/music.py @@ -25,7 +25,7 @@ def music_home(): ctx = get_base_context() current_path = request.args.get("path", "") - dirs, files, error, total_size, free_bytes, current_path = list_music_files(current_path) + dirs, files, error, used_bytes, free_bytes, current_path, total_bytes = list_music_files(current_path) return render_template( "music.html", page="music", @@ -33,8 +33,9 @@ def music_home(): dirs=dirs, files=files, error=error, - total_size=total_size, + used_bytes=used_bytes, free_bytes=free_bytes, + total_bytes=total_bytes, current_path=current_path, format_file_size=format_file_size, max_upload_size_mb=current_app.config.get("MAX_CONTENT_LENGTH", 0) // (1024 * 1024), diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py index f99d357..6e7b38d 100644 --- a/scripts/web/services/music_service.py +++ b/scripts/web/services/music_service.py @@ -111,16 +111,25 @@ def _validate_filename(name: str) -> str: def list_music_files(rel_path: str = ""): mount_path, err = _ensure_music_mount() if err: - return [], [], err, 0, 0, "" + return [], [], err, 0, 0, "", 0 try: current_rel = _normalize_rel_path(rel_path) except UploadError as exc: - return [], [], str(exc), 0, 0, "" + return [], [], str(exc), 0, 0, "", 0 + + try: + stat = os.statvfs(mount_path) + total_bytes = stat.f_blocks * stat.f_frsize + free_bytes = stat.f_bavail * stat.f_frsize + used_bytes = max(0, total_bytes - (stat.f_bfree * stat.f_frsize)) + except OSError as exc: + logger.warning("Could not stat music mount: %s", exc) + return [], [], "Music drive unavailable", 0, 0, current_rel, 0 target_dir = _resolve_subpath(mount_path, current_rel) if not os.path.isdir(target_dir): - return [], [], "Folder not found", 0, 0, current_rel + return [], [], "Folder not found", used_bytes, free_bytes, current_rel, total_bytes dirs = [] music_files = [] @@ -152,12 +161,11 @@ def list_music_files(rel_path: str = ""): total_size += stat.st_size except OSError as e: logger.warning("Could not read music directory: %s", e) - return [], [], "Unable to read music directory", 0, 0, current_rel + return [], [], "Unable to read music directory", used_bytes, free_bytes, current_rel, total_bytes - free_bytes = _fs_free_bytes(mount_path) dirs.sort(key=lambda x: x["name"].lower()) music_files.sort(key=lambda x: x["name"].lower()) - return dirs, music_files, "", total_size, free_bytes, current_rel + return dirs, music_files, "", used_bytes, free_bytes, current_rel, total_bytes def _prepare_paths(filename: str, music_dir: str): diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html index 77b9cb1..d64151f 100644 --- a/scripts/web/templates/music.html +++ b/scripts/web/templates/music.html @@ -159,7 +159,7 @@

Music Library

Used
-
{{ format_file_size(total_size) }}
+
{{ format_file_size(used_bytes) }}
Free
@@ -170,8 +170,8 @@

Music Library

{{ files|length }}
- {% set total_space = total_size + free_bytes %} - {% set used_pct = (100 * total_size / total_space) if total_space else 0 %} + {% set total_space = total_bytes if total_bytes else used_bytes + free_bytes %} + {% set used_pct = (100 * used_bytes / total_space) if total_space else 0 %}
From b620139578cfe113d4868fc754c0961ad0210d84 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Tue, 17 Feb 2026 18:10:13 +0100 Subject: [PATCH 8/9] feat(music): enforce Music folder structure and update UI with informative callout Signed-off-by: Joost Buskermolen --- readme.md | 54 ++++++++++++++++++++++-- scripts/web/services/music_service.py | 60 +++++++++++++++++++++------ scripts/web/templates/music.html | 10 +++++ 3 files changed, 108 insertions(+), 16 deletions(-) diff --git a/readme.md b/readme.md index b61f68f..41a85b9 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - **LightShow Drive**: Smaller FAT32 drive for lock chimes, custom wrap images, and light shows with read-only optimization **Key Benefits:** + - Remote access to dashcam footage without physically removing storage - Web interface for browsing videos, managing chimes, managing light shows, managing custom wrap images and monitoring storage (with light/dark mode) - Automatic cleanup policies to manage disk space @@ -30,6 +31,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv > **⚠️ Personal Project Notice** > > This is a personal project built for my own use. You are welcome to fork the code and make your own changes or updates. Please be aware: +> > - The Git repository may update frequently with new features and changes > - Bugs may be introduced into the main branch without extensive testing > - Bug fixes will be worked on as time permits, but **no timelines or guarantees** are provided @@ -39,6 +41,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv ## Features ### Core Functionality + - **Dual-Drive USB Gadget**: Two independent filesystems (TeslaCam + LightShow) with optimized performance - **Two Operating Modes**: - **Present Mode**: Active USB gadget for Tesla recording @@ -47,6 +50,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - **Captive Portal**: Automatic splash screen when connecting to TeslaUSB WiFi network ### Video Management + - Browse all TeslaCam folders (RecentClips, SavedClips, SentryClips) - Auto-generated video thumbnails - In-browser multi-camera event player with 6 camera angles @@ -60,6 +64,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - Storage analytics with folder-by-folder breakdown ### Lock Chime Management + - Upload WAV or MP3 files (automatically converted to Tesla-compatible format) - Organized chime library with preview and download - Volume normalization presets (Broadcast, Streaming, Loud, Maximum) @@ -72,12 +77,14 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - Recurring rotation (every 15min to 12 hours, or on boot) ### Light Show Management + - Upload FSEQ and MP3/WAV files - Grouped display (pairs sequence + audio files) - Preview MP3/WAV tracks in browser - Delete complete light show sets ### Custom Wrap Management + - Upload PNG files for Tesla's Paint Shop 3D vehicle visualization - Thumbnail previews of all uploaded wraps - Automatic validation (512-1024px dimensions, max 1MB, PNG only) @@ -85,6 +92,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - Drag-and-drop upload with progress indicator ### Automatic Maintenance + - **Storage Cleanup**: Age, size, or count-based policies per folder - **Boot Cleanup**: Optional automatic cleanup before presenting to Tesla - **On-Demand Thumbnails**: Instant generation via PyAV as you browse (cached for 7 days) @@ -92,6 +100,7 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - **Hardware Watchdog**: Automatic system recovery on hangs or crashes ### Network Features + - **Samba Shares**: Windows/Mac/Linux file access in Edit mode - **Offline Access Point**: Automatic fallback AP when WiFi unavailable (in-car web access) - **WiFi Roaming**: Automatic switching between access points with the same SSID for optimal signal strength (mesh networks and WiFi extenders) @@ -127,6 +136,7 @@ For detailed information, see the official Raspberry Pi whitepaper: [Using OTG m **Note for Pi Zero 2 W users**: Setup automatically optimizes memory by disabling unnecessary desktop services and enabling 1GB swap. This ensures stable operation on the 512MB RAM platform. **⚠️ Note for Raspberry Pi 4/5 users**: USB OTG/gadget mode is **only available on the USB-C port**, which is also the power input. This creates a challenge: you cannot simultaneously power the Pi from a standard USB charger and present as a USB device to Tesla. Options include: + - USB-C power + data splitter adapters (search "USB-C OTG with PD charging") - Powering the Pi via GPIO pins from a separate car charger (advanced) - Using a larger SD card instead of external USB storage to avoid power budget issues @@ -155,6 +165,7 @@ sudo ./setup_usb.sh ``` The setup script will: + - Install required packages (parted, dosfstools, python3-flask, python3-av, samba, hostapd, dnsmasq, ffmpeg) - Optimize memory for low-RAM systems (disable desktop services, enable swap) - Configure USB gadget kernel modules and hardware watchdog @@ -172,6 +183,7 @@ Alternatively, connect to the TeslaUSB WiFi network and the captive portal will ### 4. Connect to Tesla Connect the Pi to your Tesla's USB port: + - **Pi Zero 2 W**: Use USB port labeled "USB" (not "PWR") - **Pi 4/5**: Use USB-C port @@ -182,6 +194,7 @@ Tesla will detect two separate USB drives automatically. The TeslaUSB device only runs when the car is awake. When your Tesla enters sleep mode, USB ports are powered off and the Raspberry Pi shuts down. **To keep your vehicle awake for extended management sessions:** + 1. Turn on climate control 2. Enable "Dog Mode" or "Camp Mode" from the climate screen 3. Connect to the TeslaUSB web interface and manage your lock chimes, light shows, or videos @@ -195,18 +208,21 @@ The TeslaUSB device only runs when the car is awake. When your Tesla enters slee ### Operating Modes **Present USB Mode** (default on boot): + - Pi appears as USB drives to Tesla - Drives mounted read-only locally at `/mnt/gadget/part1-ro`, `/mnt/gadget/part2-ro` - Web interface: View/play only (no editing) - Samba shares disabled **Edit USB Mode**: + - USB gadget disconnected - Drives mounted read-write at `/mnt/gadget/part1`, `/mnt/gadget/part2` - Web interface: Full file management (upload, delete, organize) - Samba shares active for network access **Switch modes** via web interface or command line: + ```bash sudo /home/pi/TeslaUSB/present_usb.sh # Activate Present mode sudo /home/pi/TeslaUSB/edit_usb.sh # Activate Edit mode @@ -215,12 +231,14 @@ sudo /home/pi/TeslaUSB/edit_usb.sh # Activate Edit mode ### Network Access **Samba Shares** (Edit mode only): + - `\\\gadget_part1` - TeslaCam drive - `\\\gadget_part2` - LightShow drive - Default credentials: username = `pi`, password = `tesla` **Offline Access Point with Captive Portal**: When WiFi is unavailable, the Pi automatically creates a fallback access point: + - SSID: `TeslaUSB` (configurable in `config.yaml`) - Password: `teslausb1234` (change this!) - IP: `192.168.4.1` @@ -235,18 +253,29 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: ### Web Features **Settings Tab**: + - Switch between Present USB and Edit USB modes - Configure offline access point (SSID, password, auto/manual mode) - View network status and Samba share information **Videos Tab**: + - Browse all TeslaCam folders with auto-generated thumbnails - Multi-camera event player with 6 camera angles - Download all camera views as zip file - Delete entire events (Edit mode only) - deletes all camera views for the session +**Music Tab**: + +- Tesla scans music only from a root-level `Music` folder; the app enforces this and automatically creates it if missing +- Browse folders with breadcrumb navigation and clean per-folder views +- Drag-and-drop or select files and whole folders; chunked uploads keep memory low and preserve subfolder structure +- Per-file progress and status indicators with size limit validation +- Create folders, move files, and delete files or entire folders (Edit mode) +- Usage gauge shows used/free space for the music partition **Lock Chimes Tab**: + - Upload WAV/MP3 files (auto-converted to Tesla format) - Preview all chimes with in-browser audio player - Set any chime as active `LockChime.wav` @@ -258,12 +287,14 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: - Schedule automatic chime changes (weekly, date, holiday, recurring) **Light Shows Tab**: + - Upload and manage FSEQ + MP3/WAV light show files - Grouped display for matching files - Preview MP3/WAV audio in browser - Delete complete light show sets **Wraps Tab**: + - Upload PNG files for custom Tesla vehicle wraps (Paint Shop → Wraps) - Thumbnail preview gallery of all uploaded wraps - Client-side validation before upload (dimensions, file size, format) @@ -271,6 +302,7 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: - Requirements: PNG format, 512x512 to 1024x1024 pixels, max 1MB, up to 10 wraps **Analytics Tab**: + - Drive usage gauge and folder breakdown - Video count and size statistics - Configure cleanup policies (age, size, count-based) @@ -344,21 +376,25 @@ web: ``` **Important settings to change before first use:** + - `network.samba_password` - Default is `tesla` (change this!) - `offline_ap.ssid` - Default is `TeslaUSB` (customize for your vehicle) - `offline_ap.passphrase` - Default is `teslausb1234` (change this!) - `web.secret_key` - Auto-generated on first run, but can be set manually **Optional settings:** + - `disk_images.boot_fsck_enabled` - Auto-repair filesystems on boot (default: `true`, recommended) **After making changes:** Restart affected services + ```bash sudo systemctl restart gadget_web.service # For web application changes sudo systemctl restart wifi-monitor.service # For offline AP changes ``` **How it works:** + - Bash scripts use `yq` to read YAML values - Python web app uses `PyYAML` to load configuration - Single source of truth for all settings @@ -387,7 +423,7 @@ Removes all files, services, and system configuration. ## Systemd Services | Service/Timer | Purpose | -|---------------|---------| +| --- | --- | | `gadget_web.service` | Web interface (port 80) with captive portal | | `present_usb_on_boot.service` | Auto-present USB on boot with optional cleanup | | `chime_scheduler.timer` | Check scheduled chime changes every 60 seconds | @@ -395,6 +431,7 @@ Removes all files, services, and system configuration. | `watchdog.service` | Hardware watchdog for system reliability | **Common Commands:** + ```bash # Check service status sudo systemctl status gadget_web.service @@ -413,7 +450,7 @@ sudo systemctl disable present_usb_on_boot.service The hardware watchdog automatically reboots the Pi if the system becomes unresponsive. The default configuration is intentionally simple and reliable: -``` +```bash watchdog-device = /dev/watchdog watchdog-timeout = 60 max-load-1 = 24 @@ -426,7 +463,7 @@ priority = 1 The following options should be **avoided** on Raspberry Pi Zero 2 W (512MB RAM): | Setting | Problem | -|---------|---------| +| --- | --- | | `min-memory = 50000` | Pi Zero 2 W often has <50MB free during normal operation, triggering unnecessary reboots | | `repair-binary = /usr/lib/watchdog/repair` | This file doesn't exist on Raspberry Pi OS | | `interval` (low values) | Can cause timing issues with the kernel watchdog | @@ -446,6 +483,7 @@ The following options should be **avoided** on Raspberry Pi Zero 2 W (512MB RAM) ### Common Issues **Web interface not accessible:** + ```bash # Check service status and logs sudo systemctl status gadget_web.service @@ -453,11 +491,13 @@ sudo journalctl -u gadget_web.service -f ``` **Videos not showing:** + - Verify correct mode (Present or Edit, not Unknown) - Check TeslaCam folder exists on drive 1 - Confirm drive is properly mounted **Samba shares appear empty:** + ```bash # Force Samba refresh sudo smbcontrol all close-share gadget_part1 @@ -467,12 +507,14 @@ sudo systemctl restart smbd nmbd **Tesla not recognizing new lock chime:** Try these steps in order: + 1. Power cycle Tesla (close doors, walk away 5+ minutes, wake up) 2. Switch USB modes (Edit → wait 10s → Present) 3. Physical reconnect (unplug Pi, wait 10s, plug back in) 4. Tesla reboot (hold both scroll wheels until screen goes black) **Operation in Progress banner stuck:** + ```bash # Check and remove stale lock file if older than 120 seconds ls -lh /home/pi/TeslaUSB/.quick_edit_part2.lock @@ -480,6 +522,7 @@ rm /home/pi/TeslaUSB/.quick_edit_part2.lock ``` **iOS file upload not working:** + - Use **Safari** on iOS (third-party browsers have restricted file access) - Desktop browsers work normally regardless of choice @@ -499,21 +542,25 @@ sudo dmesg | grep -i "mass_storage\|gadget" ## Technical Details **Dual-Drive Architecture:** + - Two separate disk images (`usb_cam.img` exFAT, `usb_lightshow.img` FAT32) - Sparse files (only use space as needed) - Presented as dual-LUN USB gadget to Tesla **USB Gadget Implementation:** + - Linux `g_mass_storage` kernel module via `libcomposite` - LUN 0: Read-write (ro=0) for TeslaCam recordings - LUN 1: Read-only (ro=1) for LightShow/Chimes **Concurrency Protection:** + - `.quick_edit_part2.lock` file prevents race conditions - 10-second timeout, 120-second stale lock detection - All services and scripts respect lock state **Performance Optimizations:** + - **Boot time**: ~14 seconds on Pi Zero 2 W (detects existing RW mount at boot to skip unnecessary remount operations) - **Configuration loading**: Single YAML parse with secure eval (properly quoted values prevent command injection) - **Web UI responsiveness**: Settings page loads in ~0.4s (optimized from 133s through batched configuration reads) @@ -532,4 +579,3 @@ All screenshots shown in dark mode. Lock Chimes Management Lock Chime Audio Editor with Waveform Light Shows Management - diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py index 6e7b38d..0a5ea4c 100644 --- a/scripts/web/services/music_service.py +++ b/scripts/web/services/music_service.py @@ -98,6 +98,17 @@ def _ensure_music_mount() -> Tuple[str, str]: return mount_path, "" +def _get_music_root(mount_path: str) -> Tuple[str, str]: + """Ensure the Tesla-required Music folder exists and return its path.""" + music_root = os.path.join(mount_path, "Music") + try: + os.makedirs(music_root, exist_ok=True) + except OSError as exc: + logger.error("Unable to create Music folder: %s", exc) + return "", "Unable to access Music folder" + return music_root, "" + + def _validate_filename(name: str) -> str: safe = secure_filename(name) if not safe: @@ -113,6 +124,10 @@ def list_music_files(rel_path: str = ""): if err: return [], [], err, 0, 0, "", 0 + music_root, err = _get_music_root(mount_path) + if err: + return [], [], err, 0, 0, "", 0 + try: current_rel = _normalize_rel_path(rel_path) except UploadError as exc: @@ -127,7 +142,7 @@ def list_music_files(rel_path: str = ""): logger.warning("Could not stat music mount: %s", exc) return [], [], "Music drive unavailable", 0, 0, current_rel, 0 - target_dir = _resolve_subpath(mount_path, current_rel) + target_dir = _resolve_subpath(music_root, current_rel) if not os.path.isdir(target_dir): return [], [], "Folder not found", used_bytes, free_bytes, current_rel, total_bytes @@ -182,7 +197,11 @@ def save_file(file_storage, rel_path: str = "") -> Tuple[bool, str]: if err: return False, err - target_dir = _resolve_subpath(mount_path, rel_path) + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + target_dir = _resolve_subpath(music_root, rel_path) if not os.path.isdir(target_dir): try: os.makedirs(target_dir, exist_ok=True) @@ -233,7 +252,11 @@ def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: if err: return False, err, False - target_dir = _resolve_subpath(mount_path, rel_path) + music_root, err = _get_music_root(mount_path) + if err: + return False, err, False + + target_dir = _resolve_subpath(music_root, rel_path) os.makedirs(target_dir, exist_ok=True) filename = _validate_filename(filename) @@ -279,7 +302,11 @@ def delete_music_file(rel_path: str) -> Tuple[bool, str]: if err: return False, err - target_path = _resolve_subpath(mount_path, rel_path) + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + target_path = _resolve_subpath(music_root, rel_path) filename = os.path.basename(target_path) if not os.path.isfile(target_path): return False, "File not found" @@ -299,15 +326,16 @@ def create_directory(rel_path: str, name: str) -> Tuple[bool, str]: if err: return False, err - base_dir = _resolve_subpath(mount_path, rel_path) + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + base_dir = _resolve_subpath(music_root, rel_path) safe_name = secure_filename(name or "") if not safe_name: return False, "Invalid folder name" target_dir = os.path.join(base_dir, safe_name) - common = os.path.commonpath([mount_path, target_dir]) - if common != os.path.abspath(mount_path): - return False, "Invalid folder path" try: os.makedirs(target_dir, exist_ok=False) @@ -324,11 +352,15 @@ def delete_directory(rel_path: str) -> Tuple[bool, str]: if err: return False, err + music_root, err = _get_music_root(mount_path) + if err: + return False, err + if not rel_path: return False, "Invalid folder path" - target_dir = _resolve_subpath(mount_path, rel_path) - if os.path.abspath(target_dir) == os.path.abspath(mount_path): + target_dir = _resolve_subpath(music_root, rel_path) + if os.path.abspath(target_dir) == os.path.abspath(music_root): return False, "Cannot delete root folder" if not os.path.isdir(target_dir): return False, "Folder not found" @@ -348,11 +380,15 @@ def move_music_file(source_rel: str, dest_rel: str, new_name: str = "") -> Tuple if err: return False, err - src_path = _resolve_subpath(mount_path, source_rel) + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + src_path = _resolve_subpath(music_root, source_rel) if not os.path.isfile(src_path): return False, "Source file not found" - dest_dir = _resolve_subpath(mount_path, dest_rel) + dest_dir = _resolve_subpath(music_root, dest_rel) try: os.makedirs(dest_dir, exist_ok=True) except OSError: diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html index d64151f..68d2193 100644 --- a/scripts/web/templates/music.html +++ b/scripts/web/templates/music.html @@ -136,6 +136,14 @@ background: var(--panel-bg); } + .callout { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + background: var(--panel-bg); + margin: 10px 0 4px 0; + } + @media (max-width: 960px) { .music-layout { grid-template-columns: 1fr; @@ -156,6 +164,8 @@

Music Library

{% if error %}
{{ error }}
{% endif %} +
Tesla only scans music inside the /Music folder. This page always reads and + uploads inside that folder; paths shown below are relative to /Music.
Used
From 713155918cb385bf9a7878c99308c534978730d7 Mon Sep 17 00:00:00 2001 From: Joost Buskermolen Date: Tue, 17 Feb 2026 21:34:51 +0100 Subject: [PATCH 9/9] chore: unnecessary formatting Signed-off-by: Joost Buskermolen --- readme.md | 44 ++------------------------------------------ 1 file changed, 2 insertions(+), 42 deletions(-) diff --git a/readme.md b/readme.md index 41a85b9..4bb0307 100644 --- a/readme.md +++ b/readme.md @@ -21,7 +21,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - **LightShow Drive**: Smaller FAT32 drive for lock chimes, custom wrap images, and light shows with read-only optimization **Key Benefits:** - - Remote access to dashcam footage without physically removing storage - Web interface for browsing videos, managing chimes, managing light shows, managing custom wrap images and monitoring storage (with light/dark mode) - Automatic cleanup policies to manage disk space @@ -31,7 +30,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv > **⚠️ Personal Project Notice** > > This is a personal project built for my own use. You are welcome to fork the code and make your own changes or updates. Please be aware: -> > - The Git repository may update frequently with new features and changes > - Bugs may be introduced into the main branch without extensive testing > - Bug fixes will be worked on as time permits, but **no timelines or guarantees** are provided @@ -41,7 +39,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv ## Features ### Core Functionality - - **Dual-Drive USB Gadget**: Two independent filesystems (TeslaCam + LightShow) with optimized performance - **Two Operating Modes**: - **Present Mode**: Active USB gadget for Tesla recording @@ -50,7 +47,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - **Captive Portal**: Automatic splash screen when connecting to TeslaUSB WiFi network ### Video Management - - Browse all TeslaCam folders (RecentClips, SavedClips, SentryClips) - Auto-generated video thumbnails - In-browser multi-camera event player with 6 camera angles @@ -64,7 +60,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - Storage analytics with folder-by-folder breakdown ### Lock Chime Management - - Upload WAV or MP3 files (automatically converted to Tesla-compatible format) - Organized chime library with preview and download - Volume normalization presets (Broadcast, Streaming, Loud, Maximum) @@ -77,14 +72,12 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - Recurring rotation (every 15min to 12 hours, or on boot) ### Light Show Management - - Upload FSEQ and MP3/WAV files - Grouped display (pairs sequence + audio files) - Preview MP3/WAV tracks in browser - Delete complete light show sets ### Custom Wrap Management - - Upload PNG files for Tesla's Paint Shop 3D vehicle visualization - Thumbnail previews of all uploaded wraps - Automatic validation (512-1024px dimensions, max 1MB, PNG only) @@ -92,7 +85,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - Drag-and-drop upload with progress indicator ### Automatic Maintenance - - **Storage Cleanup**: Age, size, or count-based policies per folder - **Boot Cleanup**: Optional automatic cleanup before presenting to Tesla - **On-Demand Thumbnails**: Instant generation via PyAV as you browse (cached for 7 days) @@ -100,7 +92,6 @@ TeslaUSB creates a dual-drive USB gadget that appears as **two separate USB driv - **Hardware Watchdog**: Automatic system recovery on hangs or crashes ### Network Features - - **Samba Shares**: Windows/Mac/Linux file access in Edit mode - **Offline Access Point**: Automatic fallback AP when WiFi unavailable (in-car web access) - **WiFi Roaming**: Automatic switching between access points with the same SSID for optimal signal strength (mesh networks and WiFi extenders) @@ -136,7 +127,6 @@ For detailed information, see the official Raspberry Pi whitepaper: [Using OTG m **Note for Pi Zero 2 W users**: Setup automatically optimizes memory by disabling unnecessary desktop services and enabling 1GB swap. This ensures stable operation on the 512MB RAM platform. **⚠️ Note for Raspberry Pi 4/5 users**: USB OTG/gadget mode is **only available on the USB-C port**, which is also the power input. This creates a challenge: you cannot simultaneously power the Pi from a standard USB charger and present as a USB device to Tesla. Options include: - - USB-C power + data splitter adapters (search "USB-C OTG with PD charging") - Powering the Pi via GPIO pins from a separate car charger (advanced) - Using a larger SD card instead of external USB storage to avoid power budget issues @@ -165,7 +155,6 @@ sudo ./setup_usb.sh ``` The setup script will: - - Install required packages (parted, dosfstools, python3-flask, python3-av, samba, hostapd, dnsmasq, ffmpeg) - Optimize memory for low-RAM systems (disable desktop services, enable swap) - Configure USB gadget kernel modules and hardware watchdog @@ -183,7 +172,6 @@ Alternatively, connect to the TeslaUSB WiFi network and the captive portal will ### 4. Connect to Tesla Connect the Pi to your Tesla's USB port: - - **Pi Zero 2 W**: Use USB port labeled "USB" (not "PWR") - **Pi 4/5**: Use USB-C port @@ -194,7 +182,6 @@ Tesla will detect two separate USB drives automatically. The TeslaUSB device only runs when the car is awake. When your Tesla enters sleep mode, USB ports are powered off and the Raspberry Pi shuts down. **To keep your vehicle awake for extended management sessions:** - 1. Turn on climate control 2. Enable "Dog Mode" or "Camp Mode" from the climate screen 3. Connect to the TeslaUSB web interface and manage your lock chimes, light shows, or videos @@ -208,21 +195,18 @@ The TeslaUSB device only runs when the car is awake. When your Tesla enters slee ### Operating Modes **Present USB Mode** (default on boot): - - Pi appears as USB drives to Tesla - Drives mounted read-only locally at `/mnt/gadget/part1-ro`, `/mnt/gadget/part2-ro` - Web interface: View/play only (no editing) - Samba shares disabled **Edit USB Mode**: - - USB gadget disconnected - Drives mounted read-write at `/mnt/gadget/part1`, `/mnt/gadget/part2` - Web interface: Full file management (upload, delete, organize) - Samba shares active for network access **Switch modes** via web interface or command line: - ```bash sudo /home/pi/TeslaUSB/present_usb.sh # Activate Present mode sudo /home/pi/TeslaUSB/edit_usb.sh # Activate Edit mode @@ -231,14 +215,12 @@ sudo /home/pi/TeslaUSB/edit_usb.sh # Activate Edit mode ### Network Access **Samba Shares** (Edit mode only): - - `\\\gadget_part1` - TeslaCam drive - `\\\gadget_part2` - LightShow drive - Default credentials: username = `pi`, password = `tesla` **Offline Access Point with Captive Portal**: When WiFi is unavailable, the Pi automatically creates a fallback access point: - - SSID: `TeslaUSB` (configurable in `config.yaml`) - Password: `teslausb1234` (change this!) - IP: `192.168.4.1` @@ -253,20 +235,17 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: ### Web Features **Settings Tab**: - - Switch between Present USB and Edit USB modes - Configure offline access point (SSID, password, auto/manual mode) - View network status and Samba share information **Videos Tab**: - - Browse all TeslaCam folders with auto-generated thumbnails - Multi-camera event player with 6 camera angles - Download all camera views as zip file - Delete entire events (Edit mode only) - deletes all camera views for the session **Music Tab**: - - Tesla scans music only from a root-level `Music` folder; the app enforces this and automatically creates it if missing - Browse folders with breadcrumb navigation and clean per-folder views - Drag-and-drop or select files and whole folders; chunked uploads keep memory low and preserve subfolder structure @@ -275,7 +254,6 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: - Usage gauge shows used/free space for the music partition **Lock Chimes Tab**: - - Upload WAV/MP3 files (auto-converted to Tesla format) - Preview all chimes with in-browser audio player - Set any chime as active `LockChime.wav` @@ -287,14 +265,12 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: - Schedule automatic chime changes (weekly, date, holiday, recurring) **Light Shows Tab**: - - Upload and manage FSEQ + MP3/WAV light show files - Grouped display for matching files - Preview MP3/WAV audio in browser - Delete complete light show sets **Wraps Tab**: - - Upload PNG files for custom Tesla vehicle wraps (Paint Shop → Wraps) - Thumbnail preview gallery of all uploaded wraps - Client-side validation before upload (dimensions, file size, format) @@ -302,7 +278,6 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: - Requirements: PNG format, 512x512 to 1024x1024 pixels, max 1MB, up to 10 wraps **Analytics Tab**: - - Drive usage gauge and folder breakdown - Video count and size statistics - Configure cleanup policies (age, size, count-based) @@ -376,25 +351,21 @@ web: ``` **Important settings to change before first use:** - - `network.samba_password` - Default is `tesla` (change this!) - `offline_ap.ssid` - Default is `TeslaUSB` (customize for your vehicle) - `offline_ap.passphrase` - Default is `teslausb1234` (change this!) - `web.secret_key` - Auto-generated on first run, but can be set manually **Optional settings:** - - `disk_images.boot_fsck_enabled` - Auto-repair filesystems on boot (default: `true`, recommended) **After making changes:** Restart affected services - ```bash sudo systemctl restart gadget_web.service # For web application changes sudo systemctl restart wifi-monitor.service # For offline AP changes ``` **How it works:** - - Bash scripts use `yq` to read YAML values - Python web app uses `PyYAML` to load configuration - Single source of truth for all settings @@ -423,7 +394,7 @@ Removes all files, services, and system configuration. ## Systemd Services | Service/Timer | Purpose | -| --- | --- | +|---------------|---------| | `gadget_web.service` | Web interface (port 80) with captive portal | | `present_usb_on_boot.service` | Auto-present USB on boot with optional cleanup | | `chime_scheduler.timer` | Check scheduled chime changes every 60 seconds | @@ -431,7 +402,6 @@ Removes all files, services, and system configuration. | `watchdog.service` | Hardware watchdog for system reliability | **Common Commands:** - ```bash # Check service status sudo systemctl status gadget_web.service @@ -463,7 +433,7 @@ priority = 1 The following options should be **avoided** on Raspberry Pi Zero 2 W (512MB RAM): | Setting | Problem | -| --- | --- | +|---------|---------| | `min-memory = 50000` | Pi Zero 2 W often has <50MB free during normal operation, triggering unnecessary reboots | | `repair-binary = /usr/lib/watchdog/repair` | This file doesn't exist on Raspberry Pi OS | | `interval` (low values) | Can cause timing issues with the kernel watchdog | @@ -483,7 +453,6 @@ The following options should be **avoided** on Raspberry Pi Zero 2 W (512MB RAM) ### Common Issues **Web interface not accessible:** - ```bash # Check service status and logs sudo systemctl status gadget_web.service @@ -491,13 +460,11 @@ sudo journalctl -u gadget_web.service -f ``` **Videos not showing:** - - Verify correct mode (Present or Edit, not Unknown) - Check TeslaCam folder exists on drive 1 - Confirm drive is properly mounted **Samba shares appear empty:** - ```bash # Force Samba refresh sudo smbcontrol all close-share gadget_part1 @@ -507,14 +474,12 @@ sudo systemctl restart smbd nmbd **Tesla not recognizing new lock chime:** Try these steps in order: - 1. Power cycle Tesla (close doors, walk away 5+ minutes, wake up) 2. Switch USB modes (Edit → wait 10s → Present) 3. Physical reconnect (unplug Pi, wait 10s, plug back in) 4. Tesla reboot (hold both scroll wheels until screen goes black) **Operation in Progress banner stuck:** - ```bash # Check and remove stale lock file if older than 120 seconds ls -lh /home/pi/TeslaUSB/.quick_edit_part2.lock @@ -522,7 +487,6 @@ rm /home/pi/TeslaUSB/.quick_edit_part2.lock ``` **iOS file upload not working:** - - Use **Safari** on iOS (third-party browsers have restricted file access) - Desktop browsers work normally regardless of choice @@ -542,25 +506,21 @@ sudo dmesg | grep -i "mass_storage\|gadget" ## Technical Details **Dual-Drive Architecture:** - - Two separate disk images (`usb_cam.img` exFAT, `usb_lightshow.img` FAT32) - Sparse files (only use space as needed) - Presented as dual-LUN USB gadget to Tesla **USB Gadget Implementation:** - - Linux `g_mass_storage` kernel module via `libcomposite` - LUN 0: Read-write (ro=0) for TeslaCam recordings - LUN 1: Read-only (ro=1) for LightShow/Chimes **Concurrency Protection:** - - `.quick_edit_part2.lock` file prevents race conditions - 10-second timeout, 120-second stale lock detection - All services and scripts respect lock state **Performance Optimizations:** - - **Boot time**: ~14 seconds on Pi Zero 2 W (detects existing RW mount at boot to skip unnecessary remount operations) - **Configuration loading**: Single YAML parse with secure eval (properly quoted values prevent command injection) - **Web UI responsiveness**: Settings page loads in ~0.4s (optimized from 133s through batched configuration reads)