mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-08 18:02:50 +02:00
Merge pull request #9 from ipnet-mesh/claude/add-data-home-config-01WnK1Q9vczq5atX7U5P3yJz
Add configurable data directory for services
This commit is contained in:
+21
-4
@@ -11,6 +11,23 @@
|
||||
# MESHCORE_IMAGE=ghcr.io/ipnet-mesh/meshcore-hub:v1.0.0
|
||||
MESHCORE_IMAGE=
|
||||
|
||||
# ===================
|
||||
# Data Directory
|
||||
# ===================
|
||||
|
||||
# Base directory for all service data (collector DB, tags, members, etc.)
|
||||
# Default: ./data (relative to docker-compose.yml location)
|
||||
# Inside containers this is mapped to /data
|
||||
#
|
||||
# Structure:
|
||||
# ${DATA_HOME}/
|
||||
# ├── collector/
|
||||
# │ ├── meshcore.db # SQLite database
|
||||
# │ └── tags.json # Node tags for import
|
||||
# └── web/
|
||||
# └── members.json # Network members list
|
||||
DATA_HOME=./data
|
||||
|
||||
# ===================
|
||||
# Common Settings
|
||||
# ===================
|
||||
@@ -72,10 +89,10 @@ NETWORK_RADIO_CONFIG=
|
||||
NETWORK_CONTACT_EMAIL=
|
||||
NETWORK_CONTACT_DISCORD=
|
||||
|
||||
# Path to members JSON file (mounted into container)
|
||||
# For production: use ./data/members.json
|
||||
# For testing with example data: use ./example/data/members.json
|
||||
MEMBERS_FILE_PATH=./example/data/members.json
|
||||
# Members file location (optional override)
|
||||
# Default: ${DATA_HOME}/web/members.json
|
||||
# Only set this if you want to use a different location
|
||||
# MEMBERS_FILE=/custom/path/to/members.json
|
||||
|
||||
# ===================
|
||||
# Webhook Settings
|
||||
|
||||
@@ -283,9 +283,12 @@ meshcore-hub/
|
||||
│ │ └── tags.json # Example node tags data
|
||||
│ └── web/
|
||||
│ └── members.json # Example network members data
|
||||
├── data/ # Runtime data (gitignored)
|
||||
│ ├── collector/ # Collector data (tags.json)
|
||||
│ └── web/ # Web data (members.json)
|
||||
├── data/ # Runtime data (gitignored, DATA_HOME default)
|
||||
│ ├── collector/ # Collector data
|
||||
│ │ ├── meshcore.db # SQLite database
|
||||
│ │ └── tags.json # Node tags for import
|
||||
│ └── web/ # Web data
|
||||
│ └── members.json # Network members list
|
||||
├── Dockerfile # Docker build configuration
|
||||
├── docker-compose.yml # Docker Compose services (gitignored)
|
||||
└── docker-compose.yml.example # Docker Compose template
|
||||
@@ -433,11 +436,26 @@ meshcore-hub interface --mode receiver --mock
|
||||
See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list.
|
||||
|
||||
Key variables:
|
||||
- `DATA_HOME` - Base directory for all service data (default: `./data`)
|
||||
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
|
||||
- `DATABASE_URL` - SQLAlchemy database URL
|
||||
- `DATABASE_URL` - SQLAlchemy database URL (default: `sqlite:///{DATA_HOME}/collector/meshcore.db`)
|
||||
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
|
||||
- `LOG_LEVEL` - Logging verbosity
|
||||
|
||||
### Data Directory Structure
|
||||
|
||||
The `DATA_HOME` environment variable controls where all service data is stored:
|
||||
```
|
||||
${DATA_HOME}/
|
||||
├── collector/
|
||||
│ ├── meshcore.db # SQLite database
|
||||
│ └── tags.json # Node tags for import
|
||||
└── web/
|
||||
└── members.json # Network members list
|
||||
```
|
||||
|
||||
Services automatically create their subdirectories if they don't exist.
|
||||
|
||||
### Webhook Configuration
|
||||
|
||||
The collector supports forwarding events to external HTTP endpoints:
|
||||
|
||||
@@ -456,6 +456,7 @@ meshcore/+/command/request_telemetry
|
||||
### Common
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| DATA_HOME | ./data | Base directory for service data |
|
||||
| LOG_LEVEL | INFO | Logging level |
|
||||
| MQTT_HOST | localhost | MQTT broker host |
|
||||
| MQTT_PORT | 1883 | MQTT broker port |
|
||||
@@ -463,6 +464,17 @@ meshcore/+/command/request_telemetry
|
||||
| MQTT_PASSWORD | | MQTT password (optional) |
|
||||
| MQTT_PREFIX | meshcore | Topic prefix |
|
||||
|
||||
### Data Directory Structure
|
||||
The `DATA_HOME` environment variable controls where all service data is stored:
|
||||
```
|
||||
${DATA_HOME}/
|
||||
├── collector/
|
||||
│ ├── meshcore.db # SQLite database
|
||||
│ └── tags.json # Node tags for import
|
||||
└── web/
|
||||
└── members.json # Network members list
|
||||
```
|
||||
|
||||
### Interface
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
@@ -474,7 +486,8 @@ meshcore/+/command/request_telemetry
|
||||
### Collector
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| DATABASE_URL | sqlite:///./meshcore.db | SQLAlchemy URL |
|
||||
| DATABASE_URL | sqlite:///{DATA_HOME}/collector/meshcore.db | SQLAlchemy URL |
|
||||
| TAGS_FILE | {DATA_HOME}/collector/tags.json | Path to tags JSON file |
|
||||
|
||||
### API
|
||||
| Variable | Default | Description |
|
||||
@@ -483,7 +496,7 @@ meshcore/+/command/request_telemetry
|
||||
| API_PORT | 8000 | API bind port |
|
||||
| API_READ_KEY | | Read-only API key |
|
||||
| API_ADMIN_KEY | | Admin API key |
|
||||
| DATABASE_URL | sqlite:///./meshcore.db | SQLAlchemy URL |
|
||||
| DATABASE_URL | sqlite:///{DATA_HOME}/collector/meshcore.db | SQLAlchemy URL |
|
||||
|
||||
### Web Dashboard
|
||||
| Variable | Default | Description |
|
||||
@@ -500,7 +513,7 @@ meshcore/+/command/request_telemetry
|
||||
| NETWORK_RADIO_CONFIG | | Radio config details |
|
||||
| NETWORK_CONTACT_EMAIL | | Contact email |
|
||||
| NETWORK_CONTACT_DISCORD | | Discord link |
|
||||
| MEMBERS_FILE | members.json | Path to members JSON |
|
||||
| MEMBERS_FILE | {DATA_HOME}/web/members.json | Path to members JSON |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+13
-1
@@ -21,10 +21,22 @@ target_metadata = Base.metadata
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""Get database URL from environment or config."""
|
||||
# First try environment variable
|
||||
from pathlib import Path
|
||||
|
||||
# First try explicit DATABASE_URL environment variable
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
# Ensure directory exists for sqlite URLs
|
||||
if url.startswith("sqlite:///"):
|
||||
db_path = Path(url.replace("sqlite:///", ""))
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return url
|
||||
# Try DATA_HOME environment variable
|
||||
data_home = os.environ.get("DATA_HOME")
|
||||
if data_home:
|
||||
db_path = Path(data_home) / "collector" / "meshcore.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return f"sqlite:///{db_path}"
|
||||
# Fall back to alembic.ini
|
||||
return config.get_main_option("sqlalchemy.url", "sqlite:///./meshcore.db")
|
||||
|
||||
|
||||
+25
-20
@@ -17,9 +17,14 @@
|
||||
# - migrate: Run database migrations
|
||||
# - import-tags: Import node tags from JSON file
|
||||
#
|
||||
# Data Directory Structure:
|
||||
# ./data/
|
||||
# Data Directory (DATA_HOME):
|
||||
# The DATA_HOME environment variable controls where all service data is stored.
|
||||
# Default: ./data (local) or /data (in containers)
|
||||
#
|
||||
# Structure:
|
||||
# ${DATA_HOME}/
|
||||
# ├── collector/
|
||||
# │ ├── meshcore.db # SQLite database
|
||||
# │ └── tags.json # Node tags for import
|
||||
# └── web/
|
||||
# └── members.json # Network members list
|
||||
@@ -175,9 +180,8 @@ services:
|
||||
mqtt:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- meshcore_data:/data
|
||||
# Mount collector data directory for tags.json (optional)
|
||||
- ${COLLECTOR_DATA_PATH:-./data/collector}:/app/collector-data:ro
|
||||
# Mount data directory (contains collector/meshcore.db and collector/tags.json)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
||||
@@ -185,7 +189,7 @@ services:
|
||||
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
||||
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
|
||||
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
||||
- DATABASE_URL=sqlite:////data/meshcore.db
|
||||
- DATA_HOME=/data
|
||||
# Webhook configuration
|
||||
- WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-}
|
||||
- WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-}
|
||||
@@ -227,7 +231,8 @@ services:
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- meshcore_data:/data
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- MQTT_HOST=${MQTT_HOST:-mqtt}
|
||||
@@ -235,7 +240,7 @@ services:
|
||||
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
||||
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
|
||||
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
|
||||
- DATABASE_URL=sqlite:////data/meshcore.db
|
||||
- DATA_HOME=/data
|
||||
- API_HOST=0.0.0.0
|
||||
- API_PORT=8000
|
||||
- API_READ_KEY=${API_READ_KEY:-}
|
||||
@@ -266,12 +271,16 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
volumes:
|
||||
# Mount data directory (uses web/members.json)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
environment:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- API_BASE_URL=http://api:8000
|
||||
- API_KEY=${API_READ_KEY:-}
|
||||
- WEB_HOST=0.0.0.0
|
||||
- WEB_PORT=8080
|
||||
- DATA_HOME=/data
|
||||
- NETWORK_NAME=${NETWORK_NAME:-MeshCore Network}
|
||||
- NETWORK_CITY=${NETWORK_CITY:-}
|
||||
- NETWORK_COUNTRY=${NETWORK_COUNTRY:-}
|
||||
@@ -279,10 +288,6 @@ services:
|
||||
- NETWORK_RADIO_CONFIG=${NETWORK_RADIO_CONFIG:-}
|
||||
- NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-}
|
||||
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
|
||||
- MEMBERS_FILE=${MEMBERS_FILE:-/app/web-data/members.json}
|
||||
volumes:
|
||||
# Mount web data directory for members.json (optional)
|
||||
- ${WEB_DATA_PATH:-./data/web}:/app/web-data:ro
|
||||
command: ["web"]
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
@@ -302,9 +307,10 @@ services:
|
||||
profiles:
|
||||
- migrate
|
||||
volumes:
|
||||
- meshcore_data:/data
|
||||
# Mount data directory (uses collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////data/meshcore.db
|
||||
- DATA_HOME=/data
|
||||
command: ["db", "upgrade"]
|
||||
|
||||
# ==========================================================================
|
||||
@@ -318,12 +324,13 @@ services:
|
||||
profiles:
|
||||
- import-tags
|
||||
volumes:
|
||||
- meshcore_data:/data
|
||||
- ${COLLECTOR_DATA_PATH:-./data/collector}:/app/collector-data:ro
|
||||
# Mount data directory (uses collector/tags.json and collector/meshcore.db)
|
||||
- ${DATA_HOME:-./data}:/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:////data/meshcore.db
|
||||
- DATA_HOME=/data
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
command: ["collector", "import-tags", "/app/collector-data/tags.json"]
|
||||
# Uses default tags file: /data/collector/tags.json
|
||||
command: ["collector", "import-tags"]
|
||||
|
||||
# ==========================================================================
|
||||
# Volumes
|
||||
@@ -333,5 +340,3 @@ volumes:
|
||||
name: meshcore_mosquitto_data
|
||||
mosquitto_log:
|
||||
name: meshcore_mosquitto_log
|
||||
meshcore_data:
|
||||
name: meshcore_data
|
||||
|
||||
@@ -18,12 +18,19 @@ import click
|
||||
envvar="API_PORT",
|
||||
help="API server port",
|
||||
)
|
||||
@click.option(
|
||||
"--data-home",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="DATA_HOME",
|
||||
help="Base data directory (default: ./data)",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default="sqlite:///./meshcore.db",
|
||||
default=None,
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
help="Database connection URL (default: sqlite:///{data_home}/collector/meshcore.db)",
|
||||
)
|
||||
@click.option(
|
||||
"--read-key",
|
||||
@@ -78,7 +85,8 @@ def api(
|
||||
ctx: click.Context,
|
||||
host: str,
|
||||
port: int,
|
||||
database_url: str,
|
||||
data_home: str | None,
|
||||
database_url: str | None,
|
||||
read_key: str | None,
|
||||
admin_key: str | None,
|
||||
mqtt_host: str,
|
||||
@@ -108,14 +116,27 @@ def api(
|
||||
"""
|
||||
import uvicorn
|
||||
|
||||
from meshcore_hub.common.config import get_api_settings
|
||||
from meshcore_hub.api.app import create_app
|
||||
|
||||
# Get settings to compute effective values
|
||||
settings = get_api_settings()
|
||||
|
||||
# Override data_home if provided
|
||||
if data_home:
|
||||
settings = settings.model_copy(update={"data_home": data_home})
|
||||
|
||||
# Use effective database URL if not explicitly provided
|
||||
effective_db_url = database_url if database_url else settings.effective_database_url
|
||||
effective_data_home = data_home or settings.data_home
|
||||
|
||||
click.echo("=" * 50)
|
||||
click.echo("MeshCore Hub API Server")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Host: {host}")
|
||||
click.echo(f"Port: {port}")
|
||||
click.echo(f"Database: {database_url}")
|
||||
click.echo(f"Data home: {effective_data_home}")
|
||||
click.echo(f"Database: {effective_db_url}")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {mqtt_prefix})")
|
||||
click.echo(f"Read key configured: {read_key is not None}")
|
||||
click.echo(f"Admin key configured: {admin_key is not None}")
|
||||
@@ -144,7 +165,7 @@ def api(
|
||||
else:
|
||||
# For production, create app directly
|
||||
app = create_app(
|
||||
database_url=database_url,
|
||||
database_url=effective_db_url,
|
||||
read_key=read_key,
|
||||
admin_key=admin_key,
|
||||
mqtt_host=mqtt_host,
|
||||
|
||||
@@ -42,12 +42,19 @@ from meshcore_hub.common.logging import configure_logging
|
||||
envvar="MQTT_PREFIX",
|
||||
help="MQTT topic prefix",
|
||||
)
|
||||
@click.option(
|
||||
"--data-home",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="DATA_HOME",
|
||||
help="Base data directory (default: ./data)",
|
||||
)
|
||||
@click.option(
|
||||
"--database-url",
|
||||
type=str,
|
||||
default="sqlite:///./meshcore.db",
|
||||
default=None,
|
||||
envvar="DATABASE_URL",
|
||||
help="Database connection URL",
|
||||
help="Database connection URL (default: sqlite:///{data_home}/collector/meshcore.db)",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
@@ -63,7 +70,8 @@ def collector(
|
||||
mqtt_username: str | None,
|
||||
mqtt_password: str | None,
|
||||
prefix: str,
|
||||
database_url: str,
|
||||
data_home: str | None,
|
||||
database_url: str | None,
|
||||
log_level: str,
|
||||
) -> None:
|
||||
"""Collector component for storing MeshCore events.
|
||||
@@ -80,14 +88,30 @@ def collector(
|
||||
|
||||
When invoked without a subcommand, runs the collector service.
|
||||
"""
|
||||
from meshcore_hub.common.config import get_collector_settings
|
||||
|
||||
# Get settings to compute effective values
|
||||
settings = get_collector_settings()
|
||||
|
||||
# Override data_home if provided
|
||||
if data_home:
|
||||
settings = get_collector_settings()
|
||||
# Re-create settings with data_home override
|
||||
settings = settings.model_copy(update={"data_home": data_home})
|
||||
|
||||
# Use effective database URL if not explicitly provided
|
||||
effective_db_url = database_url if database_url else settings.effective_database_url
|
||||
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["mqtt_host"] = mqtt_host
|
||||
ctx.obj["mqtt_port"] = mqtt_port
|
||||
ctx.obj["mqtt_username"] = mqtt_username
|
||||
ctx.obj["mqtt_password"] = mqtt_password
|
||||
ctx.obj["prefix"] = prefix
|
||||
ctx.obj["database_url"] = database_url
|
||||
ctx.obj["data_home"] = data_home or settings.data_home
|
||||
ctx.obj["database_url"] = effective_db_url
|
||||
ctx.obj["log_level"] = log_level
|
||||
ctx.obj["settings"] = settings
|
||||
|
||||
# If no subcommand, run the collector service
|
||||
if ctx.invoked_subcommand is None:
|
||||
@@ -97,8 +121,9 @@ def collector(
|
||||
mqtt_username=mqtt_username,
|
||||
mqtt_password=mqtt_password,
|
||||
prefix=prefix,
|
||||
database_url=database_url,
|
||||
database_url=effective_db_url,
|
||||
log_level=log_level,
|
||||
data_home=data_home or settings.data_home,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +135,7 @@ def _run_collector_service(
|
||||
prefix: str,
|
||||
database_url: str,
|
||||
log_level: str,
|
||||
data_home: str,
|
||||
) -> None:
|
||||
"""Run the collector service.
|
||||
|
||||
@@ -119,9 +145,16 @@ def _run_collector_service(
|
||||
- WEBHOOK_CHANNEL_MESSAGE_URL: Override for channel messages
|
||||
- WEBHOOK_DIRECT_MESSAGE_URL: Override for direct messages
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
configure_logging(level=log_level)
|
||||
|
||||
# Ensure data directory exists
|
||||
collector_data_dir = Path(data_home) / "collector"
|
||||
collector_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
click.echo("Starting MeshCore Collector")
|
||||
click.echo(f"Data home: {data_home}")
|
||||
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
|
||||
click.echo(f"Database: {database_url}")
|
||||
|
||||
@@ -171,11 +204,12 @@ def run_cmd(ctx: click.Context) -> None:
|
||||
prefix=ctx.obj["prefix"],
|
||||
database_url=ctx.obj["database_url"],
|
||||
log_level=ctx.obj["log_level"],
|
||||
data_home=ctx.obj["data_home"],
|
||||
)
|
||||
|
||||
|
||||
@collector.command("import-tags")
|
||||
@click.argument("file", type=click.Path(exists=True))
|
||||
@click.argument("file", type=click.Path(exists=True), required=False, default=None)
|
||||
@click.option(
|
||||
"--no-create-nodes",
|
||||
is_flag=True,
|
||||
@@ -185,7 +219,7 @@ def run_cmd(ctx: click.Context) -> None:
|
||||
@click.pass_context
|
||||
def import_tags_cmd(
|
||||
ctx: click.Context,
|
||||
file: str,
|
||||
file: str | None,
|
||||
no_create_nodes: bool,
|
||||
) -> None:
|
||||
"""Import node tags from a JSON file.
|
||||
@@ -194,6 +228,7 @@ def import_tags_cmd(
|
||||
into the database. Existing tags are updated, new tags are created.
|
||||
|
||||
FILE is the path to the JSON file containing tags.
|
||||
If not provided, defaults to {DATA_HOME}/collector/tags.json.
|
||||
|
||||
Expected JSON format:
|
||||
\b
|
||||
@@ -210,9 +245,21 @@ def import_tags_cmd(
|
||||
|
||||
Supported value_type: string, number, boolean, coordinate
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
configure_logging(level=ctx.obj["log_level"])
|
||||
|
||||
click.echo(f"Importing tags from: {file}")
|
||||
# Use effective tags file if not provided
|
||||
settings = ctx.obj["settings"]
|
||||
tags_file = file if file else settings.effective_tags_file
|
||||
|
||||
# Check if file exists when using default
|
||||
if not file and not Path(tags_file).exists():
|
||||
click.echo(f"Tags file not found: {tags_file}")
|
||||
click.echo("Specify a file path or create the default tags file.")
|
||||
return
|
||||
|
||||
click.echo(f"Importing tags from: {tags_file}")
|
||||
click.echo(f"Database: {ctx.obj['database_url']}")
|
||||
|
||||
from meshcore_hub.common.database import DatabaseManager
|
||||
@@ -224,7 +271,7 @@ def import_tags_cmd(
|
||||
|
||||
# Import tags
|
||||
stats = import_tags(
|
||||
file_path=file,
|
||||
file_path=tags_file,
|
||||
db=db,
|
||||
create_nodes=not no_create_nodes,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,12 @@ class CommonSettings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Data home directory (base for all service data directories)
|
||||
data_home: str = Field(
|
||||
default="./data",
|
||||
description="Base directory for service data (e.g., ./data or /data)",
|
||||
)
|
||||
|
||||
# Logging
|
||||
log_level: LogLevel = Field(default=LogLevel.INFO, description="Logging level")
|
||||
|
||||
@@ -68,10 +74,16 @@ class InterfaceSettings(CommonSettings):
|
||||
class CollectorSettings(CommonSettings):
|
||||
"""Settings for the Collector component."""
|
||||
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./meshcore.db",
|
||||
description="SQLAlchemy database URL",
|
||||
# Database - default uses data_home/collector/meshcore.db
|
||||
database_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="SQLAlchemy database URL (default: sqlite:///{data_home}/collector/meshcore.db)",
|
||||
)
|
||||
|
||||
# Tags file for import - default uses data_home/collector/tags.json
|
||||
tags_file: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Path to tags JSON file (default: {data_home}/collector/tags.json)",
|
||||
)
|
||||
|
||||
# Webhook URLs (empty = disabled)
|
||||
@@ -109,12 +121,37 @@ class CollectorSettings(CommonSettings):
|
||||
default=2.0, description="Retry backoff multiplier"
|
||||
)
|
||||
|
||||
@property
|
||||
def collector_data_dir(self) -> str:
|
||||
"""Get the collector data directory path."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.data_home) / "collector")
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
"""Get the effective database URL, using default if not set."""
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path(self.data_home) / "collector" / "meshcore.db"
|
||||
return f"sqlite:///{db_path}"
|
||||
|
||||
@property
|
||||
def effective_tags_file(self) -> str:
|
||||
"""Get the effective tags file path, using default if not set."""
|
||||
if self.tags_file:
|
||||
return self.tags_file
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.data_home) / "collector" / "tags.json")
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v: str) -> str:
|
||||
def validate_database_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate database URL format."""
|
||||
if not v:
|
||||
raise ValueError("Database URL cannot be empty")
|
||||
# None is allowed - will use default
|
||||
return v
|
||||
|
||||
|
||||
@@ -125,10 +162,10 @@ class APISettings(CommonSettings):
|
||||
api_host: str = Field(default="0.0.0.0", description="API server host")
|
||||
api_port: int = Field(default=8000, description="API server port")
|
||||
|
||||
# Database
|
||||
database_url: str = Field(
|
||||
default="sqlite:///./meshcore.db",
|
||||
description="SQLAlchemy database URL",
|
||||
# Database - default uses data_home/collector/meshcore.db (same as collector)
|
||||
database_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="SQLAlchemy database URL (default: sqlite:///{data_home}/collector/meshcore.db)",
|
||||
)
|
||||
|
||||
# Authentication
|
||||
@@ -137,12 +174,21 @@ class APISettings(CommonSettings):
|
||||
default=None, description="Admin API key (full access)"
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_database_url(self) -> str:
|
||||
"""Get the effective database URL, using default if not set."""
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
from pathlib import Path
|
||||
|
||||
db_path = Path(self.data_home) / "collector" / "meshcore.db"
|
||||
return f"sqlite:///{db_path}"
|
||||
|
||||
@field_validator("database_url")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v: str) -> str:
|
||||
def validate_database_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate database URL format."""
|
||||
if not v:
|
||||
raise ValueError("Database URL cannot be empty")
|
||||
# None is allowed - will use default
|
||||
return v
|
||||
|
||||
|
||||
@@ -186,11 +232,28 @@ class WebSettings(CommonSettings):
|
||||
default=None, description="Discord server link"
|
||||
)
|
||||
|
||||
# Members file
|
||||
members_file: str = Field(
|
||||
default="members.json", description="Path to members JSON file"
|
||||
# Members file - default uses data_home/web/members.json
|
||||
members_file: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Path to members JSON file (default: {data_home}/web/members.json)",
|
||||
)
|
||||
|
||||
@property
|
||||
def web_data_dir(self) -> str:
|
||||
"""Get the web data directory path."""
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.data_home) / "web")
|
||||
|
||||
@property
|
||||
def effective_members_file(self) -> str:
|
||||
"""Get the effective members file path, using default if not set."""
|
||||
if self.members_file:
|
||||
return self.members_file
|
||||
from pathlib import Path
|
||||
|
||||
return str(Path(self.data_home) / "web" / "members.json")
|
||||
|
||||
|
||||
def get_common_settings() -> CommonSettings:
|
||||
"""Get common settings instance."""
|
||||
|
||||
@@ -32,6 +32,13 @@ import click
|
||||
envvar="API_KEY",
|
||||
help="API key for queries",
|
||||
)
|
||||
@click.option(
|
||||
"--data-home",
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="DATA_HOME",
|
||||
help="Base data directory (default: ./data)",
|
||||
)
|
||||
@click.option(
|
||||
"--network-name",
|
||||
type=str,
|
||||
@@ -93,7 +100,7 @@ import click
|
||||
type=str,
|
||||
default=None,
|
||||
envvar="MEMBERS_FILE",
|
||||
help="Path to members JSON file",
|
||||
help="Path to members JSON file (default: {data_home}/web/members.json)",
|
||||
)
|
||||
@click.option(
|
||||
"--reload",
|
||||
@@ -108,6 +115,7 @@ def web(
|
||||
port: int,
|
||||
api_url: str,
|
||||
api_key: str | None,
|
||||
data_home: str | None,
|
||||
network_name: str,
|
||||
network_city: str | None,
|
||||
network_country: str | None,
|
||||
@@ -142,14 +150,34 @@ def web(
|
||||
meshcore-hub web --reload
|
||||
"""
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
from meshcore_hub.common.config import get_web_settings
|
||||
from meshcore_hub.web.app import create_app
|
||||
|
||||
# Get settings to compute effective values
|
||||
settings = get_web_settings()
|
||||
|
||||
# Override data_home if provided
|
||||
if data_home:
|
||||
settings = settings.model_copy(update={"data_home": data_home})
|
||||
|
||||
# Use effective members file if not explicitly provided
|
||||
effective_members_file = (
|
||||
members_file if members_file else settings.effective_members_file
|
||||
)
|
||||
effective_data_home = data_home or settings.data_home
|
||||
|
||||
# Ensure web data directory exists
|
||||
web_data_dir = Path(effective_data_home) / "web"
|
||||
web_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
click.echo("=" * 50)
|
||||
click.echo("MeshCore Hub Web Dashboard")
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Host: {host}")
|
||||
click.echo(f"Port: {port}")
|
||||
click.echo(f"Data home: {effective_data_home}")
|
||||
click.echo(f"API URL: {api_url}")
|
||||
click.echo(f"API key configured: {api_key is not None}")
|
||||
click.echo(f"Network: {network_name}")
|
||||
@@ -157,8 +185,7 @@ def web(
|
||||
click.echo(f"Location: {network_city}, {network_country}")
|
||||
if network_lat != 0.0 or network_lon != 0.0:
|
||||
click.echo(f"Map center: {network_lat}, {network_lon}")
|
||||
if members_file:
|
||||
click.echo(f"Members file: {members_file}")
|
||||
click.echo(f"Members file: {effective_members_file}")
|
||||
click.echo(f"Reload mode: {reload}")
|
||||
click.echo("=" * 50)
|
||||
|
||||
@@ -188,7 +215,7 @@ def web(
|
||||
network_radio_config=network_radio_config,
|
||||
network_contact_email=network_contact_email,
|
||||
network_contact_discord=network_contact_discord,
|
||||
members_file=members_file,
|
||||
members_file=effective_members_file,
|
||||
)
|
||||
|
||||
click.echo("\nStarting web dashboard...")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for configuration settings."""
|
||||
|
||||
import pytest
|
||||
|
||||
from meshcore_hub.common.config import (
|
||||
CommonSettings,
|
||||
InterfaceSettings,
|
||||
@@ -20,6 +18,7 @@ class TestCommonSettings:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = CommonSettings(_env_file=None)
|
||||
|
||||
assert settings.data_home == "./data"
|
||||
assert settings.log_level == LogLevel.INFO
|
||||
assert settings.mqtt_host == "localhost"
|
||||
assert settings.mqtt_port == 1883
|
||||
@@ -27,6 +26,12 @@ class TestCommonSettings:
|
||||
assert settings.mqtt_password is None
|
||||
assert settings.mqtt_prefix == "meshcore"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test custom DATA_HOME setting."""
|
||||
settings = CommonSettings(_env_file=None, data_home="/custom/data")
|
||||
|
||||
assert settings.data_home == "/custom/data"
|
||||
|
||||
|
||||
class TestInterfaceSettings:
|
||||
"""Tests for InterfaceSettings."""
|
||||
@@ -48,12 +53,34 @@ class TestCollectorSettings:
|
||||
"""Test default setting values without .env file influence."""
|
||||
settings = CollectorSettings(_env_file=None)
|
||||
|
||||
assert settings.database_url == "sqlite:///./meshcore.db"
|
||||
# database_url is None by default, effective_database_url computes it
|
||||
assert settings.database_url is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db"
|
||||
assert settings.data_home == "./data"
|
||||
assert settings.collector_data_dir == "data/collector"
|
||||
assert settings.tags_file is None
|
||||
assert settings.effective_tags_file == "data/collector/tags.json"
|
||||
|
||||
def test_database_url_validation(self) -> None:
|
||||
"""Test database URL validation."""
|
||||
with pytest.raises(ValueError):
|
||||
CollectorSettings(_env_file=None, database_url="")
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective paths."""
|
||||
settings = CollectorSettings(_env_file=None, data_home="/custom/data")
|
||||
|
||||
assert (
|
||||
settings.effective_database_url
|
||||
== "sqlite:////custom/data/collector/meshcore.db"
|
||||
)
|
||||
assert settings.collector_data_dir == "/custom/data/collector"
|
||||
assert settings.effective_tags_file == "/custom/data/collector/tags.json"
|
||||
|
||||
def test_explicit_database_url_overrides(self) -> None:
|
||||
"""Test that explicit database_url overrides the default."""
|
||||
settings = CollectorSettings(
|
||||
_env_file=None, database_url="postgresql://user@host/db"
|
||||
)
|
||||
|
||||
assert settings.database_url == "postgresql://user@host/db"
|
||||
assert settings.effective_database_url == "postgresql://user@host/db"
|
||||
|
||||
|
||||
class TestAPISettings:
|
||||
@@ -65,10 +92,29 @@ class TestAPISettings:
|
||||
|
||||
assert settings.api_host == "0.0.0.0"
|
||||
assert settings.api_port == 8000
|
||||
assert settings.database_url == "sqlite:///./meshcore.db"
|
||||
# database_url is None by default, effective_database_url computes it
|
||||
assert settings.database_url is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db"
|
||||
assert settings.api_read_key is None
|
||||
assert settings.api_admin_key is None
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective database path."""
|
||||
settings = APISettings(_env_file=None, data_home="/custom/data")
|
||||
|
||||
assert (
|
||||
settings.effective_database_url
|
||||
== "sqlite:////custom/data/collector/meshcore.db"
|
||||
)
|
||||
|
||||
def test_explicit_database_url_overrides(self) -> None:
|
||||
"""Test that explicit database_url overrides the default."""
|
||||
settings = APISettings(_env_file=None, database_url="postgresql://user@host/db")
|
||||
|
||||
assert settings.database_url == "postgresql://user@host/db"
|
||||
assert settings.effective_database_url == "postgresql://user@host/db"
|
||||
|
||||
|
||||
class TestWebSettings:
|
||||
"""Tests for WebSettings."""
|
||||
@@ -81,4 +127,22 @@ class TestWebSettings:
|
||||
assert settings.web_port == 8080
|
||||
assert settings.api_base_url == "http://localhost:8000"
|
||||
assert settings.network_name == "MeshCore Network"
|
||||
assert settings.members_file == "members.json"
|
||||
# members_file is None by default, effective_members_file computes it
|
||||
assert settings.members_file is None
|
||||
# Path normalizes ./data to data
|
||||
assert settings.effective_members_file == "data/web/members.json"
|
||||
assert settings.web_data_dir == "data/web"
|
||||
|
||||
def test_custom_data_home(self) -> None:
|
||||
"""Test that custom data_home affects effective paths."""
|
||||
settings = WebSettings(_env_file=None, data_home="/custom/data")
|
||||
|
||||
assert settings.effective_members_file == "/custom/data/web/members.json"
|
||||
assert settings.web_data_dir == "/custom/data/web"
|
||||
|
||||
def test_explicit_members_file_overrides(self) -> None:
|
||||
"""Test that explicit members_file overrides the default."""
|
||||
settings = WebSettings(_env_file=None, members_file="/path/to/members.json")
|
||||
|
||||
assert settings.members_file == "/path/to/members.json"
|
||||
assert settings.effective_members_file == "/path/to/members.json"
|
||||
|
||||
Reference in New Issue
Block a user