diff --git a/.dockerignore b/.dockerignore index 17f4f56d..2fe5b097 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,21 @@ .git +.gitignore +.gitmodules +.dockerignore +Dockerfile .env +.env.example +.vscode wxenv logs/ cache/ __pycache__/ *.pyc +audio_generator/ +scripts/ +README.md +LICENSE +setup.sh +run-docker.sh +sr0wx.wav +requirements-rpi.txt diff --git a/.gitignore b/.gitignore index 323fdf65..0a2bd303 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ audio_generator/mp3 sr0wx.wav wxenv -logs/*.log +logs/* +!logs/.keep diff --git a/Dockerfile b/Dockerfile index 7eed68b5..f2900ea1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,40 @@ # Dockerfile for SR0WX FROM debian:12 -RUN apt-get update && apt-get upgrade -RUN apt-get install -y python3 python3-pip python3-venv pulseaudio git +# UID/GID of the host user, so that the mounted logs directory and the +# PulseAudio socket stay writable from inside the container +ARG UID=1000 +ARG GID=1000 -RUN useradd --create-home --shell /bin/bash -G audio sr0wx +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv pulseaudio \ + && rm -rf /var/lib/apt/lists/* -USER sr0wx - -WORKDIR /home/sr0wx - -RUN mkdir ./sr0wx -RUN mkdir ./sr0wx/logs +RUN groupadd -g ${GID} sr0wx \ + && useradd --create-home --shell /bin/bash -u ${UID} -g ${GID} -G audio,dialout sr0wx +USER sr0wx WORKDIR /home/sr0wx/sr0wx -COPY requirements.txt ./ -COPY sr0wx.py config.py .env colorcodes.py sr0wx_module.py ./ -COPY modules/ ./modules/ -COPY pl_google/ ./pl_google/ -COPY pyliczba/ ./pyliczba/ -COPY .git/ ./.git/ +RUN mkdir -p logs cache + +# requirements first, so that a code change does not invalidate the +# (slow) dependency layer +COPY --chown=sr0wx:sr0wx requirements.txt ./ +RUN python3 -m venv wxenv \ + && ./wxenv/bin/pip install --no-cache-dir --upgrade pip \ + && ./wxenv/bin/pip install --no-cache-dir -r requirements.txt -RUN python3 -m venv wxenv +COPY --chown=sr0wx:sr0wx sr0wx.py config.py colorcodes.py sr0wx_module.py ./ +COPY --chown=sr0wx:sr0wx modules/ ./modules/ +COPY --chown=sr0wx:sr0wx pl_google/ ./pl_google/ +COPY --chown=sr0wx:sr0wx pyliczba/ ./pyliczba/ -RUN . ./wxenv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt +# .env is deliberately not copied -- image layers are readable by anyone who +# can pull the image. Pass the secrets at runtime with `docker run --env-file`. +# .git is not copied either, so the update check in sr0wx.py will log a single +# error and carry on; set check_for_updates = False in config.py to silence it. -CMD ["./wxenv/bin/python3", "sr0wx.py"] +ENTRYPOINT ["./wxenv/bin/python3", "sr0wx.py"] +CMD [] diff --git a/config.py b/config.py index b4d46863..447f3174 100755 --- a/config.py +++ b/config.py @@ -8,7 +8,6 @@ import os import logging import logging.handlers -from datetime import datetime from colorcodes import * @@ -21,6 +20,9 @@ def my_import(name): return mod +# katalog na logi, na świeżym klonie repo go nie ma +os.makedirs("logs", exist_ok=True) + # logger dict_log_config = { "version": 1, @@ -49,35 +51,45 @@ def my_import(name): "formatter": "colored_console", "stream": "ext://sys.stdout", }, + # stała nazwa pliku, rotacja o północy, 30 dni historii. Znacznik + # czasu w nazwie dawał nowy plik przy każdym uruchomieniu, więc + # rotacja nigdy się nie odpalała i pliki narastały bez końca. "file": { "level": "INFO", - "class": "logging.handlers.RotatingFileHandler", + "class": "logging.handlers.TimedRotatingFileHandler", "formatter": "format_for_file", - "filename": "logs/" - + str(datetime.now().strftime("%Y-%m-%d_%H:%M")) - + ".log", - "maxBytes": 500000, + "filename": "logs/sr0wx.log", + "when": "midnight", "backupCount": 30, + "encoding": "utf-8", }, }, } -# dane z pliku .env +# dane z pliku .env, a przy uruchomieniu w kontenerze prosto ze środowiska +# (docker run --env-file), dlatego brak pliku nie jest błędem if os.path.exists(".env"): load_dotenv() - airly_key = os.getenv("AIRLY_KEY") - openweather_key = os.getenv("OPENWEATHER_KEY") - meteostation_ip = os.getenv("METEOSTATION_IP").split(",") - LATITUDE = float(os.getenv("LATITUDE")) - LONGITUDE = float(os.getenv("LONGITUDE")) - MAP_CALL = os.getenv("MAP_CALL") - MAP_INFO = os.getenv("MAP_INFO") - HEALTHCHECK_UUID = os.getenv("HEALTHCHECK_UUID") - APRS_CALL = os.getenv("APRS_CALL") - APRS_PASSWD = os.getenv("APRS_PASSWD") - APRS_COMMENT = os.getenv("APRS_COMMENT") -else: - raise FileNotFoundError("No .env file present.") + +missing = [k for k in ("LATITUDE", "LONGITUDE") if not os.getenv(k)] +if missing: + raise RuntimeError( + "Brak wymaganej konfiguracji: " + + ", ".join(missing) + + ". Skopiuj .env.example do .env i uzupełnij, albo podaj wartości w zmiennych środowiskowych." + ) + +airly_key = os.getenv("AIRLY_KEY") +openweather_key = os.getenv("OPENWEATHER_KEY") +meteostation_ip = os.getenv("METEOSTATION_IP", "").split(",") +LATITUDE = float(os.getenv("LATITUDE")) +LONGITUDE = float(os.getenv("LONGITUDE")) +MAP_CALL = os.getenv("MAP_CALL") +MAP_INFO = os.getenv("MAP_INFO") +HEALTHCHECK_UUID = os.getenv("HEALTHCHECK_UUID") +APRS_CALL = os.getenv("APRS_CALL") +APRS_PASSWD = os.getenv("APRS_PASSWD") +APRS_COMMENT = os.getenv("APRS_COMMENT") ##################### diff --git a/config_baltyk.py b/config_baltyk.py index e98ba223..c205e3d1 100755 --- a/config_baltyk.py +++ b/config_baltyk.py @@ -8,7 +8,6 @@ import os import logging import logging.handlers -from datetime import datetime from colorcodes import * @@ -21,6 +20,10 @@ def my_import(name): return mod +# katalog na logi, na świeżym klonie repo go nie ma +os.makedirs("logs", exist_ok=True) + + # logger dict_log_config = { "version": 1, @@ -49,29 +52,38 @@ def my_import(name): "formatter": "colored_console", "stream": "ext://sys.stdout", }, + # stała nazwa pliku, rotacja o północy, 30 dni historii. Znacznik + # czasu w nazwie dawał nowy plik przy każdym uruchomieniu, więc + # rotacja nigdy się nie odpalała i pliki narastały bez końca. "file": { "level": "INFO", - "class": "logging.handlers.RotatingFileHandler", + "class": "logging.handlers.TimedRotatingFileHandler", "formatter": "format_for_file", - "filename": "logs/baltyk_" - + str(datetime.now().strftime("%Y-%m-%d_%H:%M")) - + ".log", - "maxBytes": 500000, + "filename": "logs/baltyk.log", + "when": "midnight", "backupCount": 30, + "encoding": "utf-8", }, }, } -# dane z pliku .env +# dane z pliku .env, a przy uruchomieniu w kontenerze prosto ze środowiska +# (docker run --env-file), dlatego brak pliku nie jest błędem if os.path.exists(".env"): load_dotenv() - LATITUDE = float(os.getenv("LATITUDE")) - LONGITUDE = float(os.getenv("LONGITUDE")) - MAP_INFO_BALTYK=os.getenv("MAP_INFO_BALTYK") - CALL = os.getenv("CALL") - -else: - raise FileNotFoundError("No .env file present.") + +missing = [k for k in ("LATITUDE", "LONGITUDE") if not os.getenv(k)] +if missing: + raise RuntimeError( + "Brak wymaganej konfiguracji: " + + ", ".join(missing) + + ". Skopiuj .env.example do .env i uzupełnij, albo podaj wartości w zmiennych środowiskowych." + ) + +LATITUDE = float(os.getenv("LATITUDE")) +LONGITUDE = float(os.getenv("LONGITUDE")) +MAP_INFO_BALTYK = os.getenv("MAP_INFO_BALTYK") +CALL = os.getenv("CALL") ##################### diff --git a/pl_google/samples/I.ogg b/pl_google/samples/I.ogg index 40cdc45d..197a41fe 100644 Binary files a/pl_google/samples/I.ogg and b/pl_google/samples/I.ogg differ diff --git a/pl_google/samples/W.ogg b/pl_google/samples/W.ogg index be98eb4b..4b3a1dd8 100644 Binary files a/pl_google/samples/W.ogg and b/pl_google/samples/W.ogg differ diff --git a/pl_google/samples/Z.ogg b/pl_google/samples/Z.ogg index cb09d5d3..ee055527 100644 Binary files a/pl_google/samples/Z.ogg and b/pl_google/samples/Z.ogg differ diff --git a/run-docker.sh b/run-docker.sh index 6aa8a8eb..a1f1fc6a 100755 --- a/run-docker.sh +++ b/run-docker.sh @@ -1,6 +1,64 @@ -docker build -t sr0wx:latest . -docker run -it \ - -e PULSE_SERVER=unix:${XDG_RUNTIME_DIR}/pulse/native \ - -v ${XDG_RUNTIME_DIR}/pulse/native:${XDG_RUNTIME_DIR}/pulse/native \ - -v ~/.config/pulse/cookie:/root/.config/pulse/cookie \ - sr0wx:latest \ No newline at end of file +#!/bin/bash -e + +# Build and run SR0WX in a container. +# Any arguments given here are passed straight to sr0wx.py, e.g.: +# ./run-docker.sh -t -m sun_rise_set + +cd "$(dirname "$(realpath "$0")")" + +if [ ! -f .env ]; then + echo "No .env file. Copy .env.example to .env and fill it in first." >&2 + exit 1 +fi + +# pl_google needs the pyliczba submodule, an empty directory breaks the import +git submodule update --init --recursive + +docker build \ + --build-arg UID="$(id -u)" \ + --build-arg GID="$(id -g)" \ + -t sr0wx:latest . + +mkdir -p logs + +ARGS=( + --rm + --env-file .env + -v "$(pwd)/logs:/home/sr0wx/sr0wx/logs" +) + +# interactive only when there is a terminal, so cron/systemd runs work too +if [ -t 0 ]; then + ARGS+=(-it) +fi + +# audio via the host PulseAudio daemon +if [ -n "${XDG_RUNTIME_DIR}" ] && [ -S "${XDG_RUNTIME_DIR}/pulse/native" ]; then + ARGS+=( + -e "PULSE_SERVER=unix:${XDG_RUNTIME_DIR}/pulse/native" + -v "${XDG_RUNTIME_DIR}/pulse/native:${XDG_RUNTIME_DIR}/pulse/native" + ) +else + echo "Warning: no PulseAudio socket found, there will be no audio output." >&2 +fi + +# the cookie lives in the container user's home, not in /root +if [ -f "${HOME}/.config/pulse/cookie" ]; then + ARGS+=(-v "${HOME}/.config/pulse/cookie:/home/sr0wx/.config/pulse/cookie:ro") +fi + +# PTT via Raspberry Pi GPIO +if [ -e /dev/gpiomem ]; then + ARGS+=(--device /dev/gpiomem) +fi + +# PTT via serial, set SR0WX_SERIAL to the device path (e.g. /dev/ttyUSB0) +if [ -n "${SR0WX_SERIAL}" ]; then + if [ -e "${SR0WX_SERIAL}" ]; then + ARGS+=(--device "${SR0WX_SERIAL}") + else + echo "Warning: SR0WX_SERIAL=${SR0WX_SERIAL} does not exist, skipping." >&2 + fi +fi + +docker run "${ARGS[@]}" sr0wx:latest "$@" diff --git a/setup.sh b/setup.sh index 7ef42995..34cd5849 100755 --- a/setup.sh +++ b/setup.sh @@ -1,5 +1,15 @@ #!/bin/bash -e +# Safe to re-run, e.g. after changing requirements.txt. +# Pass -r to install the Raspberry Pi requirements instead. + +cd "$(dirname "$(realpath "$0")")" + +REQUIREMENTS="requirements.txt" +if [ "$1" = "-r" ]; then + REQUIREMENTS="requirements-rpi.txt" +fi + # Init git submodules (pyliczba) git submodule update --init --recursive @@ -9,7 +19,13 @@ source wxenv/bin/activate # Update and install requirements pip install --upgrade pip -pip install -r requirements.txt +pip install -r "${REQUIREMENTS}" -# Initiate .env file -cp .env.example .env +# Initiate .env file, never overwrite the one already there -- it holds the +# API keys and the APRS passcode +if [ -f .env ]; then + echo ".env already exists, leaving it alone." +else + cp .env.example .env + echo "Created .env from .env.example, fill it in before running sr0wx.py." +fi diff --git a/sr0wx.py b/sr0wx.py index 4d1750f3..dd0af518 100755 --- a/sr0wx.py +++ b/sr0wx.py @@ -97,6 +97,10 @@ # socket for checking for other program instances import socket +# atexit and signal for releasing PTT no matter how the script ends +import atexit +import signal + # colorcodes from colorcodes import * @@ -579,6 +583,52 @@ def run_module(args): f"Failed to open serial port {config.serial_port}@{config.serial_baud_rate}: {e}" ) +# PTT must be released even if the script dies in the middle of playback. +# RPi.GPIO does not reset pins on interpreter exit, so a crash between +# switching PTT on and off would leave the transmitter keyed indefinitely. + +ptt_released = False + + +def ptt_off(): + """Release PTT (serial and GPIO). Safe to call more than once.""" + global ptt_released + if ptt_released: + return + ptt_released = True + + if config.serial_port is not None: + try: + ser.close() + logger.info(COLOR_OKGREEN + "Serial PTT: OFF" + COLOR_ENDC) + except NameError: + # sudo gpasswd --add ${USER} dialout + logger.exception("Couldn't close serial port") + except Exception as e: + logger.error(f"Unable to close serial port, got error: {e}") + + if not nopi: + try: + GPIO.output(config.rpi_pin, GPIO.LOW) + logger.info(COLOR_OKGREEN + f"GPIO PTT: OFF, PIN: {config.rpi_pin}" + COLOR_ENDC) + GPIO.cleanup() + except Exception as e: + logger.error(f"Unable to turn GPIO ptt off, got error: {e}") + + +def ptt_signal_handler(signum, frame): + logger.warning(f"Got signal {signum}, releasing PTT and exiting...") + ptt_off() + sys.exit(1) + + +# atexit covers every exit() call, signal handlers cover SIGTERM from +# cron/systemd, which atexit alone would not see +atexit.register(ptt_off) +for sig_name in ("SIGTERM", "SIGINT", "SIGHUP"): + sig = getattr(signal, sig_name, None) + if sig is not None: + signal.signal(sig, ptt_signal_handler) pygame.time.delay(config.marginDelay) @@ -595,54 +645,47 @@ def run_module(args): logger.info("playing sound samples...\n") -for el in message: - if config.showSamples or showSamplesOverwrite: - print(el, end=" ", flush=True) - if el == "_": - pygame.time.wait(config.delayValue) - else: - if "upper" in dir(el): - try: +try: + for el in message: + if config.showSamples or showSamplesOverwrite: + print(el, end=" ", flush=True) + if el == "_": + pygame.time.wait(config.delayValue) + continue + + # reset on every iteration, otherwise a failed playback would fall + # through to the channel of the previous sample (or to no channel + # at all on the very first one) + voice_channel = None + try: + if "upper" in dir(el): voice_channel = sound_samples[el].play() - except: - a = 1 - - elif "upper" not in dir(el): - sound = pygame.sndarray.make_sound(el) - if config.pygame_bug == 1: - sound = pygame.sndarray.make_sound( - pygame.sndarray.array(sound)[ - : int(len(pygame.sndarray.array(sound)) / 2) - ] - ) - voice_channel = sound.play() - while voice_channel.get_busy(): - pygame.time.Clock().tick(config.clockTick) + else: + sound = pygame.sndarray.make_sound(el) + if config.pygame_bug == 1: + arr = pygame.sndarray.array(sound) + sound = pygame.sndarray.make_sound(arr[: len(arr) // 2]) + voice_channel = sound.play() + except (KeyError, pygame.error) as e: + logger.error(COLOR_FAIL + f"Couldn't play sample '{el}': {e}" + COLOR_ENDC) + + if voice_channel is not None: + while voice_channel.get_busy(): + pygame.time.Clock().tick(config.clockTick) pygame.time.delay(config.timeDelay) -# The following four lines give us a one second break (for CTCSS, PTT and -# other stuff) before closing the ``pygame`` mixer and display some debug -# informations. +finally: + # The following lines give us a one second break (for CTCSS, PTT and + # other stuff) before closing the ``pygame`` mixer and display some debug + # informations. -time_playing = time.time() + time_playing = time.time() -pygame.time.delay(config.marginDelay) + pygame.time.delay(config.marginDelay) -logger.info(COLOR_WARNING + "finishing...\n" + COLOR_ENDC) + logger.info(COLOR_WARNING + "finishing...\n" + COLOR_ENDC) -# If we've opened serial it's now time to close it. -try: - if config.serial_port is not None: - ser.close() - logger.info(COLOR_OKGREEN + "Serial PTT: OFF" + COLOR_ENDC) -except NameError: - # sudo gpasswd --add ${USER} dialout - logger.exception("Couldn't close serial port") - -if not nopi: - GPIO.output(config.rpi_pin, GPIO.LOW) - logger.info(COLOR_OKGREEN + f"GPIO PTT: OFF, PIN: {config.rpi_pin}" + COLOR_ENDC) - GPIO.cleanup() + ptt_off() # Save the message to an audio file