python v2 - added feartures 1 and 2 - daemon hsa errors

This commit is contained in:
2025-08-08 13:33:50 -07:00
parent a1bc9b4410
commit b718a908ce
46 changed files with 719642 additions and 12 deletions

View File

@@ -12,11 +12,12 @@ app = typer.Typer(help="GarminSync - Download Garmin Connect activities", rich_m
def list_activities(
all_activities: Annotated[bool, typer.Option("--all", help="List all activities")] = False,
missing: Annotated[bool, typer.Option("--missing", help="List missing activities")] = False,
downloaded: Annotated[bool, typer.Option("--downloaded", help="List downloaded activities")] = False
downloaded: Annotated[bool, typer.Option("--downloaded", help="List downloaded activities")] = False,
offline: Annotated[bool, typer.Option("--offline", help="Work offline without syncing")] = False
):
"""List activities based on specified filters"""
from tqdm import tqdm
from .database import get_session, Activity
from .database import get_session, Activity, get_offline_stats, sync_database
from .garmin import GarminClient
# Validate input
@@ -28,10 +29,14 @@ def list_activities(
client = GarminClient()
session = get_session()
# Sync database with latest activities
typer.echo("Syncing activities from Garmin Connect...")
from .database import sync_database
sync_database(client)
if not offline:
# Sync database with latest activities
typer.echo("Syncing activities from Garmin Connect...")
sync_database(client)
else:
# Show offline status with last sync info
stats = get_offline_stats()
typer.echo(f"Working in offline mode - using cached data (last sync: {stats['last_sync']})")
# Build query based on filters
query = session.query(Activity)
@@ -130,8 +135,32 @@ def download(
if 'session' in locals():
session.close()
@app.command("daemon")
def daemon_mode(
start: Annotated[bool, typer.Option("--start", help="Start daemon")] = False,
stop: Annotated[bool, typer.Option("--stop", help="Stop daemon")] = False,
status: Annotated[bool, typer.Option("--status", help="Show daemon status")] = False,
port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080
):
"""Daemon mode operations"""
from .daemon import GarminSyncDaemon
if start:
daemon = GarminSyncDaemon()
daemon.start(web_port=port)
elif stop:
# Implementation for stopping daemon (PID file or signal)
typer.echo("Stopping daemon...")
# TODO: Implement stop (we can use a PID file to stop the daemon)
typer.echo("Daemon stop not implemented yet")
elif status:
# Show current daemon status
typer.echo("Daemon status not implemented yet")
else:
typer.echo("Please specify one of: --start, --stop, --status")
def main():
app()
if __name__ == "__main__":
main()
main()

145
garminsync/daemon.py Normal file
View File

@@ -0,0 +1,145 @@
import signal
import sys
import time
import threading
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from .database import get_session, Activity, DaemonConfig, SyncLog
from .garmin import GarminClient
from .utils import logger
class GarminSyncDaemon:
def __init__(self):
self.scheduler = BackgroundScheduler()
self.running = False
self.web_server = None
def start(self, web_port=8080):
"""Start daemon with scheduler and web UI"""
try:
# Load configuration from database
config = self.load_config()
# Setup scheduled job
if config.enabled:
self.scheduler.add_job(
func=self.sync_and_download,
trigger=CronTrigger.from_crontab(config.schedule_cron),
id='sync_job',
replace_existing=True
)
# Start scheduler
self.scheduler.start()
self.running = True
# Start web UI in separate thread
self.start_web_ui(web_port)
# Setup signal handlers for graceful shutdown
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
logger.info(f"Daemon started. Web UI available at http://localhost:{web_port}")
# Keep daemon running
while self.running:
time.sleep(1)
except Exception as e:
logger.error(f"Failed to start daemon: {str(e)}")
self.stop()
def sync_and_download(self):
"""Scheduled job function"""
try:
self.log_operation("sync", "started")
# Perform sync and download
client = GarminClient()
activities_before = self.count_missing()
# Sync database
session = get_session()
activities = client.get_activities(0, 1000)
for activity in activities:
activity_id = activity["activityId"]
existing = session.query(Activity).filter_by(activity_id=activity_id).first()
if not existing:
new_activity = Activity(
activity_id=activity_id,
start_time=activity["startTimeLocal"],
downloaded=False,
created_at=datetime.now().isoformat()
)
session.add(new_activity)
session.commit()
# Download missing activities
downloaded_count = 0
missing_activities = session.query(Activity).filter_by(downloaded=False).all()
for activity in missing_activities:
if client.download_activity(activity.activity_id, activity.start_time):
activity.downloaded = True
activity.last_sync = datetime.now().isoformat()
downloaded_count += 1
session.commit()
self.log_operation("sync", "success",
f"Downloaded {downloaded_count} new activities")
except Exception as e:
self.log_operation("sync", "error", str(e))
def load_config(self):
"""Load daemon configuration from database"""
session = get_session()
config = session.query(DaemonConfig).first()
if not config:
# Create default configuration
config = DaemonConfig()
session.add(config)
session.commit()
return config
def start_web_ui(self, port):
"""Start FastAPI web server in a separate thread"""
from .web.app import app
import uvicorn
def run_server():
uvicorn.run(app, host="0.0.0.0", port=port)
web_thread = threading.Thread(target=run_server, daemon=True)
web_thread.start()
self.web_server = web_thread
def signal_handler(self, signum, frame):
"""Handle shutdown signals"""
logger.info("Received shutdown signal, stopping daemon...")
self.stop()
def stop(self):
"""Stop daemon and clean up resources"""
if self.scheduler.running:
self.scheduler.shutdown()
self.running = False
logger.info("Daemon stopped")
def log_operation(self, operation, status, message=None):
"""Log sync operation to database"""
session = get_session()
log = SyncLog(
timestamp=datetime.now().isoformat(),
operation=operation,
status=status,
message=message
)
session.add(log)
session.commit()
def count_missing(self):
"""Count missing activities"""
session = get_session()
return session.query(Activity).filter_by(downloaded=False).count()

View File

@@ -12,6 +12,28 @@ class Activity(Base):
start_time = Column(String, nullable=False)
filename = Column(String, unique=True, nullable=True)
downloaded = Column(Boolean, default=False, nullable=False)
last_sync = Column(String, nullable=True) # ISO timestamp of last sync
class DaemonConfig(Base):
__tablename__ = 'daemon_config'
id = Column(Integer, primary_key=True, default=1)
enabled = Column(Boolean, default=True, nullable=False)
schedule_cron = Column(String, default="0 */6 * * *", nullable=False) # Every 6 hours
last_run = Column(String, nullable=True)
next_run = Column(String, nullable=True)
status = Column(String, default="stopped", nullable=False) # stopped, running, error
class SyncLog(Base):
__tablename__ = 'sync_logs'
id = Column(Integer, primary_key=True, autoincrement=True)
timestamp = Column(String, nullable=False)
operation = Column(String, nullable=False) # sync, download, daemon_start, daemon_stop
status = Column(String, nullable=False) # success, error, partial
message = Column(String, nullable=True)
activities_processed = Column(Integer, default=0, nullable=False)
activities_downloaded = Column(Integer, default=0, nullable=False)
def init_db():
"""Initialize database connection and create tables"""
@@ -28,6 +50,7 @@ def get_session():
def sync_database(garmin_client):
"""Sync local database with Garmin Connect activities"""
from datetime import datetime
session = get_session()
try:
# Fetch activities from Garmin Connect
@@ -44,7 +67,8 @@ def sync_database(garmin_client):
new_activity = Activity(
activity_id=activity_id,
start_time=start_time,
downloaded=False
downloaded=False,
last_sync=datetime.now().isoformat()
)
session.add(new_activity)
@@ -54,6 +78,24 @@ def sync_database(garmin_client):
raise e
finally:
session.close()
def get_offline_stats():
"""Return statistics about cached data without API calls"""
session = get_session()
try:
total = session.query(Activity).count()
downloaded = session.query(Activity).filter_by(downloaded=True).count()
missing = total - downloaded
# Get most recent sync timestamp
last_sync = session.query(Activity).order_by(Activity.last_sync.desc()).first()
return {
'total': total,
'downloaded': downloaded,
'missing': missing,
'last_sync': last_sync.last_sync if last_sync else 'Never synced'
}
finally:
session.close()
# Example usage:
# from .garmin import GarminClient

View File

@@ -0,0 +1 @@
# Empty file to mark this directory as a Python package

24
garminsync/web/app.py Normal file
View File

@@ -0,0 +1,24 @@
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from .routes import router
app = FastAPI(title="GarminSync Dashboard")
# Mount static files and templates
app.mount("/static", StaticFiles(directory="garminsync/web/static"), name="static")
templates = Jinja2Templates(directory="garminsync/web/templates")
# Include API routes
app.include_router(router)
@app.get("/")
async def dashboard(request: Request):
# Get current statistics
from garminsync.database import get_offline_stats
stats = get_offline_stats()
return templates.TemplateResponse("dashboard.html", {
"request": request,
"stats": stats
})

56
garminsync/web/routes.py Normal file
View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from garminsync.database import get_session, DaemonConfig, SyncLog
router = APIRouter(prefix="/api")
class ScheduleConfig(BaseModel):
enabled: bool
cron_schedule: str
@router.get("/status")
async def get_status():
"""Get current daemon status"""
session = get_session()
config = session.query(DaemonConfig).first()
# Get recent logs
logs = session.query(SyncLog).order_by(SyncLog.timestamp.desc()).limit(10).all()
return {
"daemon": {
"running": config.status == "running" if config else False,
"next_run": config.next_run if config else None,
"schedule": config.schedule_cron if config else None
},
"recent_logs": [
{
"timestamp": log.timestamp,
"operation": log.operation,
"status": log.status,
"message": log.message
} for log in logs
]
}
@router.post("/schedule")
async def update_schedule(config: ScheduleConfig):
"""Update daemon schedule configuration"""
session = get_session()
daemon_config = session.query(DaemonConfig).first()
if not daemon_config:
daemon_config = DaemonConfig()
session.add(daemon_config)
daemon_config.enabled = config.enabled
daemon_config.schedule_cron = config.cron_schedule
session.commit()
return {"message": "Configuration updated successfully"}
@router.post("/sync/trigger")
async def trigger_sync():
"""Manually trigger a sync operation"""
# TODO: Implement sync triggering
return {"message": "Sync triggered successfully"}

View File

@@ -0,0 +1,47 @@
// Auto-refresh dashboard data
setInterval(updateStatus, 30000); // Every 30 seconds
async function updateStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
// Update daemon status
document.getElementById('daemon-status').innerHTML = `
<p>Status: <span class="badge ${data.daemon.running ? 'badge-success' : 'badge-danger'}">
${data.daemon.running ? 'Running' : 'Stopped'}
</span></p>
<p>Next Run: ${data.daemon.next_run || 'Not scheduled'}</p>
<p>Schedule: ${data.daemon.schedule || 'Not configured'}</p>
`;
// Update recent logs
const logsHtml = data.recent_logs.map(log => `
<div class="log-entry">
<small class="text-muted">${log.timestamp}</small>
<span class="badge badge-${log.status === 'success' ? 'success' : 'danger'}">
${log.status}
</span>
${log.operation}: ${log.message || ''}
</div>
`).join('');
document.getElementById('recent-logs').innerHTML = logsHtml;
} catch (error) {
console.error('Failed to update status:', error);
}
}
async function triggerSync() {
try {
await fetch('/api/sync/trigger', { method: 'POST' });
alert('Sync triggered successfully');
updateStatus();
} catch (error) {
alert('Failed to trigger sync');
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', updateStatus);

View File

@@ -0,0 +1,32 @@
body {
font-family: Arial, sans-serif;
background-color: #f8f9fa;
}
.card {
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.card-header {
font-weight: bold;
background-color: #f1f1f1;
}
.btn {
margin-right: 5px;
}
.log-entry {
margin-bottom: 10px;
padding: 5px;
border-left: 3px solid #ddd;
}
.log-entry .badge-success {
background-color: #28a745;
}
.log-entry .badge-error {
background-color: #dc3545;
}

View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GarminSync Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">GarminSync</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="/">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/config">Configuration</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block content %}
<div class="container">
<h1>GarminSync Dashboard</h1>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">Statistics</div>
<div class="card-body">
<p>Total Activities: {{ stats.total }}</p>
<p>Downloaded: {{ stats.downloaded }}</p>
<p>Missing: {{ stats.missing }}</p>
<p>Last Sync: {{ stats.last_sync }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Daemon Status</div>
<div class="card-body" id="daemon-status">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Quick Actions</div>
<div class="card-body">
<button class="btn btn-primary" onclick="triggerSync()">
Sync Now
</button>
<button class="btn btn-secondary" onclick="toggleDaemon()">
Toggle Daemon
</button>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">Recent Activity</div>
<div class="card-body" id="recent-logs">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">Schedule Configuration</div>
<div class="card-body">
<form id="schedule-form">
<div class="form-group">
<label for="schedule-enabled">Enable Scheduled Sync</label>
<input type="checkbox" id="schedule-enabled">
</div>
<div class="form-group">
<label for="cron-schedule">Cron Schedule</label>
<input type="text" class="form-control" id="cron-schedule"
placeholder="0 */6 * * *" title="Every 6 hours">
</div>
<button type="submit" class="btn btn-primary">
Update Schedule
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}