rpi-rgb-led-matrix icon indicating copy to clipboard operation
rpi-rgb-led-matrix copied to clipboard

Problem launching led-image-viewer via subprocess.Popen from root Python

Open flemingt opened this issue 6 months ago • 0 comments
trafficstars

I am trying to launch led-image-viewer from a Python script using subprocess.Popen, running the script as root, but the viewer subprocess exits with "Must run as root to be able to access /dev/mem".

I'm executing in the usual way: sudo /path/to/venv/python /path/to/script.py This is on a fresh rasbian lite install with python3 on a pi3b

Confirmation of Root: I have verified the Python script runs as root (EUID 0) using os.geteuid().

Confirmation of Subprocess Root: I have verified a simple subprocess (id -u) launched by the script also runs as root (EUID 0).

Confirmation of Viewer Functionality: Running the led-image-viewer directly from the terminal with sudo using a standard image file does work.

Temporary File Details: My script downloads the image, saves it to /tmp as a temporary file (which for some reason ends up being owned by daemon:daemon, though I've added os.chmod(0o644) to make it readable), and that sudo file /path/to/tempfile.jpg confirms it's a valid JPEG.

The Exact Error: Some output from my script:

Starting Sonos Album Art Display Script... Process nice value set to: -10 --- DEBUG: Script running with Effective User ID: 0 --- DEBUG: Testing privilege level of subprocess... --- DEBUG: Subprocess 'id -u' reported EUID: 0 Initializing LED matrix hardware object... Setting disable_hardware_pulsing = True (equivalent to --led-no-hardware-pulse) Setting pwm_lsb_nanoseconds = 130 LED matrix hardware object initialized successfully. Initial matrix clear complete. Attempting to discover Sonos speaker: 'Office'... Found Sonos speakers: ['Office', 'Living Room'] Successfully connected to Sonos speaker: 'Office' at IP: XXX.XXX.XXX.XXX Starting main loop to monitor Sonos playback...

Sonos playback state: PLAYING DEBUG: Full track_info dictionary: {'title': 'Feel Good Inc.', 'artist': 'Gorillaz, De La Soul', 'album': 'Demon Days', 'album_art': 'http://XXX.XXX.XXX.XXX:1400/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7', 'position': '0:00:06', 'playlist_position': '1', 'duration': '0:03:43', 'uri': 'x-sonosapi-hls-static:ALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2?sid=284&flags=8&sn=7', 'metadata': '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="-1" parentID="-1" restricted="true"><res protocolInfo="sonos.com-http::application/x-mpegURL:" duration="0:03:43">x-sonosapi-hls-static:ALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2?sid=284&flags=8&sn=7<r:streamContent></r:streamContent>upnp:albumArtURI/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7</upnp:albumArtURI>dc:titleFeel Good Inc.</dc:title>upnp:classobject.item.audioItem.musicTrack</upnp:class>dc:creatorGorillaz, De La Soul</dc:creator>upnp:albumDemon Days</upnp:album></DIDL-Lite>'} Currently playing: 'Feel Good Inc.' by 'Gorillaz, De La Soul' Album art URI obtained: 'http://XXX.XXX.XXX.XXX:1400/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7' Detected state change (new song/art) or viewer is not running. New song or new album art detected. Old URI: 'None', New URI: 'http://XXX.XXX.XXX.XXX:1400/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7' Processing album art URL string: 'http://XXX.XXX.XXX.XXX:1400/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7' Using safe URL after encoding path: http://XXX.XXX.XXX.XXX:1400/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7 --- DEBUG: Checking if executable exists at: '/home/XXXX/rpi-rgb-led-matrix/utils/led-image-viewer' Requesting image data from: http://XXX.XXX.XXX.XXX:1400/getaa?s=1&u=x-sonosapi-hls-static%3aALkSOiEQY4McqzFWuxa5vAq7TmIJB_pzAsWB-PTgyJ7m5Fe2%3fsid%3d284%26flags%3d8%26sn%3d7 --- DEBUG: Successfully downloaded image data. --- DEBUG: Downloaded image Content-Type: None --- DEBUG: Writing image data to temporary file: /tmp/tmpgmwrhvjk.jpg --- DEBUG: Set temporary file permissions to 0o644: /tmp/tmpgmwrhvjk.jpg --- DEBUG: Launching led-image-viewer with command: /home/XXXX/rpi-rgb-led-matrix/utils/led-image-viewer --led-rows=64 --led-cols=64 --led-chain=1 --led-parallel=1 --led-brightness=75 --led-pwm-bits=11 --led-gpio-mapping=adafruit-hat-pwm --led-scan-mode=1 --led-rgb-sequence=RGB --led-no-hardware-pulse /tmp/tmpgmwrhvjk.jpg --- DEBUG: led-image-viewer process started with PID: 1207 Album art viewer launched on LED matrix. --- DEBUG: Cleaning up temporary file: /tmp/tmpgmwrhvjk.jpg --- WARNING: led-image-viewer process (PID 1207) stopped unexpectedly (Exit Code: 1). Clearing. --- VIEWER STDERR: Must run as root to be able to access /dev/mem Prepend 'sudo' to the command

Clearing matrix display. Stopping existing image viewer process.

Python Code Snippets: Here are some code snippets. Import and configuration:

import subprocess
import tempfile
import os
import urllib.parse # For URL processing
import requests # For downloading
# Other imports like soco, rgbmatrix

# ... (Other Configuration like TARGET_SONOS_SPEAKER_NAME) ...

# LED Matrix Configuration (These become command line args for viewer)
MATRIX_ROWS = 64
MATRIX_COLS = 64
MATRIX_CHAIN = 1
MATRIX_PARALLEL = 1
MATRIX_BRIGHTNESS = 75
MATRIX_PWM_BITS = 11
MATRIX_GPIO_MAPPING = "adafruit-hat-pwm"
MATRIX_SCAN_MODE = 1
MATRIX_LED_RGB_SEQUENCE = "RGB"

# --- LED Viewer Configuration ---
LED_VIEWER_PATH = "/home/XXXX/rpi-rgb-led-matrix/utils/led-image-viewer" # <--- Your actual path
# --- End Configuration ---

# Global variables
# ... (sonos_device, track_uri, etc.) ...
viewer_process = None # Global variable to hold the viewer process handle

EUID checks:

# --- Check effective user ID ---
try:
    euid = os.geteuid()
    print(f"--- DEBUG: Script running with Effective User ID: {euid}")
    if euid != 0:
        print("--- WARNING: Script is NOT running as root (EUID is not 0).")
except Exception as e:
    print(f"--- ERROR: Could not determine Effective User ID: {e}")
# --- End check ---

# --- Check subprocess effective user ID ---
print("--- DEBUG: Testing privilege level of subprocess...")
try:
    result = subprocess.run(['id', '-u'], capture_output=True, text=True, check=True)
    subprocess_euid = result.stdout.strip()
    print(f"--- DEBUG: Subprocess 'id -u' reported EUID: {subprocess_euid}")
    if subprocess_euid != '0':
        print("--- WARNING: Subprocess is NOT reporting root privileges (EUID is not 0).")
except FileNotFoundError:
    print("--- ERROR: 'id' command not found. Cannot test subprocess EUID.")
except subprocess.CalledProcessError as e:
    print(f"--- ERROR: Subprocess 'id -u' failed with exit code {e.returncode}. Stderr:\n{e.stderr}")
except Exception as e:
    print(f"--- ERROR: An unexpected error occurred during subprocess EUID test: {e}")
# --- End subprocess check ---

Clearing the matrix

def clear_matrix():
    """Clears the LED matrix by stopping the image viewer process and clearing the Python object."""
    global viewer_process, matrix
    print("Clearing matrix display.")
    # Stop the external image viewer process
    if viewer_process is not None:
        print("Stopping existing image viewer process.")
        try:
            viewer_process.terminate() # Use terminate() first
            # Give it a moment to terminate, then kill if necessary
            viewer_process.wait(timeout=2)
        except subprocess.TimeoutExpired:
            print("Old viewer process did not terminate gracefully, killing.")
            viewer_process.kill()
        except Exception as e:
            print(f"Error stopping viewer process: {e}")
        viewer_process = None # Clear the process handle

    # Also clear the matrix using the Python object (optional, depends on setup)
    if matrix:
        try:
            matrix.Clear() # Or matrix.SetImage(black_image)
        except Exception as e:
            print(f"Error clearing matrix with Python object: {e}")

Displaying the album art

def display_album_art(image_url):
    """Downloads album art, saves to temp file with permissions, and launches led-image-viewer."""
    global viewer_process

    if not image_url:
        print("No image URL provided. Clearing matrix.")
        clear_matrix()
        return

    # Ensure the viewer executable exists (Optional but good practice)
    if not os.path.exists(LED_VIEWER_PATH):
        print(f"--- ERROR: LED viewer executable not found at '{LED_VIEWER_PATH}'. Cannot display image.")
        # clear_matrix() # Decide error handling
        return

    # Construct the base command line arguments for the viewer
    viewer_command = [
        LED_VIEWER_PATH,
        f"--led-rows={MATRIX_ROWS}",
        f"--led-cols={MATRIX_COLS}",
        f"--led-chain={MATRIX_CHAIN}",
        f"--led-parallel={MATRIX_PARALLEL}",
        f"--led-brightness={MATRIX_BRIGHTNESS}",
        f"--led-pwm-bits={MATRIX_PWM_BITS}",
        f"--led-gpio-mapping={MATRIX_GPIO_MAPPING}",
        f"--led-scan-mode={MATRIX_SCAN_MODE}",
        f"--led-rgb-sequence={MATRIX_LED_RGB_SEQUENCE}",
        "--led-no-hardware-pulse", # Example flag, include your necessary flags
        # Add other flags like --led-slowdown-gpio=1 etc.
    ]

    temp_file_path = None # Initialize temporary file path

    try:
        # Assume image_url is already processed by get_sonos_album_art_url
        safe_image_url = image_url

        print(f"Requesting image data from: {safe_image_url}")
        response = requests.get(safe_image_url, stream=True, timeout=10)
        response.raise_for_status()
        print("--- DEBUG: Successfully downloaded image data.")
        print(f"--- DEBUG: Downloaded image Content-Type: {response.headers.get('Content-Type')}") # Shows 'None'

        # Create a temporary file with a .jpg suffix
        temp_file = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
        temp_file_path = temp_file.name
        print(f"--- DEBUG: Writing image data to temporary file: {temp_file_path}")
        temp_file.write(response.content)
        temp_file.close() # Close the file handle

        # *** Crucial: Set permissions so viewer (even if somehow dropping privileges or owned by daemon) can read ***
        os.chmod(temp_file_path, 0o644) # Set permissions to -rw-r--r--
        print(f"--- DEBUG: Set temporary file permissions to 0o644: {temp_file_path}")
        # Include the ls -l output you got showing it was daemon:daemon owned with -rw------- before chmod

        # --- STOP any existing viewer process ---
        if viewer_process is not None:
            print("Stopping existing image viewer process before starting new one.")
            try:
                viewer_process.terminate()
                viewer_process.wait(timeout=2)
            except subprocess.TimeoutExpired:
                print("Old viewer process did not terminate gracefully, killing.")
                viewer_process.kill()
            except Exception as e:
                print(f"--- ERROR: Error stopping old viewer process: {e}")
            viewer_process = None # Clear the handle

        # Add the temporary file path to the command
        final_command = viewer_command + [temp_file_path]

        # --- LAUNCH the led-image-viewer process ---
        print(f"--- DEBUG: Launching led-image-viewer with command: {' '.join(final_command)}")
        # Use Popen to run it in the background, inherit environment
        viewer_process = subprocess.Popen(final_command,
                                           stdout=subprocess.PIPE, # Capture stdout
                                           stderr=subprocess.PIPE, # Capture stderr
                                           env=os.environ.copy()) # Explicitly pass environment
        print(f"--- DEBUG: led-image-viewer process started with PID: {viewer_process.pid}")
        print("Album art viewer launched on LED matrix.")

    except requests.exceptions.RequestException as e:
        print(f"--- ERROR: Error downloading image: {e}")
        # Error handling...
        pass
    except FileNotFoundError:
         print(f"--- ERROR: LED viewer executable not found at '{LED_VIEWER_PATH}'. Check the path.")
         # Error handling...
         pass
    except Exception as e:
        print(f"--- ERROR: An unexpected error occurred in display_album_art (download/launch): {e}")
        # Error handling...
        pass
    finally:
        # --- CLEANUP TEMPORARY FILE ---
        # Delete the temporary file after attempting to launch the viewer
        if temp_file_path and os.path.exists(temp_file_path):
             try:
                 print(f"--- DEBUG: Cleaning up temporary file: {temp_file_path}")
                 os.remove(temp_file_path) # Re-enable os.remove
             except Exception as e:
                 print(f"--- ERROR: Failed to clean up temporary file {temp_file_path}: {e}")
        # --- END CLEANUP ---

I've this in the main loop to show error capture

# Inside the while True loop in main_loop:

# Optional: Check if the viewer process is still running periodically
if viewer_process is not None and viewer_process.poll() is not None:
    print(f"--- WARNING: led-image-viewer process (PID {viewer_process.pid}) stopped unexpectedly (Exit Code: {viewer_process.returncode}). Clearing.")
    # Read any stderr output for debugging crashed viewer
    try:
        stderr_output = viewer_process.stderr.read().decode('utf-8', errors='ignore')
        if stderr_output:
             print(f"--- VIEWER STDERR:\n{stderr_output}")
    except Exception as e:
         print(f"--- ERROR: Failed to read viewer stderr: {e}")

    # Clear state to force display_album_art call on next playing state
    clear_matrix() # This also sets viewer_process = None
    # Reset state variables if needed
    # current_track_album_art_uri = None
    # last_known_track_title = None

# ... (rest of main_loop logic checking Sonos state and calling display_album_art) ...

This is puzzling because both the parent script and other subprocesses appear to have root, but this specific executable fails its root check in this context. Am I missing something blindingly obvious?

flemingt avatar May 16 '25 19:05 flemingt