mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
- Add MapLibre GL JS viewer with light/dark themes, terrain layer, and embedded static map page for coordinate sharing - Add map pin support with persistent map_pins.yaml config - Add map region subscriptions and downloader with progress tracking, sourcing pre-extracted PMTiles from a cold CDN - Add map export to PNG and GPS coordinate copy via context menu - Add MapViewer React component alongside reworked Map page - Add map catalog and region-building script (build_map_regions.py) - Remove OSM import/render scripts, leaflet files, Apache configs, renderd service, and osm-map-dumper Docker container - Update Caddyfile to serve PMTiles and map static assets - Update build/install/repair scripts for Protomaps dependencies - Drop map_file table, add/drop map_subscription table (alembic) - Adding button to skip primary drive mounting during onboarding.
298 lines
10 KiB
Python
298 lines
10 KiB
Python
"""Paths for third-party commands that WROLPi may require."""
|
|
import asyncio
|
|
import dataclasses
|
|
import os
|
|
import pathlib
|
|
import shlex
|
|
import shutil
|
|
import tempfile
|
|
from asyncio import CancelledError
|
|
from pathlib import Path
|
|
from time import time
|
|
from typing import Optional
|
|
|
|
from wrolpi.common import logger, TRACE_LEVEL
|
|
from wrolpi.vars import PYTEST, DOCKERIZED
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
def is_executable(path: Path) -> bool:
|
|
"""Returns True if the file is executable."""
|
|
return path.is_file() and os.access(path, os.X_OK)
|
|
|
|
|
|
def which(*possible_paths: str, warn: bool = False) -> Optional[Path]:
|
|
"""
|
|
Find an executable in the system $PATH. If the executable cannot be found in $PATH, then return the first
|
|
executable found in possible_paths.
|
|
|
|
Returns None if no executable can be found.
|
|
"""
|
|
found = None
|
|
possible_paths = list(map(Path, possible_paths))
|
|
for path in possible_paths:
|
|
if is_executable(path):
|
|
# `path` was an absolute file which is executable.
|
|
found = path
|
|
break
|
|
path = shutil.which(path)
|
|
if path and (path := Path(path).absolute()) and is_executable(path):
|
|
# `path` was a name or relative path.
|
|
found = path
|
|
|
|
if not found and warn and not PYTEST and not DOCKERIZED:
|
|
logger.warning(f'Cannot find executable {possible_paths[0]}')
|
|
return
|
|
elif found:
|
|
|
|
return found.absolute()
|
|
|
|
|
|
def pid_is_running(pid: int) -> bool:
|
|
"""Return True if a process with PID exists."""
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
# Admin
|
|
SUDO_BIN = which('sudo', '/usr/bin/sudo')
|
|
NMCLI_BIN = which('nmcli', '/usr/bin/nmcli')
|
|
CPUFREQ_INFO_BIN = which('cpufreq-info', '/usr/bin/cpufreq-info')
|
|
CPUFREQ_SET_BIN = which('cpufreq-set', '/usr/bin/cpufreq-set')
|
|
|
|
# Files
|
|
WGET_BIN = which('wget', '/usr/bin/wget')
|
|
|
|
# Map
|
|
BASH_BIN = which('bash', '/bin/bash')
|
|
|
|
# Archive
|
|
SINGLE_FILE_BIN = which('single-file',
|
|
'/usr/bin/single-file', # rpi os
|
|
'/usr/local/bin/single-file', # debian
|
|
)
|
|
CHROMIUM = which('chromium-browser', 'chromium',
|
|
'/usr/bin/chromium-browser', # rpi os
|
|
'/usr/bin/chromium', # debian
|
|
)
|
|
FIREFOX = which('firefox', 'firefox-esr',
|
|
'/usr/bin/firefox',
|
|
'/usr/bin/firefox-esr',
|
|
)
|
|
READABILITY_BIN = which('readability-extractor',
|
|
'/usr/bin/readability-extractor', # rpi os
|
|
'/usr/local/bin/readability-extractor', # debian
|
|
)
|
|
|
|
# Known browser definitions for SingleFile
|
|
KNOWN_BROWSERS = {
|
|
'chromium': {
|
|
'paths': ['chromium-browser', 'chromium', '/usr/bin/chromium-browser', '/usr/bin/chromium'],
|
|
'name': 'Chromium',
|
|
},
|
|
'firefox': {
|
|
'paths': ['firefox', 'firefox-esr', '/usr/bin/firefox', '/usr/bin/firefox-esr'],
|
|
'name': 'Firefox',
|
|
},
|
|
'brave': {
|
|
'paths': ['brave-browser', '/usr/bin/brave-browser'],
|
|
'name': 'Brave',
|
|
}
|
|
}
|
|
|
|
|
|
def get_installed_browsers() -> list:
|
|
"""
|
|
Detect browsers installed on the system that can be used with SingleFile.
|
|
|
|
Returns a list of dicts with:
|
|
- key: The browser identifier (e.g., 'chromium', 'firefox')
|
|
- name: Human-readable name (e.g., 'Chromium', 'Firefox')
|
|
- path: Absolute path to the executable
|
|
"""
|
|
browsers = []
|
|
|
|
for key, info in KNOWN_BROWSERS.items():
|
|
path = which(*info['paths'])
|
|
if path:
|
|
browsers.append({
|
|
'key': key,
|
|
'name': info['name'],
|
|
'path': str(path),
|
|
})
|
|
|
|
return browsers
|
|
|
|
|
|
# Videos
|
|
YT_DLP_BIN = which(
|
|
'/opt/wrolpi/venv/bin/yt-dlp', # Use virtual environment location
|
|
'yt-dlp',
|
|
'/usr/local/bin/yt-dlp', # Location in docker container
|
|
)
|
|
FFPROBE_BIN = which('ffprobe', '/usr/bin/ffprobe')
|
|
FFMPEG_BIN = which('ffmpeg', '/usr/bin/ffmpeg')
|
|
|
|
# Documents
|
|
CATDOC_PATH = which('catdoc')
|
|
TEXTUTIL_PATH = which('textutil')
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class CommandResult:
|
|
return_code: int
|
|
cancelled: bool
|
|
stdout: bytes
|
|
stderr: bytes
|
|
elapsed: int
|
|
pid: int = None
|
|
|
|
|
|
TESTING_RUN_COMMAND_RESULT = None
|
|
|
|
|
|
async def run_command(cmd: tuple[str | pathlib.Path, ...], cwd: pathlib.Path | str = None,
|
|
timeout: int = 600, log_command: bool = True,
|
|
stdout_callback: callable = None, env: dict = None) -> CommandResult:
|
|
"""Run a shell command, return the results (stdout/stderr/return code).
|
|
|
|
When stdout_callback is provided, stdout is piped and each line is passed to the callback
|
|
in real-time. Otherwise, stdout is written to a temporary file.
|
|
|
|
:param log_command: Enable debug logging.
|
|
:param stdout_callback: Called with each decoded stdout line (str). May be None.
|
|
:param env: Environment variables for the subprocess. If None, inherits the current environment.
|
|
"""
|
|
if not isinstance(cmd, (list, tuple)):
|
|
raise RuntimeError('Command must be a list or tuple')
|
|
|
|
if PYTEST and TESTING_RUN_COMMAND_RESULT:
|
|
logger.debug('run_command: returning mock result')
|
|
return TESTING_RUN_COMMAND_RESULT() # call the mock
|
|
|
|
streaming = stdout_callback is not None
|
|
|
|
with tempfile.NamedTemporaryFile() as stderr_fh:
|
|
stderr_file = pathlib.Path(stderr_fh.name)
|
|
cmd = tuple(str(i) for i in cmd)
|
|
cmd_str = ' '.join(shlex.quote(i) for i in cmd)
|
|
if log_command:
|
|
logger.info(f'run_command: running ({timeout=}): {cmd_str}')
|
|
elif __debug__ and logger.isEnabledFor(TRACE_LEVEL):
|
|
logger.trace(f'run_command: running ({timeout=}): {cmd_str}')
|
|
|
|
stdout_fh = None
|
|
stdout_file = None
|
|
reader_task = None
|
|
stdout_buf = None
|
|
|
|
if streaming:
|
|
stdout_arg = asyncio.subprocess.PIPE
|
|
stdout_buf = bytearray()
|
|
else:
|
|
stdout_fh = tempfile.NamedTemporaryFile()
|
|
stdout_file = pathlib.Path(stdout_fh.name)
|
|
stdout_arg = stdout_fh
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=stdout_arg,
|
|
stderr=stderr_fh,
|
|
cwd=cwd,
|
|
env=env,
|
|
)
|
|
pid = proc.pid
|
|
|
|
if streaming:
|
|
async def _read_stdout():
|
|
buf = ''
|
|
while True:
|
|
chunk = await proc.stdout.read(4096)
|
|
if not chunk:
|
|
# Flush remaining buffer.
|
|
if buf.strip():
|
|
try:
|
|
stdout_callback(buf.strip())
|
|
except Exception as e:
|
|
logger.warning(f'run_command: stdout_callback error: {e}')
|
|
break
|
|
stdout_buf.extend(chunk)
|
|
buf += chunk.decode(errors='replace')
|
|
# Split on both \n and \r to handle progress bars.
|
|
while '\n' in buf or '\r' in buf:
|
|
# Find the earliest delimiter.
|
|
ni = buf.find('\n')
|
|
ri = buf.find('\r')
|
|
if ni == -1:
|
|
idx, skip = ri, 1
|
|
elif ri == -1:
|
|
idx, skip = ni, 1
|
|
else:
|
|
idx, skip = min(ni, ri), 1
|
|
line = buf[:idx]
|
|
buf = buf[idx + skip:]
|
|
if line.strip():
|
|
try:
|
|
stdout_callback(line.strip())
|
|
except Exception as e:
|
|
logger.warning(f'run_command: stdout_callback error: {e}')
|
|
|
|
reader_task = asyncio.create_task(_read_stdout())
|
|
|
|
cancelled = False
|
|
start = time()
|
|
try:
|
|
while True:
|
|
elapsed = int(time() - start)
|
|
if timeout and elapsed >= timeout:
|
|
logger.warning(f'run_command: timeout exceeded, killing... {cmd=}')
|
|
proc.kill()
|
|
await proc.wait()
|
|
break
|
|
|
|
try:
|
|
await asyncio.wait_for(proc.wait(), timeout=1)
|
|
break
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
except CancelledError as e:
|
|
logger.warning(f'run_command: cancelled, killing... {cmd=}', exc_info=e)
|
|
cancelled = True
|
|
proc.kill()
|
|
await proc.wait()
|
|
|
|
if reader_task:
|
|
reader_task.cancel()
|
|
try:
|
|
await reader_task
|
|
except (CancelledError, asyncio.CancelledError):
|
|
pass
|
|
|
|
elapsed = int(time() - start)
|
|
if streaming:
|
|
stdout = bytes(stdout_buf)
|
|
else:
|
|
stdout = stdout_file.read_bytes() or b''
|
|
stderr = stderr_file.read_bytes() or b''
|
|
if log_command:
|
|
logger.debug(
|
|
f'run_command: finished ({elapsed=}s) with stdout={len(stdout)} stderr={len(stderr)}: {cmd_str=}')
|
|
elif __debug__ and logger.isEnabledFor(TRACE_LEVEL):
|
|
logger.trace(
|
|
f'run_command: finished ({elapsed=}s) with stdout={len(stdout)} stderr={len(stderr)}: {cmd_str=}')
|
|
return CommandResult(
|
|
return_code=proc.returncode,
|
|
cancelled=cancelled,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
elapsed=elapsed,
|
|
pid=pid,
|
|
)
|
|
finally:
|
|
if stdout_fh:
|
|
stdout_fh.close()
|