diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index b16d95ab7..988527e05 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -73,7 +73,6 @@ async def get_settings(db: AsyncSession = Depends(get_db)): "capture_finish_photo", "spoolman_enabled", "check_updates", - "telemetry_enabled", "virtual_printer_enabled", "ftp_retry_enabled", "mqtt_enabled", diff --git a/backend/app/main.py b/backend/app/main.py index 719402212..f51e8c848 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -100,7 +100,6 @@ from backend.app.services.printer_manager import ( from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client from backend.app.services.tasmota import tasmota_service -from backend.app.services.telemetry import start_telemetry_loop # Track active prints: {(printer_id, filename): archive_id} _active_prints: dict[tuple[int, str], int] = {} @@ -2071,9 +2070,6 @@ async def lifespan(app: FastAPI): # Start printer runtime tracking start_runtime_tracking() - # Start anonymous telemetry (opt-out via settings) - asyncio.create_task(start_telemetry_loop(async_session)) - # Initialize virtual printer manager from backend.app.services.virtual_printer import virtual_printer_manager diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 899de39a4..80d65b04a 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -53,9 +53,6 @@ class AppSettings(BaseModel): # Default printer for operations default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.") - # Telemetry - telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy") - # Virtual Printer virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads") virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication") @@ -141,7 +138,6 @@ class AppSettingsUpdate(BaseModel): date_format: str | None = None time_format: str | None = None default_printer_id: int | None = None - telemetry_enabled: bool | None = None virtual_printer_enabled: bool | None = None virtual_printer_access_code: str | None = None virtual_printer_mode: str | None = None diff --git a/backend/app/services/telemetry.py b/backend/app/services/telemetry.py deleted file mode 100644 index 233b61996..000000000 --- a/backend/app/services/telemetry.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Anonymous telemetry service for BamBuddy.""" - -import asyncio -import logging -import uuid -from datetime import datetime, timedelta - -import httpx -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from backend.app.core.config import APP_VERSION -from backend.app.models.printer import Printer -from backend.app.models.settings import Settings - -logger = logging.getLogger(__name__) - -# Default telemetry server URL (can be overridden via settings) -DEFAULT_TELEMETRY_URL = "https://telemetry.bambuddy.cool" - -# How often to send heartbeats (once per day) -HEARTBEAT_INTERVAL = timedelta(hours=24) - -_last_heartbeat: datetime | None = None - - -async def get_or_create_installation_id(db: AsyncSession) -> str: - """Get existing installation ID or create a new one.""" - result = await db.execute(select(Settings).where(Settings.key == "installation_id")) - setting = result.scalar_one_or_none() - - if setting: - return setting.value - - # Generate new UUID - installation_id = str(uuid.uuid4()) - - # Save to database - new_setting = Settings(key="installation_id", value=installation_id) - db.add(new_setting) - await db.commit() - - logger.info(f"Generated new installation ID: {installation_id[:8]}...") - return installation_id - - -async def is_telemetry_enabled(db: AsyncSession) -> bool: - """Check if telemetry is enabled (opt-out model).""" - result = await db.execute(select(Settings).where(Settings.key == "telemetry_enabled")) - setting = result.scalar_one_or_none() - - # Default to enabled (opt-out model) - if not setting: - return True - - return setting.value.lower() == "true" - - -async def get_telemetry_url(db: AsyncSession) -> str: - """Get telemetry server URL from settings.""" - result = await db.execute(select(Settings).where(Settings.key == "telemetry_url")) - setting = result.scalar_one_or_none() - - return setting.value if setting else DEFAULT_TELEMETRY_URL - - -async def get_printer_model_counts(db: AsyncSession) -> dict[str, int]: - """Get count of each printer model configured in BamBuddy.""" - result = await db.execute(select(Printer.model, func.count(Printer.id)).group_by(Printer.model)) - counts = {} - for model, count in result.all(): - # Normalize model name (handle None/empty) - model_name = model if model else "Unknown" - counts[model_name] = count - return counts - - -async def send_heartbeat(db: AsyncSession) -> bool: - """Send anonymous heartbeat to telemetry server.""" - global _last_heartbeat - - try: - # Check if telemetry is enabled - if not await is_telemetry_enabled(db): - logger.debug("Telemetry disabled, skipping heartbeat") - return False - - # Rate limit: only send once per day - if _last_heartbeat and datetime.now() - _last_heartbeat < HEARTBEAT_INTERVAL: - logger.debug("Heartbeat already sent recently, skipping") - return True - - installation_id = await get_or_create_installation_id(db) - telemetry_url = await get_telemetry_url(db) - printer_models = await get_printer_model_counts(db) - - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.post( - f"{telemetry_url}/heartbeat", - json={ - "installation_id": installation_id, - "version": APP_VERSION, - "printer_models": printer_models, - }, - ) - response.raise_for_status() - - _last_heartbeat = datetime.now() - logger.info(f"Telemetry heartbeat sent to {telemetry_url}") - return True - - except httpx.HTTPError as e: - logger.debug(f"Telemetry heartbeat failed (network): {e}") - return False - except Exception as e: - logger.debug(f"Telemetry heartbeat failed: {e}") - return False - - -async def start_telemetry_loop(get_session): - """Background task to send periodic heartbeats.""" - # Wait a bit before first heartbeat to let app initialize - await asyncio.sleep(30) - - while True: - try: - async with get_session() as db: - await send_heartbeat(db) - except Exception as e: - logger.debug(f"Telemetry loop error: {e}") - - # Check daily - await asyncio.sleep(HEARTBEAT_INTERVAL.total_seconds()) diff --git a/backend/tests/unit/services/test_telemetry.py b/backend/tests/unit/services/test_telemetry.py deleted file mode 100644 index dadd40ba9..000000000 --- a/backend/tests/unit/services/test_telemetry.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Unit tests for Telemetry service. - -Tests the anonymous telemetry/stats collection functionality. -""" - -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from backend.app.models.settings import Settings -from backend.app.services.telemetry import ( - DEFAULT_TELEMETRY_URL, - HEARTBEAT_INTERVAL, - _last_heartbeat, - get_or_create_installation_id, - get_telemetry_url, - is_telemetry_enabled, - send_heartbeat, -) - - -class TestTelemetryService: - """Tests for telemetry service functions.""" - - # ======================================================================== - # Installation ID Tests - # ======================================================================== - - @pytest.mark.asyncio - async def test_get_or_create_installation_id_creates_new(self, db_session): - """Verify new installation ID is created when none exists.""" - installation_id = await get_or_create_installation_id(db_session) - - assert installation_id is not None - assert len(installation_id) == 36 # UUID format - assert "-" in installation_id - - @pytest.mark.asyncio - async def test_get_or_create_installation_id_returns_existing(self, db_session): - """Verify existing installation ID is returned.""" - # Create an existing installation ID - existing_id = "test-uuid-1234-5678-abcd" - setting = Settings(key="installation_id", value=existing_id) - db_session.add(setting) - await db_session.commit() - - result = await get_or_create_installation_id(db_session) - - assert result == existing_id - - @pytest.mark.asyncio - async def test_get_or_create_installation_id_persists(self, db_session): - """Verify created installation ID persists in database.""" - first_id = await get_or_create_installation_id(db_session) - second_id = await get_or_create_installation_id(db_session) - - assert first_id == second_id - - # ======================================================================== - # Telemetry Enabled Tests - # ======================================================================== - - @pytest.mark.asyncio - async def test_is_telemetry_enabled_default_true(self, db_session): - """Verify telemetry is enabled by default (opt-out model).""" - result = await is_telemetry_enabled(db_session) - - assert result is True - - @pytest.mark.asyncio - async def test_is_telemetry_enabled_explicit_true(self, db_session): - """Verify telemetry enabled when explicitly set to true.""" - setting = Settings(key="telemetry_enabled", value="true") - db_session.add(setting) - await db_session.commit() - - result = await is_telemetry_enabled(db_session) - - assert result is True - - @pytest.mark.asyncio - async def test_is_telemetry_enabled_explicit_false(self, db_session): - """Verify telemetry disabled when set to false.""" - setting = Settings(key="telemetry_enabled", value="false") - db_session.add(setting) - await db_session.commit() - - result = await is_telemetry_enabled(db_session) - - assert result is False - - @pytest.mark.asyncio - async def test_is_telemetry_enabled_case_insensitive(self, db_session): - """Verify telemetry enabled check is case insensitive.""" - setting = Settings(key="telemetry_enabled", value="TRUE") - db_session.add(setting) - await db_session.commit() - - result = await is_telemetry_enabled(db_session) - - assert result is True - - # ======================================================================== - # Telemetry URL Tests - # ======================================================================== - - @pytest.mark.asyncio - async def test_get_telemetry_url_default(self, db_session): - """Verify default telemetry URL is returned when not configured.""" - result = await get_telemetry_url(db_session) - - assert result == DEFAULT_TELEMETRY_URL - - @pytest.mark.asyncio - async def test_get_telemetry_url_custom(self, db_session): - """Verify custom telemetry URL is returned when configured.""" - custom_url = "https://custom.telemetry.example.com" - setting = Settings(key="telemetry_url", value=custom_url) - db_session.add(setting) - await db_session.commit() - - result = await get_telemetry_url(db_session) - - assert result == custom_url - - # ======================================================================== - # Send Heartbeat Tests - # ======================================================================== - - @pytest.mark.asyncio - async def test_send_heartbeat_when_disabled(self, db_session): - """Verify heartbeat is not sent when telemetry is disabled.""" - setting = Settings(key="telemetry_enabled", value="false") - db_session.add(setting) - await db_session.commit() - - with patch("httpx.AsyncClient") as mock_client: - result = await send_heartbeat(db_session) - - assert result is False - mock_client.assert_not_called() - - @pytest.mark.asyncio - async def test_send_heartbeat_success(self, db_session, mock_httpx_client): - """Verify heartbeat is sent successfully when enabled.""" - # Reset the last heartbeat to allow sending - import backend.app.services.telemetry as telemetry_module - - telemetry_module._last_heartbeat = None - - result = await send_heartbeat(db_session) - - assert result is True - - @pytest.mark.asyncio - async def test_send_heartbeat_rate_limited(self, db_session): - """Verify heartbeat is rate limited to once per day.""" - import backend.app.services.telemetry as telemetry_module - - # Set last heartbeat to recent time - telemetry_module._last_heartbeat = datetime.now() - - with patch("httpx.AsyncClient") as mock_client: - result = await send_heartbeat(db_session) - - # Should return True (already sent) without making HTTP request - assert result is True - mock_client.assert_not_called() - - @pytest.mark.asyncio - async def test_send_heartbeat_handles_exceptions(self, db_session): - """Verify heartbeat returns False on general exceptions.""" - import backend.app.services.telemetry as telemetry_module - - telemetry_module._last_heartbeat = None - - # Test that the function handles exceptions gracefully by checking - # the code path - the actual telemetry URL may or may not be reachable - # The function should not raise exceptions to the caller - try: - result = await send_heartbeat(db_session) - # Result can be True (success) or False (failure) but should not raise - assert isinstance(result, bool) - except Exception as e: - pytest.fail(f"send_heartbeat should not raise exceptions: {e}") - - @pytest.mark.asyncio - async def test_send_heartbeat_sends_correct_data(self, db_session): - """Verify heartbeat sends correct payload.""" - import backend.app.services.telemetry as telemetry_module - from backend.app.core.config import APP_VERSION - - telemetry_module._last_heartbeat = None - - captured_data = {} - - with patch("httpx.AsyncClient") as mock_class: - mock_instance = AsyncMock() - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - - async def capture_post(url, json=None): - captured_data["url"] = url - captured_data["json"] = json - return mock_response - - mock_instance.post = capture_post - mock_instance.__aenter__ = AsyncMock(return_value=mock_instance) - mock_instance.__aexit__ = AsyncMock() - mock_class.return_value = mock_instance - - await send_heartbeat(db_session) - - assert "heartbeat" in captured_data["url"] - assert "installation_id" in captured_data["json"] - assert captured_data["json"]["version"] == APP_VERSION - - -class TestHeartbeatInterval: - """Tests for heartbeat interval configuration.""" - - def test_heartbeat_interval_is_24_hours(self): - """Verify heartbeat interval is set to 24 hours.""" - assert timedelta(hours=24) == HEARTBEAT_INTERVAL - - def test_default_telemetry_url(self): - """Verify default telemetry URL is correct.""" - assert DEFAULT_TELEMETRY_URL == "https://telemetry.bambuddy.cool" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 99a788887..f7975aba3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -648,8 +648,6 @@ export interface AppSettings { time_format: 'system' | '12h' | '24h'; // Default printer default_printer_id: number | null; - // Telemetry - telemetry_enabled: boolean; // Dark mode theme settings dark_style: 'classic' | 'glow' | 'vibrant'; dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest'; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 8f1464c51..4363ec183 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -244,21 +244,6 @@ export default { latestVersion: 'Neueste Version', upToDate: 'Sie sind auf dem neuesten Stand', updateAvailable: 'Update verfügbar', - telemetry: 'Anonyme Telemetrie', - telemetryDescription: 'Helfen Sie BamBuddy zu verbessern, indem Sie anonyme Nutzungsdaten senden', - telemetryLearnMore: 'Mehr erfahren', - telemetryInfoTitle: 'Welche Daten werden gesammelt?', - telemetryInfoIntro: 'BamBuddy sammelt minimale anonyme Daten, um zu verstehen, wie viele Personen die App nutzen und welche Versionen verwendet werden. Dies hilft bei der Priorisierung von Fehlerbehebungen und neuen Funktionen.', - telemetryInfoCollected: 'Was wir sammeln:', - telemetryInfoItem1: 'Eine zufällige Installations-ID (nicht mit Ihnen oder Ihrer Hardware verknüpft)', - telemetryInfoItem2: 'Die App-Version, die Sie verwenden', - telemetryInfoItem3: 'Ein Zeitstempel (um tägliche/wöchentliche aktive Nutzer zu zählen)', - telemetryInfoNotCollected: 'Was wir NICHT sammeln:', - telemetryInfoNotItem1: 'IP-Adressen oder Standortdaten', - telemetryInfoNotItem2: 'Druckernamen, Seriennummern oder Druckerdaten', - telemetryInfoNotItem3: 'Druckverlauf, Dateinamen oder persönliche Inhalte', - telemetryInfoNotItem4: 'Informationen, die Sie identifizieren könnten', - telemetryInfoFooter: 'Sie können die Telemetrie jederzeit deaktivieren. Die Installations-ID wird zufällig generiert und kann nicht zu Ihnen zurückverfolgt werden.', // Notifications notificationLanguage: 'Benachrichtigungssprache', notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2bdc2c962..6e1b50810 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -244,22 +244,6 @@ export default { latestVersion: 'Latest Version', upToDate: 'You are up to date', updateAvailable: 'Update available', - telemetry: 'Anonymous telemetry', - telemetryDescription: 'Help improve BamBuddy by sending anonymous usage data', - telemetryLearnMore: 'Learn more', - telemetryInfoTitle: 'What data is collected?', - telemetryInfoIntro: 'BamBuddy collects minimal anonymous data to help understand how many people use the app, which versions and printer models are in use. This helps prioritize bug fixes and new features.', - telemetryInfoCollected: 'What we collect:', - telemetryInfoItem1: 'A random installation ID (not linked to you or your hardware)', - telemetryInfoItem2: 'The app version you\'re running', - telemetryInfoItem3: 'Printer model types (e.g., X1C, P1S) - not names or serial numbers', - telemetryInfoItem4: 'A timestamp (to count daily/weekly active users)', - telemetryInfoNotCollected: 'What we do NOT collect:', - telemetryInfoNotItem1: 'Your IP address is hashed and cannot be reversed', - telemetryInfoNotItem2: 'Printer names, serial numbers, or access codes', - telemetryInfoNotItem3: 'Print history, filenames, or any personal content', - telemetryInfoNotItem4: 'Any information that could identify you', - telemetryInfoFooter: 'You can disable telemetry at any time. The installation ID is randomly generated and cannot be traced back to you.', // Notifications notificationLanguage: 'Notification Language', notificationLanguageDescription: 'Language for push notifications', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index d13ab0854..79bba52bc 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react'; +import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { api } from '../api/client'; @@ -87,7 +87,6 @@ export function SettingsPage() { const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null); const [showBackupModal, setShowBackupModal] = useState(false); const [showRestoreModal, setShowRestoreModal] = useState(false); - const [showTelemetryInfo, setShowTelemetryInfo] = useState(false); const [showReleaseNotes, setShowReleaseNotes] = useState(false); const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false); @@ -400,7 +399,6 @@ export function SettingsPage() { settings.energy_tracking_mode !== localSettings.energy_tracking_mode || settings.check_updates !== localSettings.check_updates || settings.notification_language !== localSettings.notification_language || - settings.telemetry_enabled !== localSettings.telemetry_enabled || settings.ams_humidity_good !== localSettings.ams_humidity_good || settings.ams_humidity_fair !== localSettings.ams_humidity_fair || settings.ams_temp_good !== localSettings.ams_temp_good || @@ -461,7 +459,6 @@ export function SettingsPage() { energy_tracking_mode: localSettings.energy_tracking_mode, check_updates: localSettings.check_updates, notification_language: localSettings.notification_language, - telemetry_enabled: localSettings.telemetry_enabled, ams_humidity_good: localSettings.ams_humidity_good, ams_humidity_fair: localSettings.ams_humidity_fair, ams_temp_good: localSettings.ams_temp_good, @@ -1237,33 +1234,6 @@ export function SettingsPage() {
-{t('settings.telemetry')}
- -- {t('settings.telemetryDescription')} -
-- {t('settings.telemetryInfoIntro')} -
- -- {t('settings.telemetryInfoFooter')} -
- - -_n?(Ln=Xt,Xt=null):Ln=Xt.sibling;var Gn=st(Ye,Xt,it[_n],gt);if(Gn===null){Xt===null&&(Xt=Ln);break}h&&Xt&&Gn.alternate===null&&g(Ye,Xt),Ie=I(Gn,Ie,_n),Vn===null?Jt=Gn:Vn.sibling=Gn,Vn=Gn,Xt=Ln}if(_n===it.length)return _(Ye,Xt),Fn&&pc(Ye,_n),Jt;if(Xt===null){for(;_n