mirror of
https://github.com/sstent/GarminSync.git
synced 2026-02-01 03:51:45 +00:00
python v2 - added feartures 1 and 2 - daemon hsa errors
This commit is contained in:
@@ -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
145
garminsync/daemon.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
1
garminsync/web/__init__.py
Normal file
1
garminsync/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Empty file to mark this directory as a Python package
|
||||
24
garminsync/web/app.py
Normal file
24
garminsync/web/app.py
Normal 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
56
garminsync/web/routes.py
Normal 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"}
|
||||
47
garminsync/web/static/app.js
Normal file
47
garminsync/web/static/app.js
Normal 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);
|
||||
32
garminsync/web/static/style.css
Normal file
32
garminsync/web/static/style.css
Normal 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;
|
||||
}
|
||||
37
garminsync/web/templates/base.html
Normal file
37
garminsync/web/templates/base.html
Normal 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>
|
||||
79
garminsync/web/templates/dashboard.html
Normal file
79
garminsync/web/templates/dashboard.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user