Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/build_server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,11 @@ jobs:
sudo apt update
sudo apt install -y portaudio19-dev libhamlib-dev libhamlib-utils build-essential cmake patchelf

- name: Install MacOS pyAudio
- name: Install MacOS dependencies
if: ${{startsWith(matrix.os, 'macos')}}
run: |
brew install portaudio
python -m pip install --upgrade pip
pip3 install pyaudio

- name: Install Python dependencies
run: |
Expand Down
65 changes: 58 additions & 7 deletions .github/workflows/pip_package.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Deploy Python Package
on: [push]
on:
push:
tags:
- "v*"

jobs:
deploy:
Expand All @@ -17,16 +20,64 @@ jobs:
with:
node-version: 24

- name: Install Linux dependencies
run: |
sudo apt update
sudo apt install -y portaudio19-dev libhamlib-dev libhamlib-utils build-essential cmake patchelf

- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install .[build]

- name: Set package version from tag
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
python3 - <<'EOF'
import os
import re
import sys

from packaging.version import InvalidVersion, Version

tag = os.environ["RELEASE_TAG"]
version = tag.removeprefix("v")

if not re.fullmatch(r"\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?", version):
print(
f"::error::Tag '{tag}' does not look like a release version. "
f"Use e.g. v1.2.3, v1.2.3-beta, v1.2.3-rc1 or v1.2.3-alpha.1."
)
sys.exit(1)

try:
Version(version)
except InvalidVersion:
print(
f"::error::Tag '{tag}' has an unrecognized pre-release suffix "
f"('{version}' is not valid PEP 440). Use a standard suffix such "
f"as -alpha, -alpha.1, -beta, -rc1 or -dev."
)
sys.exit(1)

print(f"Releasing version {version} (from tag {tag})")

path = "freedata_server/constants.py"
with open(path) as f:
content = f.read()

new_content, count = re.subn(
r'^MODEM_VERSION = .*$',
f'MODEM_VERSION = "{version}"',
content,
count=1,
flags=re.MULTILINE,
)
if count != 1:
print("::error::Could not find MODEM_VERSION in freedata_server/constants.py")
sys.exit(1)

with open(path, "w") as f:
f.write(new_content)
EOF
grep "^MODEM_VERSION" freedata_server/constants.py

- name: Build GUI
working-directory: freedata_gui
run: |
Expand All @@ -39,7 +90,7 @@ jobs:

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.14.0
if: startsWith(github.ref, 'refs/tags/v')
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
skip-existing: true
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ARG HAMLIB_VERSION=4.5.5
ENV HAMLIB_VERSION=${HAMLIB_VERSION}

RUN apt-get update && \
apt-get install --upgrade -y fonts-noto-color-emoji git build-essential cmake portaudio19-dev python3-pyaudio python3-colorama wget && \
apt-get install --upgrade -y fonts-noto-color-emoji git build-essential cmake portaudio19-dev python3-colorama wget && \
mkdir -p /app/FreeDATA

WORKDIR /src
Expand Down
34 changes: 25 additions & 9 deletions freedata_server/codec2.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,36 @@ def freedv_get_mode_name_by_value(mode: int) -> str:
return FREEDV_MODE(mode).name


# Get the directory of the current script file
script_dir = os.path.dirname(os.path.abspath(__file__))
# Determine the base directory to search for the codec2 shared library.
#
# In normal (non-frozen) execution this is simply the directory containing
# this script, and that's where "lib/codec2/*" lives relative to
# freedata_server/codec2.py.
#
# When compiled by Nuitka into a standalone binary however, data files added
# via --include-data-dir/--include-data-files (e.g. "lib=lib") are placed
# relative to the *distribution* directory (next to the produced .exe), not
# relative to this module's own (nested) package directory. Using
# os.path.dirname(__file__) in that case points at "<dist>/freedata_server"
# while the actual DLL ends up at "<dist>/lib/codec2/libcodec2.dll" - a
# sibling directory, not a child - so the glob below never finds it.
#
# Nuitka exposes the correct directory via the compiled-only global
# `__compiled__.containing_dir`, which always points at the distribution
# directory regardless of platform or nesting. See:
# https://nuitka.net/user-documentation/common-issue-solutions.html#standalone-finding-files
try:
script_dir = __compiled__.containing_dir # type: ignore[name-defined]
except NameError:
script_dir = os.path.dirname(os.path.abspath(__file__))

# Use script_dir to construct the paths for file search
if sys.platform == "linux":
files = glob.glob(os.path.join(script_dir, "**/*libcodec2*"), recursive=True)
# files.append(os.path.join(script_dir, "libcodec2.so"))
files = glob.glob(os.path.join(script_dir, "**", "*libcodec2*"), recursive=True)
elif sys.platform == "darwin":
if hasattr(sys, "_MEIPASS"):
files = glob.glob(os.path.join(getattr(sys, "_MEIPASS"), "**/*libcodec2*"), recursive=True)
else:
files = glob.glob(os.path.join(script_dir, "**/*libcodec2*.dylib"), recursive=True)
files = glob.glob(os.path.join(script_dir, "**", "*libcodec2*.dylib"), recursive=True)
elif sys.platform in ["win32", "win64"]:
files = glob.glob(os.path.join(script_dir, "**\\*libcodec2*.dll"), recursive=True)
files = glob.glob(os.path.join(script_dir, "**", "*libcodec2*.dll"), recursive=True)
else:
files = []
api = None
Expand Down
26 changes: 26 additions & 0 deletions freedata_server/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
# Module for saving some constants
import os
import sys


def _default_app_dir() -> str:
"""
Per-user directory for config, database and log file, following each
OS's own convention rather than forcing a single layout everywhere:
- Windows: %APPDATA%\\FreeDATA
- macOS: ~/Library/Application Support/FreeDATA
- Linux: $XDG_CONFIG_HOME/FreeDATA or ~/.config/FreeDATA
Used only when FREEDATA_CONFIG / FREEDATA_DATABASE are not set (e.g. a
plain `pip install freedata` run). Keeping this outside the installed
package directory means it survives package upgrades/reinstalls.
"""
home = os.path.expanduser("~")
if sys.platform == "win32":
base = os.getenv("APPDATA") or home
elif sys.platform == "darwin":
base = os.path.join(home, "Library", "Application Support")
else:
base = os.getenv("XDG_CONFIG_HOME") or os.path.join(home, ".config")
return os.path.join(base, "FreeDATA")


CONFIG_ENV_VAR = "FREEDATA_CONFIG"
DEFAULT_CONFIG_FILE = "config.ini"
DEFAULT_APP_DIR = _default_app_dir()
MODEM_VERSION = "0.18.1"
API_VERSION = 4
ARQ_PROTOCOL_VERSION = 1
Expand Down
13 changes: 7 additions & 6 deletions freedata_server/message_system_db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import structlog
from freedata_server import helpers
import os
from freedata_server.constants import MESSAGE_SYSTEM_DATABASE_VERSION
from freedata_server.constants import MESSAGE_SYSTEM_DATABASE_VERSION, DEFAULT_APP_DIR


class DatabaseManager:
Expand Down Expand Up @@ -42,18 +42,19 @@ def get_database(self):
This method determines the database file path based on the
environment variable `FREEDATA_DATABASE`. If the variable is set,
its value is used as the path. Otherwise, it defaults to
`freedata-messages.db` in the script directory.
`freedata-messages.db` in the per-user app directory
(DEFAULT_APP_DIR), so a plain `pip install` keeps the database
outside the installed package and it survives upgrades.

Returns:
str: The database file path as a SQLAlchemy URL.
"""
script_directory = os.path.dirname(os.path.abspath(__file__))

if self.DATABASE_ENV_VAR in os.environ:
# db_path = os.getenv(self.DATABASE_ENV_VAR, os.path.join(script_directory, self.DEFAULT_DATABASE_FILE))
db_path = os.getenv(self.DATABASE_ENV_VAR)
else:
db_path = os.path.join(script_directory, self.DEFAULT_DATABASE_FILE)
db_path = os.path.join(DEFAULT_APP_DIR, self.DEFAULT_DATABASE_FILE)

os.makedirs(os.path.dirname(db_path), exist_ok=True)
return "sqlite:///" + db_path

def initialize_default_values(self):
Expand Down
46 changes: 36 additions & 10 deletions freedata_server/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import shutil
import sys

import threading
Expand All @@ -10,7 +11,7 @@
from fastapi.staticfiles import StaticFiles

from freedata_server.log_handler import setup_logging
from freedata_server.constants import CONFIG_ENV_VAR, DEFAULT_CONFIG_FILE, API_VERSION
from freedata_server.constants import CONFIG_ENV_VAR, DEFAULT_CONFIG_FILE, DEFAULT_APP_DIR, API_VERSION
from freedata_server.context import AppContext

from freedata_server.api.general import router as general_router
Expand All @@ -27,18 +28,39 @@
# --- Resolve config path FIRST (no logger needed yet) ---
def resolve_config_path() -> str:
"""
Determine the configuration file to use (env var or default next to this file).
Exits if not found.
Determine the configuration file to use.

Uses FREEDATA_CONFIG if set, otherwise defaults to a per-user config
directory (DEFAULT_APP_DIR). If no config file exists yet at that
location, a fresh one is bootstrapped from the bundled
config.ini.example template so a plain `pip install freedata` followed
by `freedata` works out of the box without any manual setup.
"""
candidate = os.getenv(
CONFIG_ENV_VAR,
os.path.join(os.path.dirname(__file__), DEFAULT_CONFIG_FILE),
candidate = os.path.abspath(
os.getenv(
CONFIG_ENV_VAR,
os.path.join(DEFAULT_APP_DIR, DEFAULT_CONFIG_FILE),
)
)

if not os.path.exists(candidate):
# We cannot log to file yet since we don't know the directory; write to stderr.
sys.stderr.write(f"[FATAL] Config file not found: {candidate}\n")
sys.exit(1)
return os.path.abspath(candidate)
template = os.path.join(os.path.dirname(__file__), "config.ini.example")
try:
os.makedirs(os.path.dirname(candidate), exist_ok=True)
if os.path.isfile(template):
shutil.copyfile(template, candidate)
sys.stderr.write(f"[INFO] No config found - created a default one at: {candidate}\n")
else:
sys.stderr.write(
f"[FATAL] Config file not found and no template available to create one: {candidate}\n"
)
sys.exit(1)
except OSError as e:
sys.stderr.write(f"[FATAL] Could not create config file at {candidate}: {e}\n")
sys.exit(1)

return candidate


config_file = resolve_config_path()
Expand Down Expand Up @@ -95,11 +117,15 @@ async def nocache(request: Request, call_next):


# Static GUI mounting
# Order matters: prefer paths anchored to this file's location (work no
# matter what the current working directory is) over cwd-relative
# fallbacks kept for backwards compatibility with older layouts.
potential_gui_dirs = [
os.path.join(os.path.dirname(__file__), "gui"), # nuitka standalone build
os.path.join(os.path.dirname(os.path.dirname(__file__)), "freedata_gui", "dist"), # pip install (sibling package)
"../freedata_gui/dist",
"freedata_gui/dist",
"FreeDATA/freedata_gui/dist",
os.path.join(os.path.dirname(__file__), "gui"),
]
gui_dir = next((d for d in potential_gui_dirs if os.path.isdir(d)), None)
if gui_dir:
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ requires-python = ">=3.10"
dependencies = [
"numpy",
"psutil",
"PyAudio",
"pyserial",
"sounddevice",
"structlog",
Expand Down Expand Up @@ -78,12 +77,13 @@ nuitka = [

[tool.setuptools.packages.find]
where = [ "." ]
exclude = [
"tools*",
include = [
"freedata_server*",
"freedata_gui",
]

[tool.setuptools.package-data]
freedata_server = [ "lib/**/*" ]
freedata_server = [ "lib/**/*", "config.ini.example" ]
freedata_gui = [ "dist/**/*" ]

[tool.setuptools.dynamic]
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
numpy
psutil
PyAudio
pyserial
sounddevice
structlog
Expand Down
9 changes: 6 additions & 3 deletions tools/Linux/install-freedata-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
#
#
# Changelog:
# 2.10: 24 Jul 2026
# Remove python3-pyaudio (unused dependency, FreeDATA uses sounddevice)
#
# 2.9: 10 Jan Sep 2026
# Add Ubuntu 24.10 and 25.04
# Change hamlib default version to 4.6.5
Expand Down Expand Up @@ -164,7 +167,7 @@ case $osname in
"Debian GNU/Linux")
case $osversion in
"11" | "12" | "13")
sudo apt install --upgrade -y fonts-noto-color-emoji git build-essential cmake python3 portaudio19-dev python3-pyaudio python3-pip python3-colorama python3-venv wget python3-dev
sudo apt install --upgrade -y fonts-noto-color-emoji git build-essential cmake python3 portaudio19-dev python3-pip python3-colorama python3-venv wget python3-dev
;;

*)
Expand All @@ -182,7 +185,7 @@ case $osname in
"Ubuntu" | "Linux Mint")
case $osversion in
"21.3" | "22.04" | "24.04" | "24.10" | "25.04" )
sudo apt install --upgrade -y fonts-noto-color-emoji git build-essential cmake python3 portaudio19-dev python3-pyaudio python3-pip python3-colorama python3-venv wget python3-dev
sudo apt install --upgrade -y fonts-noto-color-emoji git build-essential cmake python3 portaudio19-dev python3-pip python3-colorama python3-venv wget python3-dev
;;

*)
Expand All @@ -197,7 +200,7 @@ case $osname in
"Fedora Linux")
case $osversion in
"VERSION_ID=40" | "VERSION_ID=41")
sudo dnf install -y git cmake make automake gcc gcc-c++ kernel-devel wget portaudio-devel python3-pyaudio python3-pip python3-colorama python3-virtualenv google-noto-emoji-fonts python3-devel
sudo dnf install -y git cmake make automake gcc gcc-c++ kernel-devel wget portaudio-devel python3-pip python3-colorama python3-virtualenv google-noto-emoji-fonts python3-devel
;;
esac
;;
Expand Down