diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6f70cd8..894f6dc9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,25 @@ All notable changes to Bambuddy will be documented in this file. - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme - Thumbnails auto-refresh in UI after generation - Graceful handling of complex/invalid STL files +- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164): + - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay + - Real-time print progress, ETA, layer count, and filename display + - Bambuddy logo branding (links to GitHub) + - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer` + - No authentication required - designed for OBS browser source embedding + - Gradient overlay at bottom for readable text over camera feed + - Auto-reconnect on camera stream errors +- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173): + - New "MQTT" plug type alongside Tasmota and Home Assistant + - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.) + - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state + - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`) + - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh) + - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1") + - Monitor-only: displays power/energy data without control capabilities + - Reuses existing MQTT broker settings from Settings → Network + - Energy data included in statistics and per-print tracking + - Full backup/restore support for MQTT plug configurations - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks: - Prevents Bambuddy from checking Bambu Lab servers for firmware updates - Useful for users who prefer to manage firmware manually or have network restrictions @@ -106,24 +125,6 @@ All notable changes to Bambuddy will be documented in this file. - Bulk edit: printer assignment, print options, queue options - Bulk cancel selected items - Tri-state toggles: unchanged / on / off for each setting -- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162): - - Extract printer model from sliced 3MF files (e.g., "X1C", "P1S") - - Display sliced-for model in archive view - - New queue mode: assign to model instead of specific printer - - Scheduler auto-assigns to first idle printer of matching model - - Filament validation: only assign to printers with required filament types loaded - - Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)") - - "Waiting" status badge (purple) distinguishes from regular "Pending" - - Compatibility warnings when file/printer model mismatch -- **Queue Notifications** - Get notified about print queue events: - - Job Added: When a job is added to the queue - - Job Assigned: When a model-based job is assigned to a printer - - Job Started: When a queue job starts printing - - Job Waiting: When a job is waiting for filament (enabled by default) - - Job Skipped: When a job is skipped due to previous failure (enabled by default) - - Job Failed: When a job fails to start (enabled by default) - - Queue Complete: When all queued jobs finish - - New "Print Queue" section in notification provider settings ### Fixes - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166): diff --git a/README.md b/README.md index e39468774..2e5c1a24c 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,12 @@ - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support) - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates) - Archive comparison (side-by-side diff) +- Tag management (rename/delete across all archives) ### 📊 Monitoring & Control - Real-time printer status via WebSocket - Live camera streaming (MJPEG) & snapshots with multi-viewer support +- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`) - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment) - Fan status monitoring (part cooling, auxiliary, chamber) @@ -80,7 +82,8 @@ - Per-printer AMS mapping (individual slot configuration for print farms) - Scheduled prints (date/time) - Queue Only mode (stage without auto-start) -- Smart plug integration (Tasmota, Home Assistant) +- Smart plug integration (Tasmota, Home Assistant, MQTT) +- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring - Energy consumption tracking (per-print kWh and cost) - HA energy sensor support (for plugs with separate power/energy sensors) - Auto power-on before print diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 85241c422..b19855f21 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -515,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): if energy_tracking_mode == "total": # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption) from backend.app.models.smart_plug import SmartPlug + from backend.app.services.homeassistant import homeassistant_service + from backend.app.services.mqtt_relay import mqtt_relay from backend.app.services.tasmota import tasmota_service plugs_result = await db.execute(select(SmartPlug)) @@ -522,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_energy_kwh = 0.0 for plug in plugs: - energy = await tasmota_service.get_energy(plug) - if energy and energy.get("total") is not None: - total_energy_kwh += energy["total"] + if plug.plug_type == "tasmota": + energy = await tasmota_service.get_energy(plug) + if energy and energy.get("total") is not None: + total_energy_kwh += energy["total"] + elif plug.plug_type == "homeassistant": + energy = await homeassistant_service.get_energy(plug) + if energy and energy.get("total") is not None: + total_energy_kwh += energy["total"] + elif plug.plug_type == "mqtt": + # MQTT plugs report "today" energy, not lifetime total + mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id) + if mqtt_data and mqtt_data.energy is not None: + total_energy_kwh += mqtt_data.energy total_energy_kwh = round(total_energy_kwh, 3) total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2) @@ -552,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): ) +@router.get("/tags") +async def get_all_tags(db: AsyncSession = Depends(get_db)): + """List all unique tags with usage counts. + + Returns a list of tags sorted by count (descending), then by name. + """ + # Query all archives with non-null tags + result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None))) + all_tags_rows = result.all() + + # Count occurrences of each tag + tag_counts: dict[str, int] = {} + for (tags_str,) in all_tags_rows: + if tags_str: + for tag in tags_str.split(","): + tag = tag.strip() + if tag: + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + # Convert to list and sort by count (desc), then name (asc) + tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()] + tags_list.sort(key=lambda x: (-x["count"], x["name"].lower())) + + return tags_list + + +@router.put("/tags/{tag_name}") +async def rename_tag( + tag_name: str, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Rename a tag across all archives. + + Request body should contain {"new_name": "new tag name"}. + Returns the count of affected archives. + """ + body = await request.json() + new_name = body.get("new_name", "").strip() + + if not new_name: + raise HTTPException(400, "new_name is required") + + if new_name == tag_name: + return {"affected": 0} + + # Find all archives containing the old tag + result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None))) + archives = list(result.scalars().all()) + + affected = 0 + for archive in archives: + if not archive.tags: + continue + tags = [t.strip() for t in archive.tags.split(",")] + if tag_name in tags: + # Replace old tag with new tag + new_tags = [new_name if t == tag_name else t for t in tags] + # Remove duplicates while preserving order + seen = set() + unique_tags = [] + for t in new_tags: + if t not in seen: + seen.add(t) + unique_tags.append(t) + archive.tags = ", ".join(unique_tags) + affected += 1 + + await db.commit() + return {"affected": affected} + + +@router.delete("/tags/{tag_name}") +async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)): + """Delete a tag from all archives. + + Returns the count of affected archives. + """ + # Find all archives containing the tag + result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None))) + archives = list(result.scalars().all()) + + affected = 0 + for archive in archives: + if not archive.tags: + continue + tags = [t.strip() for t in archive.tags.split(",")] + if tag_name in tags: + # Remove the tag + new_tags = [t for t in tags if t != tag_name] + archive.tags = ", ".join(new_tags) if new_tags else None + affected += 1 + + await db.commit() + return {"affected": affected} + + @router.get("/{archive_id}", response_model=ArchiveResponse) async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): """Get a specific archive.""" diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index db314639b..5b0a3dd45 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -369,6 +369,21 @@ async def export_backup( "ha_power_entity": plug.ha_power_entity, "ha_energy_today_entity": plug.ha_energy_today_entity, "ha_energy_total_entity": plug.ha_energy_total_entity, + # MQTT plug fields (legacy) + "mqtt_topic": plug.mqtt_topic, + "mqtt_multiplier": plug.mqtt_multiplier, + # MQTT power fields + "mqtt_power_topic": plug.mqtt_power_topic, + "mqtt_power_path": plug.mqtt_power_path, + "mqtt_power_multiplier": plug.mqtt_power_multiplier, + # MQTT energy fields + "mqtt_energy_topic": plug.mqtt_energy_topic, + "mqtt_energy_path": plug.mqtt_energy_path, + "mqtt_energy_multiplier": plug.mqtt_energy_multiplier, + # MQTT state fields + "mqtt_state_topic": plug.mqtt_state_topic, + "mqtt_state_path": plug.mqtt_state_path, + "mqtt_state_on_value": plug.mqtt_state_on_value, "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, "enabled": plug.enabled, "auto_on": plug.auto_on, @@ -385,6 +400,7 @@ async def export_backup( "schedule_on_time": plug.schedule_on_time, "schedule_off_time": plug.schedule_off_time, "show_in_switchbar": plug.show_in_switchbar, + "show_on_printer_card": plug.show_on_printer_card, } ) backup["included"].append("smart_plugs") @@ -1300,12 +1316,23 @@ async def import_backup( # Determine plug type (default to tasmota for backwards compatibility) plug_type = plug_data.get("plug_type", "tasmota") - # Find existing plug by IP (Tasmota) or entity_id (Home Assistant) + # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT) existing = None + plug_identifier = None if plug_type == "homeassistant" and plug_data.get("ha_entity_id"): result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"])) existing = result.scalar_one_or_none() plug_identifier = plug_data["ha_entity_id"] + elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")): + # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy) + power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic") + result = await db.execute( + select(SmartPlug).where( + (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic) + ) + ) + existing = result.scalar_one_or_none() + plug_identifier = power_topic elif plug_data.get("ip_address"): result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) existing = result.scalar_one_or_none() @@ -1322,6 +1349,21 @@ async def import_backup( existing.ha_power_entity = plug_data.get("ha_power_entity") existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity") existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity") + # MQTT fields (legacy) + existing.mqtt_topic = plug_data.get("mqtt_topic") + existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0) + # MQTT power fields + existing.mqtt_power_topic = plug_data.get("mqtt_power_topic") + existing.mqtt_power_path = plug_data.get("mqtt_power_path") + existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0) + # MQTT energy fields + existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic") + existing.mqtt_energy_path = plug_data.get("mqtt_energy_path") + existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0) + # MQTT state fields + existing.mqtt_state_topic = plug_data.get("mqtt_state_topic") + existing.mqtt_state_path = plug_data.get("mqtt_state_path") + existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value") existing.printer_id = printer_id existing.enabled = plug_data.get("enabled", True) existing.auto_on = plug_data.get("auto_on", True) @@ -1338,6 +1380,7 @@ async def import_backup( existing.schedule_on_time = plug_data.get("schedule_on_time") existing.schedule_off_time = plug_data.get("schedule_off_time") existing.show_in_switchbar = plug_data.get("show_in_switchbar", False) + existing.show_on_printer_card = plug_data.get("show_on_printer_card", True) restored["smart_plugs"] += 1 else: skipped["smart_plugs"] += 1 @@ -1351,6 +1394,21 @@ async def import_backup( ha_power_entity=plug_data.get("ha_power_entity"), ha_energy_today_entity=plug_data.get("ha_energy_today_entity"), ha_energy_total_entity=plug_data.get("ha_energy_total_entity"), + # MQTT fields (legacy) + mqtt_topic=plug_data.get("mqtt_topic"), + mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0), + # MQTT power fields + mqtt_power_topic=plug_data.get("mqtt_power_topic"), + mqtt_power_path=plug_data.get("mqtt_power_path"), + mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0), + # MQTT energy fields + mqtt_energy_topic=plug_data.get("mqtt_energy_topic"), + mqtt_energy_path=plug_data.get("mqtt_energy_path"), + mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0), + # MQTT state fields + mqtt_state_topic=plug_data.get("mqtt_state_topic"), + mqtt_state_path=plug_data.get("mqtt_state_path"), + mqtt_state_on_value=plug_data.get("mqtt_state_on_value"), printer_id=printer_id, enabled=plug_data.get("enabled", True), auto_on=plug_data.get("auto_on", True), @@ -1367,6 +1425,7 @@ async def import_backup( schedule_on_time=plug_data.get("schedule_on_time"), schedule_off_time=plug_data.get("schedule_off_time"), show_in_switchbar=plug_data.get("show_in_switchbar", False), + show_on_printer_card=plug_data.get("show_on_printer_card", True), ) db.add(plug) restored["smart_plugs"] += 1 diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 1a73338cf..79732a104 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import ( ) from backend.app.services.discovery import tasmota_scanner from backend.app.services.homeassistant import homeassistant_service +from backend.app.services.mqtt_relay import mqtt_relay from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import printer_manager from backend.app.services.tasmota import tasmota_service @@ -56,16 +57,84 @@ async def create_smart_plug( raise HTTPException(400, "Printer not found") # Check if printer already has a plug assigned - result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + # Scripts can coexist with other plugs (they're for multi-device control, not power on/off) + is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.") + if not is_script: + # For non-script plugs, check there's no other non-script plug assigned + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") + + # For MQTT plugs, ensure MQTT broker is configured and service is connected + if data.plug_type == "mqtt": + # Try to configure the smart plug service if not already configured + if not mqtt_relay.smart_plug_service.is_configured(): + # Get MQTT broker settings from database + mqtt_broker = await get_setting(db, "mqtt_broker") or "" + if not mqtt_broker: + raise HTTPException( + 400, + "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.", + ) + + # Configure the smart plug service with broker settings + mqtt_settings = { + "mqtt_enabled": True, # Enable for smart plug subscription + "mqtt_broker": mqtt_broker, + "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"), + "mqtt_username": await get_setting(db, "mqtt_username") or "", + "mqtt_password": await get_setting(db, "mqtt_password") or "", + "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true", + } + await mqtt_relay.smart_plug_service.configure(mqtt_settings) + + # Check if connection succeeded + if not mqtt_relay.smart_plug_service.is_configured(): + raise HTTPException( + 400, + f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.", + ) plug = SmartPlug(**data.model_dump()) db.add(plug) await db.commit() await db.refresh(plug) - if plug.plug_type == "homeassistant": + # Subscribe MQTT plugs to their topics + if plug.plug_type == "mqtt": + # Determine effective topics (new fields take priority, fall back to legacy) + power_topic = plug.mqtt_power_topic or plug.mqtt_topic + energy_topic = plug.mqtt_energy_topic + state_topic = plug.mqtt_state_topic + + # Only subscribe if at least one topic is configured + if power_topic or energy_topic or state_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + # Power source (path is optional) + power_topic=power_topic, + power_path=plug.mqtt_power_path, + power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0, + # Energy source (path is optional) + energy_topic=energy_topic, + energy_path=plug.mqtt_energy_path, + energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0, + # State source (path is optional) + state_topic=state_topic, + state_path=plug.mqtt_state_path, + state_on_value=plug.mqtt_state_on_value, + ) + topics = [t for t in [power_topic, energy_topic, state_topic] if t] + logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}") + elif plug.plug_type == "homeassistant": logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})") else: logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}") @@ -74,12 +143,48 @@ async def create_smart_plug( @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None) async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): - """Get the smart plug assigned to a printer.""" + """Get the main smart plug assigned to a printer. + + When multiple plugs are assigned (e.g., a regular plug + script), + returns the main (non-script) plug for power control. + """ result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plug = result.scalar_one_or_none() - if not plug: + plugs = result.scalars().all() + + if not plugs: return None - return plug + + # If multiple plugs, prefer the non-script one (main power plug) + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + return plug + + # All are scripts, return the first one + return plugs[0] + + +@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse]) +async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get all HA script plugs assigned to a printer. + + Returns only script entities (script.*) for the printer that have + show_on_printer_card enabled. + Used to display "Run Script" buttons alongside the main power plug. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Filter to only scripts with show_on_printer_card enabled + scripts = [ + plug + for plug in plugs + if plug.plug_type == "homeassistant" + and plug.ha_entity_id + and plug.ha_entity_id.startswith("script.") + and plug.show_on_printer_card + ] + return scripts # Tasmota Discovery Endpoints @@ -287,14 +392,43 @@ async def update_smart_plug( raise HTTPException(400, "Printer not found") # Check if that printer already has a different plug assigned - result = await db.execute( - select(SmartPlug).where( - SmartPlug.printer_id == new_printer_id, - SmartPlug.id != plug_id, + # Scripts can coexist with other plugs + # Determine if the plug being updated is/will be a script + new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id) + new_plug_type = update_data.get("plug_type", plug.plug_type) + is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.") + + if not is_script: + result = await db.execute( + select(SmartPlug).where( + SmartPlug.printer_id == new_printer_id, + SmartPlug.id != plug_id, + ) ) - ) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") + + # Track old MQTT settings for comparison + old_plug_type = plug.plug_type + old_mqtt_config = { + "power_topic": plug.mqtt_power_topic or plug.mqtt_topic, + "power_path": plug.mqtt_power_path, + "power_multiplier": plug.mqtt_power_multiplier, + "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic, + "energy_path": plug.mqtt_energy_path, + "energy_multiplier": plug.mqtt_energy_multiplier, + "state_topic": plug.mqtt_state_topic or plug.mqtt_topic, + "state_path": plug.mqtt_state_path, + "state_on_value": plug.mqtt_state_on_value, + } for field, value in update_data.items(): setattr(plug, field, value) @@ -302,6 +436,54 @@ async def update_smart_plug( await db.commit() await db.refresh(plug) + # Handle MQTT subscription changes + if old_plug_type == "mqtt" and plug.plug_type != "mqtt": + # Changed away from MQTT - unsubscribe + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + elif plug.plug_type == "mqtt": + # Check if any MQTT config changed + new_mqtt_config = { + "power_topic": plug.mqtt_power_topic or plug.mqtt_topic, + "power_path": plug.mqtt_power_path, + "power_multiplier": plug.mqtt_power_multiplier, + "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic, + "energy_path": plug.mqtt_energy_path, + "energy_multiplier": plug.mqtt_energy_multiplier, + "state_topic": plug.mqtt_state_topic or plug.mqtt_topic, + "state_path": plug.mqtt_state_path, + "state_on_value": plug.mqtt_state_on_value, + } + + mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config + + if mqtt_changed: + # Unsubscribe from old topics first + if old_plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug.id) + + # Subscribe to new topics + power_topic = plug.mqtt_power_topic or plug.mqtt_topic + energy_topic = plug.mqtt_energy_topic + state_topic = plug.mqtt_state_topic + + # Only subscribe if at least one topic is configured + if power_topic or energy_topic or state_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + # Power source (path is optional) + power_topic=power_topic, + power_path=plug.mqtt_power_path, + power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0, + # Energy source (path is optional) + energy_topic=energy_topic, + energy_path=plug.mqtt_energy_path, + energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0, + # State source (path is optional) + state_topic=state_topic, + state_path=plug.mqtt_state_path, + state_on_value=plug.mqtt_state_on_value, + ) + logger.info(f"Updated smart plug '{plug.name}'") return plug @@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)): raise HTTPException(404, "Smart plug not found") plug_name = plug.name + plug_type = plug.plug_type + + # Unsubscribe MQTT plug before deletion + if plug_type == "mqtt": + mqtt_relay.smart_plug_service.unsubscribe(plug_id) + await db.delete(plug) await db.commit() @@ -348,6 +536,13 @@ async def control_smart_plug( if not plug: raise HTTPException(404, "Smart plug not found") + # MQTT plugs are monitor-only - cannot control them + if plug.plug_type == "mqtt": + raise HTTPException( + 400, + "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.", + ) + service = await _get_service_for_plug(plug, db) if control.action == "on": @@ -376,6 +571,13 @@ async def control_smart_plug( plug.last_checked = datetime.utcnow() await db.commit() + # Trigger associated scripts if this is a main (non-script) plug + is_main_plug = not ( + plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + ) + if is_main_plug and plug.printer_id and expected_state: + await trigger_associated_scripts(plug.printer_id, expected_state, db) + # MQTT relay - publish smart plug state change if expected_state: try: @@ -401,6 +603,37 @@ async def control_smart_plug( return {"success": True, "action": control.action} +async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession): + """Trigger scripts linked to a printer based on main plug state change. + + When the main plug turns ON, triggers scripts with auto_on=True. + When the main plug turns OFF, triggers scripts with auto_off=True. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Find scripts that should be triggered + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + continue + + should_trigger = False + if plug_state == "ON" and plug.auto_on: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-on") + elif plug_state == "OFF" and plug.auto_off: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-off") + + if should_trigger: + try: + service = await _get_service_for_plug(plug, db) + await service.turn_on(plug) # Scripts are triggered by calling turn_on + except Exception as e: + logger.error(f"Failed to trigger script '{plug.name}': {e}") + + @router.get("/{plug_id}/status", response_model=SmartPlugStatus) async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): """Get current plug status from device including energy data.""" @@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): if not plug: raise HTTPException(404, "Smart plug not found") + # Handle MQTT plugs - get data from subscription service + if plug.plug_type == "mqtt": + data = mqtt_relay.smart_plug_service.get_plug_data(plug_id) + is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id) + + if data: + # Update last state in database + if is_reachable and data.state: + plug.last_state = data.state + plug.last_checked = datetime.utcnow() + await db.commit() + + energy_data = None + if data.power is not None or data.energy is not None: + energy_data = SmartPlugEnergy( + power=data.power, + today=data.energy, + ) + # Check power alerts + if data.power is not None: + await check_power_alerts(plug, data.power, db) + + return SmartPlugStatus( + state=data.state, + reachable=is_reachable, + device_name=None, + energy=energy_data, + ) + + # No data received yet + return SmartPlugStatus( + state=None, + reachable=False, + device_name=None, + energy=None, + ) + + # Handle Tasmota/HomeAssistant plugs service = await _get_service_for_plug(plug, db) status = await service.get_status(plug) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 9997226c3..e3fca7c70 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -764,45 +764,139 @@ async def run_migrations(conn): except Exception: pass - # Migration: Add sliced_for_model column to print_archives for printer model from 3MF + # Migration: Remove UNIQUE constraint from smart_plugs.printer_id + # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control) + # SQLite requires table recreation to drop constraints try: - await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")) + # Check if we need to migrate (if UNIQUE constraint exists) + result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'")) + row = result.fetchone() + if row and "printer_id INTEGER UNIQUE" in (row[0] or ""): + # Create new table without UNIQUE constraint on printer_id + await conn.execute( + text(""" + CREATE TABLE smart_plugs_temp ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL, + ip_address VARCHAR(45), + plug_type VARCHAR(20) DEFAULT 'tasmota', + ha_entity_id VARCHAR(100), + ha_power_entity VARCHAR(100), + ha_energy_today_entity VARCHAR(100), + ha_energy_total_entity VARCHAR(100), + printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + auto_on BOOLEAN NOT NULL DEFAULT 1, + auto_off BOOLEAN NOT NULL DEFAULT 1, + off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time', + off_delay_minutes INTEGER NOT NULL DEFAULT 5, + off_temp_threshold INTEGER NOT NULL DEFAULT 70, + username VARCHAR(50), + password VARCHAR(100), + power_alert_enabled BOOLEAN NOT NULL DEFAULT 0, + power_alert_high FLOAT, + power_alert_low FLOAT, + power_alert_last_triggered DATETIME, + schedule_enabled BOOLEAN NOT NULL DEFAULT 0, + schedule_on_time VARCHAR(5), + schedule_off_time VARCHAR(5), + show_in_switchbar BOOLEAN DEFAULT 0, + last_state VARCHAR(10), + last_checked DATETIME, + auto_off_executed BOOLEAN NOT NULL DEFAULT 0, + auto_off_pending BOOLEAN DEFAULT 0, + auto_off_pending_since DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + ) + # Copy data + await conn.execute( + text(""" + INSERT INTO smart_plugs_temp + SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity, + ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled, + auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold, + username, password, power_alert_enabled, power_alert_high, power_alert_low, + power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time, + show_in_switchbar, last_state, last_checked, auto_off_executed, + auto_off_pending, auto_off_pending_since, created_at, updated_at + FROM smart_plugs + """) + ) + # Drop old table and rename new one + await conn.execute(text("DROP TABLE smart_plugs")) + await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs")) except Exception: pass - # Migration: Add target_model column to print_queue for model-based assignment + # Migration: Add show_on_printer_card column to smart_plugs try: - await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)")) + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")) except Exception: pass - # Migration: Add required_filament_types column to print_queue for filament validation + # Migration: Add MQTT smart plug fields (legacy) try: - await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT")) + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")) except Exception: pass - # Migration: Add waiting_reason column to print_queue for status feedback + # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers) try: - await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT")) + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")) except Exception: pass - # Migration: Add queue notification event columns to notification_providers - queue_notification_columns = [ - ("on_queue_job_added", "BOOLEAN DEFAULT 0"), - ("on_queue_job_assigned", "BOOLEAN DEFAULT 0"), - ("on_queue_job_started", "BOOLEAN DEFAULT 0"), - ("on_queue_job_waiting", "BOOLEAN DEFAULT 1"), - ("on_queue_job_skipped", "BOOLEAN DEFAULT 1"), - ("on_queue_job_failed", "BOOLEAN DEFAULT 1"), - ("on_queue_completed", "BOOLEAN DEFAULT 0"), - ] - for col_name, col_def in queue_notification_columns: - try: - await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}")) - except Exception: - pass + # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility + try: + await conn.execute( + text(""" + UPDATE smart_plugs + SET mqtt_power_topic = mqtt_topic, + mqtt_power_multiplier = mqtt_multiplier + WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL + """) + ) + except Exception: + pass # Migration: Create groups table for permission-based access control try: diff --git a/backend/app/main.py b/backend/app/main.py index c8601dd93..d4920b859 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -249,9 +249,10 @@ _notified_hms_errors: dict[int, set[str]] = {} async def _get_plug_energy(plug, db) -> dict | None: - """Get energy from plug regardless of type (Tasmota or Home Assistant). + """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT). For HA plugs, configures the service with current settings from DB. + For MQTT plugs, returns data from the subscription service. """ if plug.plug_type == "homeassistant": from backend.app.api.routes.settings import get_setting @@ -260,6 +261,17 @@ async def _get_plug_energy(plug, db) -> dict | None: ha_token = await get_setting(db, "ha_token") or "" homeassistant_service.configure(ha_url, ha_token) return await homeassistant_service.get_energy(plug) + elif plug.plug_type == "mqtt": + # MQTT plugs report "today" energy, not lifetime total + # For per-print tracking, we use "today" as the counter (resets at midnight) + mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id) + if mqtt_data: + return { + "power": mqtt_data.power, + "today": mqtt_data.energy, + "total": mqtt_data.energy, # Use today as total for per-print calculations + } + return None else: return await tasmota_service.get_energy(plug) @@ -2396,6 +2408,27 @@ async def lifespan(app: FastAPI): } await mqtt_relay.configure(mqtt_settings) + # Restore MQTT smart plug subscriptions + if mqtt_settings.get("mqtt_enabled"): + from sqlalchemy import select + + from backend.app.models.smart_plug import SmartPlug + + result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt")) + mqtt_plugs = result.scalars().all() + for plug in mqtt_plugs: + if plug.mqtt_topic: + mqtt_relay.smart_plug_service.subscribe( + plug_id=plug.id, + topic=plug.mqtt_topic, + power_path=plug.mqtt_power_path, + energy_path=plug.mqtt_energy_path, + state_path=plug.mqtt_state_path, + multiplier=plug.mqtt_multiplier or 1.0, + ) + if mqtt_plugs: + logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions") + # Connect to all active printers async with async_session() as db: await init_printer_connections(db) diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index 2f13ebc38..1d9160ef9 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -7,7 +7,7 @@ from backend.app.core.database import Base class SmartPlug(Base): - """Smart plug for printer power control (Tasmota or Home Assistant).""" + """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT).""" __tablename__ = "smart_plugs" @@ -15,7 +15,7 @@ class SmartPlug(Base): name: Mapped[str] = mapped_column(String(100)) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv4/IPv6 (required for Tasmota) - # Plug type: "tasmota" (default) or "homeassistant" + # Plug type: "tasmota" (default), "homeassistant", or "mqtt" plug_type: Mapped[str] = mapped_column(String(20), default="tasmota") # Home Assistant entity ID (e.g., "switch.printer_plug") ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True) @@ -24,6 +24,32 @@ class SmartPlug(Base): ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total + # MQTT plug fields (required when plug_type="mqtt") + # Legacy field - kept for backward compatibility, now use mqtt_power_topic + mqtt_topic: Mapped[str | None] = mapped_column( + String(200), nullable=True + ) # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic) + + # Power monitoring + mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for power data + mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "power_l1" or "data.power" + mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for power + + # Energy monitoring + mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for energy data + mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "energy_l1" + mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Unit conversion for energy + + # State monitoring + mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True) # Topic for state data + mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True) # e.g., "state_l1" for ON/OFF + mqtt_state_on_value: Mapped[str | None] = mapped_column( + String(50), nullable=True + ) # What value means "ON" (e.g., "ON", "true", "1") + + # Legacy multiplier - kept for backward compatibility + mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0) # Deprecated, use mqtt_power_multiplier + # Link to printer (1:1) printer_id: Mapped[int | None] = mapped_column( ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True @@ -57,6 +83,9 @@ class SmartPlug(Base): # Switchbar visibility show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False) + # Printer card visibility (for scripts) + show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True) + # Status tracking last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF" last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index d60fac854..6f0960cb2 100644 --- a/backend/app/schemas/smart_plug.py +++ b/backend/app/schemas/smart_plug.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator class SmartPlugBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) - plug_type: Literal["tasmota", "homeassistant"] = "tasmota" + plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota" # Tasmota fields (required when plug_type="tasmota") ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") @@ -14,12 +14,36 @@ class SmartPlugBase(BaseModel): password: str | None = None # Home Assistant fields (required when plug_type="homeassistant") - ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$") + ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$") # Home Assistant energy sensor entities (optional, for separate energy sensors) ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") + # MQTT fields (required when plug_type="mqtt") + # Legacy field - kept for backward compatibility + mqtt_topic: str | None = Field(default=None, max_length=200) # Deprecated, use mqtt_power_topic + + # Power monitoring + mqtt_power_topic: str | None = Field(default=None, max_length=200) # Topic for power data + mqtt_power_path: str | None = Field(default=None, max_length=100) # e.g., "power_l1" or "data.power" + mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for power + + # Energy monitoring + mqtt_energy_topic: str | None = Field(default=None, max_length=200) # Topic for energy data + mqtt_energy_path: str | None = Field(default=None, max_length=100) # e.g., "energy_l1" + mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Unit conversion for energy + + # State monitoring + mqtt_state_topic: str | None = Field(default=None, max_length=200) # Topic for state data + mqtt_state_path: str | None = Field(default=None, max_length=100) # e.g., "state_l1" for ON/OFF + mqtt_state_on_value: str | None = Field( + default=None, max_length=50 + ) # What value means "ON" (e.g., "ON", "true", "1") + + # Legacy multiplier - kept for backward compatibility + mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000) # Deprecated, use mqtt_power_multiplier + printer_id: int | None = None enabled: bool = True auto_on: bool = True @@ -35,8 +59,9 @@ class SmartPlugBase(BaseModel): schedule_enabled: bool = False schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format - # Switchbar visibility + # Visibility options show_in_switchbar: bool = False + show_on_printer_card: bool = True # For scripts: show on printer card @model_validator(mode="after") def validate_plug_type_fields(self) -> "SmartPlugBase": @@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel): raise ValueError("ip_address is required for Tasmota plugs") if self.plug_type == "homeassistant" and not self.ha_entity_id: raise ValueError("ha_entity_id is required for Home Assistant plugs") + if self.plug_type == "mqtt": + # Determine the effective power topic (new field takes priority, fall back to legacy) + power_topic = self.mqtt_power_topic or self.mqtt_topic + # Path is optional - if not set, raw MQTT payload value will be used + has_power = bool(power_topic) + has_energy = bool(self.mqtt_energy_topic) + has_state = bool(self.mqtt_state_topic) + + # At least one data source must be configured (path is optional) + if not has_power and not has_energy and not has_state: + raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring") return self @@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase): class SmartPlugUpdate(BaseModel): name: str | None = None - plug_type: Literal["tasmota", "homeassistant"] | None = None + plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None ip_address: str | None = None ha_entity_id: str | None = None # Home Assistant energy sensor entities (optional) ha_power_entity: str | None = None ha_energy_today_entity: str | None = None ha_energy_total_entity: str | None = None + # MQTT fields (legacy) + mqtt_topic: str | None = None + mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT power fields + mqtt_power_topic: str | None = None + mqtt_power_path: str | None = None + mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT energy fields + mqtt_energy_topic: str | None = None + mqtt_energy_path: str | None = None + mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000) + # MQTT state fields + mqtt_state_topic: str | None = None + mqtt_state_path: str | None = None + mqtt_state_on_value: str | None = None printer_id: int | None = None enabled: bool | None = None auto_on: bool | None = None @@ -77,8 +128,9 @@ class SmartPlugUpdate(BaseModel): schedule_enabled: bool | None = None schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") - # Switchbar visibility + # Visibility options show_in_switchbar: bool | None = None + show_on_printer_card: bool | None = None class SmartPlugResponse(SmartPlugBase): @@ -147,7 +199,7 @@ class HAEntity(BaseModel): entity_id: str friendly_name: str state: str | None = None - domain: str # "switch", "light", "input_boolean" + domain: str # "switch", "light", "input_boolean", "script" class HASensorEntity(BaseModel): diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 38fca794b..45ba70920 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -228,7 +228,7 @@ class HomeAssistantService: - domain: str """ # Default domains for smart plug control - default_domains = {"switch", "light", "input_boolean"} + default_domains = {"switch", "light", "input_boolean", "script"} try: async with httpx.AsyncClient(timeout=self.timeout) as client: diff --git a/backend/app/services/mqtt_relay.py b/backend/app/services/mqtt_relay.py index 7bfa18366..f55657125 100644 --- a/backend/app/services/mqtt_relay.py +++ b/backend/app/services/mqtt_relay.py @@ -34,6 +34,8 @@ class MQTTRelayService: self._broker = "" self._port = 1883 self._last_printer_status: dict[int, float] = {} # printer_id -> last publish timestamp + self._smart_plug_service = None # Lazy import to avoid circular dependency + self._settings: dict = {} # Store settings for smart plug service async def configure(self, settings: dict) -> bool: """Configure MQTT connection from settings. @@ -41,9 +43,12 @@ class MQTTRelayService: Returns True if connection was successful or MQTT is disabled. """ self.enabled = settings.get("mqtt_enabled", False) + self._settings = settings # Store for smart plug service if not self.enabled: await self.disconnect() + # Also configure smart plug service (will disable it) + await self._configure_smart_plug_service(settings) logger.info("MQTT relay disabled") return True @@ -67,7 +72,33 @@ class MQTTRelayService: await self.disconnect() # Create and connect client - return await self._connect(broker, port, username, password, use_tls) + result = await self._connect(broker, port, username, password, use_tls) + + # Configure smart plug service with same settings + await self._configure_smart_plug_service(settings) + + return result + + async def _configure_smart_plug_service(self, settings: dict): + """Configure the MQTT smart plug service with the same broker settings.""" + try: + if self._smart_plug_service is None: + from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service + + self._smart_plug_service = mqtt_smart_plug_service + + await self._smart_plug_service.configure(settings) + except Exception as e: + logger.error(f"Failed to configure MQTT smart plug service: {e}") + + @property + def smart_plug_service(self): + """Get the MQTT smart plug service instance.""" + if self._smart_plug_service is None: + from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service + + self._smart_plug_service = mqtt_smart_plug_service + return self._smart_plug_service async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool: """Establish MQTT connection.""" diff --git a/backend/app/services/mqtt_smart_plug.py b/backend/app/services/mqtt_smart_plug.py new file mode 100644 index 000000000..2ed44ce33 --- /dev/null +++ b/backend/app/services/mqtt_smart_plug.py @@ -0,0 +1,488 @@ +"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data. + +This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices. +""" + +import json +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +import paho.mqtt.client as mqtt + +logger = logging.getLogger(__name__) + + +@dataclass +class SmartPlugMQTTData: + """Latest data received from an MQTT smart plug.""" + + plug_id: int + power: float | None = None # Current power in watts + energy: float | None = None # Energy in kWh (today) + state: str | None = None # "ON" or "OFF" + last_seen: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class MQTTDataSourceConfig: + """Configuration for a single MQTT data source (power, energy, or state).""" + + topic: str + path: str + multiplier: float = 1.0 # For power/energy + on_value: str | None = None # For state (what value means "ON") + + +class MQTTSmartPlugService: + """Subscribes to MQTT topics for smart plug energy monitoring.""" + + # Consider plug unreachable if no message received in this time + REACHABLE_TIMEOUT_MINUTES = 5 + + def __init__(self): + self.client: mqtt.Client | None = None + self.connected = False + self._lock = threading.Lock() + # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state" + self.subscriptions: dict[str, list[tuple[int, str]]] = {} + # plug_id -> {data_type: MQTTDataSourceConfig} + self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {} + # plug_id -> latest data + self.plug_data: dict[int, SmartPlugMQTTData] = {} + self._configured = False + self._broker = "" + self._port = 1883 + self._username = "" + self._password = "" + self._use_tls = False + + def is_configured(self) -> bool: + """Check if the MQTT service is configured and connected.""" + return self._configured and self.connected + + def has_broker_settings(self) -> bool: + """Check if broker settings are available (even if not connected yet).""" + return bool(self._broker) + + async def configure(self, settings: dict) -> bool: + """Configure MQTT connection from settings. + + Uses the same broker settings as the MQTT relay service. + Returns True if connection was successful or MQTT is disabled. + """ + enabled = settings.get("mqtt_enabled", False) + + if not enabled: + await self.disconnect() + self._configured = False + logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)") + return True + + broker = settings.get("mqtt_broker", "") + port = settings.get("mqtt_port", 1883) + username = settings.get("mqtt_username", "") + password = settings.get("mqtt_password", "") + use_tls = settings.get("mqtt_use_tls", False) + + if not broker: + logger.warning("MQTT smart plug service: no broker configured") + self._configured = False + return False + + # Check if settings changed + settings_changed = ( + self._broker != broker + or self._port != port + or self._username != username + or self._password != password + or self._use_tls != use_tls + ) + + self._broker = broker + self._port = port + self._username = username + self._password = password + self._use_tls = use_tls + self._configured = True + + # Disconnect and reconnect if settings changed + if settings_changed and self.client: + await self.disconnect() + + # Connect if not already connected + if not self.client or not self.connected: + return await self._connect() + + return True + + async def _connect(self) -> bool: + """Establish MQTT connection.""" + import asyncio + import ssl + + try: + # Create client with callback API version 2 + self.client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"bambuddy-smartplug-{id(self)}", + protocol=mqtt.MQTTv311, + ) + + # Set up callbacks + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + self.client.on_message = self._on_message + + # Configure authentication + if self._username: + self.client.username_pw_set(self._username, self._password) + + # Configure TLS + if self._use_tls: + self.client.tls_set(cert_reqs=ssl.CERT_NONE) + self.client.tls_insecure_set(True) + + # Connect with timeout + try: + await asyncio.wait_for( + asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60), + timeout=3.0, + ) + except TimeoutError: + logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out") + return False + + self.client.loop_start() + + # Wait briefly for connection + await asyncio.sleep(1.0) + + if self.connected: + logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}") + # Resubscribe to all topics + self._resubscribe_all() + return True + else: + logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}") + return True # Connection is async + + except Exception as e: + logger.error(f"MQTT smart plug connection failed: {e}") + self.connected = False + return False + + def _on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: dict, + reason_code: int | mqtt.ReasonCode, + properties: mqtt.Properties | None = None, + ): + """Callback when connected to broker.""" + rc = reason_code if isinstance(reason_code, int) else reason_code.value + if rc == 0: + self.connected = True + logger.info("MQTT smart plug service connected successfully") + # Resubscribe to all topics + self._resubscribe_all() + else: + self.connected = False + logger.error(f"MQTT smart plug connection failed: {reason_code}") + + def _on_disconnect( + self, + client: mqtt.Client, + userdata: Any, + flags_or_rc: dict | int | mqtt.ReasonCode, + reason_code: int | mqtt.ReasonCode | None = None, + properties: mqtt.Properties | None = None, + ): + """Callback when disconnected from broker.""" + self.connected = False + rc = reason_code if reason_code is not None else flags_or_rc + rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0) + if rc_val != 0: + logger.warning(f"MQTT smart plug service disconnected: {rc}") + else: + logger.info("MQTT smart plug service disconnected cleanly") + + def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage): + """Handle incoming MQTT message, extract data using JSON path.""" + topic = msg.topic + + with self._lock: + subscriptions = self.subscriptions.get(topic, []) + if not subscriptions: + return + + # Parse JSON payload (or treat as raw value) + try: + payload = json.loads(msg.payload.decode("utf-8")) + is_json = True + except (json.JSONDecodeError, UnicodeDecodeError): + # Not JSON - treat the whole payload as a raw value + payload = msg.payload.decode("utf-8").strip() + is_json = False + + # Process for each subscribed (plug_id, data_type) + for plug_id, data_type in subscriptions: + configs = self.plug_configs.get(plug_id, {}) + config = configs.get(data_type) + if not config: + continue + + # Extract value using path (or use raw payload if no path) + if is_json and config.path: + raw_value = self._extract_json_path(payload, config.path) + elif is_json and not config.path: + # JSON but no path - if it's a simple value use it, otherwise skip + if isinstance(payload, (int, float, str, bool)): + raw_value = payload + else: + # Can't use a dict/list as a value + logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured") + continue + else: + # Raw value (non-JSON) + raw_value = payload + + if raw_value is None: + continue + + # Initialize plug data if needed + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + data = self.plug_data[plug_id] + data.last_seen = datetime.utcnow() + + # Process based on data type + if data_type == "power": + try: + data.power = float(raw_value) * config.multiplier + logger.debug(f"MQTT smart plug {plug_id}: power={data.power}") + except (ValueError, TypeError): + pass + + elif data_type == "energy": + try: + data.energy = float(raw_value) * config.multiplier + logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}") + except (ValueError, TypeError): + pass + + elif data_type == "state": + state_str = str(raw_value) + # Check against configured ON value if set + if config.on_value: + # Case-insensitive comparison + if state_str.lower() == config.on_value.lower(): + data.state = "ON" + else: + data.state = "OFF" + else: + # Default behavior: normalize common values + upper_state = state_str.upper() + if upper_state in ("ON", "1", "TRUE"): + data.state = "ON" + elif upper_state in ("OFF", "0", "FALSE"): + data.state = "OFF" + else: + data.state = state_str + logger.debug(f"MQTT smart plug {plug_id}: state={data.state}") + + def _extract_json_path(self, data: dict, path: str) -> Any: + """Extract value using dot notation (e.g., 'power_l1' or 'data.power'). + + Supports simple dot notation for nested objects. + """ + if not path: + return None + + parts = path.split(".") + current = data + + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + + return current + + def _resubscribe_all(self): + """Resubscribe to all registered topics after reconnection.""" + if not self.client or not self.connected: + return + + with self._lock: + for topic in self.subscriptions: + if self.subscriptions[topic]: # Only if there are subscribers + try: + self.client.subscribe(topic, qos=1) + logger.debug(f"MQTT smart plug: resubscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}") + + def subscribe( + self, + plug_id: int, + # Power source + power_topic: str | None = None, + power_path: str | None = None, + power_multiplier: float = 1.0, + # Energy source + energy_topic: str | None = None, + energy_path: str | None = None, + energy_multiplier: float = 1.0, + # State source + state_topic: str | None = None, + state_path: str | None = None, + state_on_value: str | None = None, + # Legacy: single topic/path/multiplier (for backward compatibility) + topic: str | None = None, + multiplier: float = 1.0, + ): + """Subscribe to MQTT topics for a plug. + + Each data type (power, energy, state) can have its own topic. + For backward compatibility, if power_topic is not set but topic is, + topic will be used for all data types that have paths configured. + """ + with self._lock: + # Initialize config for this plug + self.plug_configs[plug_id] = {} + + # Determine topics (new fields take priority, fall back to legacy) + effective_power_topic = power_topic or topic + effective_energy_topic = energy_topic or topic + effective_state_topic = state_topic or topic + + # Use new multipliers or fall back to legacy + effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier + effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier + + # Configure power subscription (path is optional - empty means use raw payload) + if effective_power_topic: + config = MQTTDataSourceConfig( + topic=effective_power_topic, + path=power_path or "", + multiplier=effective_power_mult, + ) + self.plug_configs[plug_id]["power"] = config + self._add_subscription(plug_id, effective_power_topic, "power") + + # Configure energy subscription (path is optional - empty means use raw payload) + if effective_energy_topic: + config = MQTTDataSourceConfig( + topic=effective_energy_topic, + path=energy_path or "", + multiplier=effective_energy_mult, + ) + self.plug_configs[plug_id]["energy"] = config + self._add_subscription(plug_id, effective_energy_topic, "energy") + + # Configure state subscription (path is optional - empty means use raw payload) + if effective_state_topic: + config = MQTTDataSourceConfig( + topic=effective_state_topic, + path=state_path or "", + on_value=state_on_value, + ) + self.plug_configs[plug_id]["state"] = config + self._add_subscription(plug_id, effective_state_topic, "state") + + # Initialize data entry + if plug_id not in self.plug_data: + self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id) + + logger.info( + f"MQTT smart plug {plug_id}: configured with " + f"power={effective_power_topic if power_path else None}, " + f"energy={effective_energy_topic if energy_path else None}, " + f"state={effective_state_topic if state_path else None}" + ) + + def _add_subscription(self, plug_id: int, topic: str, data_type: str): + """Add a subscription for a plug/data_type to a topic.""" + if topic not in self.subscriptions: + self.subscriptions[topic] = [] + # Actually subscribe if connected + if self.client and self.connected: + try: + self.client.subscribe(topic, qos=1) + logger.info(f"MQTT smart plug: subscribed to {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}") + + entry = (plug_id, data_type) + if entry not in self.subscriptions[topic]: + self.subscriptions[topic].append(entry) + + def unsubscribe(self, plug_id: int): + """Unsubscribe when plug is deleted/updated.""" + with self._lock: + # Get all configs for this plug + configs = self.plug_configs.pop(plug_id, {}) + if not configs: + # Still clean up any stray subscriptions + pass + + # Collect all topics this plug was subscribed to + topics_to_check = set() + for _data_type, config in configs.items(): + topics_to_check.add(config.topic) + + # Also scan subscriptions to remove any entries for this plug + for topic in list(self.subscriptions.keys()): + # Remove all entries for this plug_id + self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id] + topics_to_check.add(topic) + + # Unsubscribe from topics with no more subscribers + for topic in topics_to_check: + if topic in self.subscriptions and not self.subscriptions[topic]: + del self.subscriptions[topic] + if self.client and self.connected: + try: + self.client.unsubscribe(topic) + logger.info(f"MQTT smart plug: unsubscribed from {topic}") + except Exception as e: + logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}") + + # Remove data + self.plug_data.pop(plug_id, None) + + def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None: + """Get latest data for a plug (called by status endpoint).""" + with self._lock: + return self.plug_data.get(plug_id) + + def is_reachable(self, plug_id: int) -> bool: + """Check if a plug has received data recently.""" + data = self.get_plug_data(plug_id) + if not data: + return False + + timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES) + return datetime.utcnow() - data.last_seen < timeout + + async def disconnect(self): + """Disconnect from MQTT broker.""" + if self.client: + try: + self.client.loop_stop() + self.client.disconnect() + except Exception as e: + logger.debug(f"MQTT smart plug disconnect error (ignored): {e}") + finally: + self.client = None + self.connected = False + + +# Global instance +mqtt_smart_plug_service = MQTTSmartPlugService() diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 81ab1e520..d96d09883 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -17,7 +17,7 @@ from backend.app.models.smart_plug import SmartPlug from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import printer_manager -from backend.app.services.tasmota import tasmota_service +from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.utils.printer_models import normalize_printer_model logger = logging.getLogger(__name__) @@ -348,15 +348,18 @@ class PrintScheduler: Returns True if printer connected successfully within timeout. """ + # Get the appropriate service for the plug type (Tasmota or Home Assistant) + service = await smart_plug_manager.get_service_for_plug(plug, db) + # Check current plug state - status = await tasmota_service.get_status(plug) + status = await service.get_status(plug) if not status.get("reachable"): logger.warning(f"Smart plug '{plug.name}' is not reachable") return False # Turn on if not already on if status.get("state") != "ON": - success = await tasmota_service.turn_on(plug) + success = await service.turn_on(plug) if not success: logger.warning(f"Failed to turn on smart plug '{plug.name}'") return False @@ -425,7 +428,8 @@ class PrintScheduler: # Wait for cooldown (up to 10 minutes) await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600) logger.info(f"Auto-off: Powering off printer {item.printer_id}") - await tasmota_service.turn_off(plug) + service = await smart_plug_manager.get_service_for_plug(plug, db) + await service.turn_off(plug) async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str: """Get a human-readable name for a queue item.""" diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 39366d7da..e788068a9 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -27,7 +27,7 @@ class SmartPlugManager: self._scheduler_task: asyncio.Task | None = None self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed - async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None): + async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None): """Get the appropriate service for the plug type. For HA plugs, configures the service with current settings from DB. @@ -110,7 +110,7 @@ class SmartPlugManager: plugs = result.scalars().all() for plug in plugs: - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) # Check if we should turn on if plug.schedule_on_time == current_time: @@ -166,7 +166,7 @@ class SmartPlugManager: # Turn on the plug logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'") - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) success = await service.turn_on(plug) if success: @@ -195,6 +195,11 @@ class SmartPlugManager: logger.debug(f"Smart plug '{plug.name}' auto_off is disabled") return + # Skip auto-off for HA script entities (scripts can only be triggered, not turned off) + if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."): + logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off") + return + # Only auto-off on successful completion, not on failures # This allows the user to investigate errors before power-off if status != "completed": @@ -261,7 +266,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" plug_info = PlugInfo() - service = await self._get_service_for_plug(plug_info) + service = await self.get_service_for_plug(plug_info) success = await service.turn_off(plug_info) logger.info(f"Turned off plug {plug_id} after time delay") @@ -353,7 +358,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" plug_info = PlugInfo() - service = await self._get_service_for_plug(plug_info) + service = await self.get_service_for_plug(plug_info) success = await service.turn_off(plug_info) logger.info( f"Turned off plug {plug_id} after nozzle temp dropped to " @@ -474,7 +479,7 @@ class SmartPlugManager: # For time mode, just turn off immediately since delay already passed logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now") - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) success = await service.turn_off(plug) if success: await self._mark_auto_off_executed(plug.id) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aca80a62c..e56ee875b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -222,6 +222,24 @@ def mock_mqtt_client(): yield mock +@pytest.fixture +def mock_mqtt_smart_plug_service(): + """Mock the MQTT smart plug service for MQTT plug tests.""" + with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock: + # Create a mock smart_plug_service + mock_service = MagicMock() + mock_service.is_configured = MagicMock(return_value=True) + mock_service.has_broker_settings = MagicMock(return_value=True) + mock_service.configure = AsyncMock(return_value=True) + mock_service.subscribe = MagicMock() + mock_service.unsubscribe = MagicMock() + mock_service.get_plug_data = MagicMock(return_value=None) + mock_service.is_reachable = MagicMock(return_value=False) + + mock.smart_plug_service = mock_service + yield mock + + @pytest.fixture def mock_ftp_client(): """Mock the FTP client for file transfer tests.""" @@ -302,6 +320,22 @@ def smart_plug_factory(db_session): if plug_type == "homeassistant": defaults["ha_entity_id"] = "switch.test" defaults["ip_address"] = None + elif plug_type == "mqtt": + # Legacy fields (for backward compatibility tests) + defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic") + defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0) + # New separate topic/path/multiplier fields + defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic") + defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power") + defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0) + defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic") + defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path") + defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0) + defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic") + defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path") + defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value") + defaults["ip_address"] = None + defaults["ha_entity_id"] = None else: defaults["ip_address"] = "192.168.1.100" defaults["ha_entity_id"] = None diff --git a/backend/tests/integration/test_archives_api.py b/backend/tests/integration/test_archives_api.py index 6d8510f17..59c8a30d4 100644 --- a/backend/tests/integration/test_archives_api.py +++ b/backend/tests/integration/test_archives_api.py @@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints: """Verify filament-requirements with plate_id returns 404 for non-existent archive.""" response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1") assert response.status_code == 404 + + # ======================================================================== + # Tag Management endpoints (Issue #183) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_empty(self, async_client: AsyncClient): + """Verify empty list when no tags exist.""" + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify tags are returned with counts.""" + printer = await printer_factory() + await archive_factory(printer.id, print_name="Archive 1", tags="functional, test") + await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration") + await archive_factory(printer.id, print_name="Archive 3", tags="test") + + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + + # Convert to dict for easier lookup + tags_dict = {t["name"]: t["count"] for t in data} + assert tags_dict.get("functional") == 2 + assert tags_dict.get("test") == 2 + assert tags_dict.get("calibration") == 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_tags_sorted_by_count( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify tags are sorted by count descending, then by name.""" + printer = await printer_factory() + await archive_factory(printer.id, tags="alpha") + await archive_factory(printer.id, tags="beta, alpha") + await archive_factory(printer.id, tags="gamma, beta, alpha") + + response = await async_client.get("/api/v1/archives/tags") + assert response.status_code == 200 + data = response.json() + + # alpha=3, beta=2, gamma=1 + assert data[0]["name"] == "alpha" + assert data[0]["count"] == 3 + assert data[1]["name"] == "beta" + assert data[1]["count"] == 2 + assert data[2]["name"] == "gamma" + assert data[2]["count"] == 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify renaming a tag updates all archives.""" + printer = await printer_factory() + a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other") + a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag") + await archive_factory(printer.id, print_name="Archive 3", tags="different") + + response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"}) + assert response.status_code == 200 + data = response.json() + assert data["affected"] == 2 + + # Verify the archives were updated + response = await async_client.get(f"/api/v1/archives/{a1.id}") + assert "new-tag" in response.json()["tags"] + assert "old-tag" not in response.json()["tags"] + + response = await async_client.get(f"/api/v1/archives/{a2.id}") + assert response.json()["tags"] == "new-tag" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag_no_change(self, async_client: AsyncClient): + """Verify renaming to same name returns 0 affected.""" + response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"}) + assert response.status_code == 200 + assert response.json()["affected"] == 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_rename_tag_empty_name_error(self, async_client: AsyncClient): + """Verify renaming to empty name returns error.""" + response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""}) + assert response.status_code == 400 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session): + """Verify deleting a tag removes it from all archives.""" + printer = await printer_factory() + a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep") + a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me") + await archive_factory(printer.id, print_name="Archive 3", tags="different") + + response = await async_client.delete("/api/v1/archives/tags/delete-me") + assert response.status_code == 200 + data = response.json() + assert data["affected"] == 2 + + # Verify the archives were updated + response = await async_client.get(f"/api/v1/archives/{a1.id}") + assert response.json()["tags"] == "keep" + + response = await async_client.get(f"/api/v1/archives/{a2.id}") + # Should be None or empty when last tag is removed + assert response.json()["tags"] is None or response.json()["tags"] == "" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_tag_not_found(self, async_client: AsyncClient): + """Verify deleting non-existent tag returns 0 affected.""" + response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag") + assert response.status_code == 200 + assert response.json()["affected"] == 0 diff --git a/backend/tests/integration/test_smart_plugs_api.py b/backend/tests/integration/test_smart_plugs_api.py index e6ad7b573..c5e917e10 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -573,3 +573,255 @@ class TestSmartPlugsAPI: assert response.status_code == 400 assert "not configured" in response.json()["detail"].lower() + + # ======================================================================== + # MQTT Integration tests + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with topic and JSON paths.""" + data = { + "name": "MQTT Energy Monitor", + "plug_type": "mqtt", + "mqtt_topic": "zigbee2mqtt/shelly-working-room", + "mqtt_power_path": "power_l1", + "mqtt_energy_path": "energy_l1", + "mqtt_state_path": "state_l1", + "mqtt_multiplier": 1.0, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "MQTT Energy Monitor" + assert result["plug_type"] == "mqtt" + assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room" + assert result["mqtt_power_path"] == "power_l1" + assert result["mqtt_energy_path"] == "energy_l1" + assert result["mqtt_state_path"] == "state_l1" + assert result["mqtt_multiplier"] == 1.0 + assert result["ip_address"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient): + """Verify creating MQTT plug without topic fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + # Missing mqtt_topic + "mqtt_power_path": "power", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient): + """Verify creating MQTT plug without any topic fails.""" + data = { + "name": "MQTT Plug", + "plug_type": "mqtt", + # No topic configured at all + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can use multiplier for unit conversion.""" + data = { + "name": "MQTT mW to W", + "plug_type": "mqtt", + "mqtt_topic": "sensors/power", + "mqtt_power_path": "power_mw", + "mqtt_multiplier": 0.001, # Convert mW to W + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_multiplier"] == 0.001 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify MQTT plugs cannot be controlled (monitor-only).""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_topic="test/topic", + mqtt_power_path="power", + ) + + response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"}) + + assert response.status_code == 400 + assert "monitor-only" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session): + """Verify MQTT plug topic can be updated.""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_topic="old/topic", + mqtt_power_path="power", + ) + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "mqtt_topic": "new/topic", + "mqtt_power_path": "new_power", + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_topic"] == "new/topic" + assert result["mqtt_power_path"] == "new_power" + + # ======================================================================== + # Enhanced MQTT Integration tests (separate topics per data type) + # ======================================================================== + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with separate topics for power, energy, and state.""" + data = { + "name": "MQTT Separate Topics", + "plug_type": "mqtt", + "mqtt_power_topic": "zigbee/power", + "mqtt_power_path": "power_l1", + "mqtt_power_multiplier": 0.001, + "mqtt_energy_topic": "zigbee/energy", + "mqtt_energy_path": "energy_total", + "mqtt_energy_multiplier": 1.0, + "mqtt_state_topic": "zigbee/state", + "mqtt_state_path": "state", + "mqtt_state_on_value": "ON", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "MQTT Separate Topics" + assert result["plug_type"] == "mqtt" + # Power fields + assert result["mqtt_power_topic"] == "zigbee/power" + assert result["mqtt_power_path"] == "power_l1" + assert result["mqtt_power_multiplier"] == 0.001 + # Energy fields + assert result["mqtt_energy_topic"] == "zigbee/energy" + assert result["mqtt_energy_path"] == "energy_total" + assert result["mqtt_energy_multiplier"] == 1.0 + # State fields + assert result["mqtt_state_topic"] == "zigbee/state" + assert result["mqtt_state_path"] == "state" + assert result["mqtt_state_on_value"] == "ON" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with only energy monitoring.""" + data = { + "name": "Energy Only Monitor", + "plug_type": "mqtt", + "mqtt_energy_topic": "sensors/energy", + "mqtt_energy_path": "kwh", + "mqtt_energy_multiplier": 0.001, # Wh to kWh + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_energy_topic"] == "sensors/energy" + assert result["mqtt_energy_path"] == "kwh" + assert result["mqtt_energy_multiplier"] == 0.001 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify MQTT plug can be created with only state monitoring.""" + data = { + "name": "State Only Monitor", + "plug_type": "mqtt", + "mqtt_state_topic": "switches/outlet", + "mqtt_state_path": "state", + "mqtt_state_on_value": "true", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_state_topic"] == "switches/outlet" + assert result["mqtt_state_path"] == "state" + assert result["mqtt_state_on_value"] == "true" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service): + """Verify creating MQTT plug with topic only (no path) succeeds for raw values.""" + data = { + "name": "Raw MQTT Plug", + "plug_type": "mqtt", + # Topic only, no path - valid for raw numeric MQTT values + "mqtt_power_topic": "zigbee/power", + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 # Should succeed + result = response.json() + assert result["mqtt_power_topic"] == "zigbee/power" + assert result["mqtt_power_path"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_mqtt_plug_separate_multipliers( + self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service + ): + """Verify MQTT plug multipliers can be updated separately.""" + plug = await smart_plug_factory( + plug_type="mqtt", + mqtt_power_topic="test/power", + mqtt_power_path="power", + mqtt_power_multiplier=1.0, + mqtt_energy_topic="test/energy", + mqtt_energy_path="energy", + mqtt_energy_multiplier=1.0, + ) + + response = await async_client.patch( + f"/api/v1/smart-plugs/{plug.id}", + json={ + "mqtt_power_multiplier": 0.001, # Change power multiplier only + "mqtt_energy_multiplier": 0.001, # Change energy multiplier only + }, + ) + + assert response.status_code == 200 + result = response.json() + assert result["mqtt_power_multiplier"] == 0.001 + assert result["mqtt_energy_multiplier"] == 0.001 diff --git a/deploy/bambuddy.service b/deploy/bambuddy.service index b096b3d29..93c779466 100644 --- a/deploy/bambuddy.service +++ b/deploy/bambuddy.service @@ -1,31 +1,61 @@ +# BamBuddy Systemd Service Template +# +# INSTALLATION: +# 1. Copy this file to /etc/systemd/system/bambuddy.service +# 2. Replace placeholders: +# - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy) +# - SERVICE_USER: User to run as (e.g., bambuddy) +# - DATA_DIR: Data directory (e.g., /opt/bambuddy/data) +# - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs) +# 3. Run: sudo systemctl daemon-reload +# 4. Run: sudo systemctl enable bambuddy +# 5. Run: sudo systemctl start bambuddy +# +# Or use the install script: ./install/install.sh +# + [Unit] -Description=BamBuddy Print Archive +Description=BamBuddy - Bambu Lab Print Management +Documentation=https://github.com/maziggy/bambuddy After=network.target [Service] Type=simple -User=claude -Group=claude -WorkingDirectory=/bambuddy -Environment="PATH=/bambuddy/logs -ExecStartPre=+/bin/mkdir -p /bambuddy/archive -ExecStartPre=+/bin/chown -R : /bambuddy/logs -ExecStartPre=+/bin/chown -R : /bambuddy/archive +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=bambuddy -ExecStart=/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 -Restart=always -RestartSec=10 +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH [Install] WantedBy=multi-user.target diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5253c8d3b..d62d353d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { ProjectsPage } from './pages/ProjectsPage'; import { ProjectDetailPage } from './pages/ProjectDetailPage'; import { FileManagerPage } from './pages/FileManagerPage'; import { CameraPage } from './pages/CameraPage'; +import { StreamOverlayPage } from './pages/StreamOverlayPage'; import { ExternalLinkPage } from './pages/ExternalLinkPage'; import { SystemInfoPage } from './pages/SystemInfoPage'; import { LoginPage } from './pages/LoginPage'; @@ -108,6 +109,9 @@ function App() { {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */} } /> + {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */} + } /> + {/* Main app with WebSocket for real-time updates */} }> } /> diff --git a/frontend/src/__tests__/components/EditArchiveModal.test.tsx b/frontend/src/__tests__/components/EditArchiveModal.test.tsx index d852bcf66..73718efca 100644 --- a/frontend/src/__tests__/components/EditArchiveModal.test.tsx +++ b/frontend/src/__tests__/components/EditArchiveModal.test.tsx @@ -38,7 +38,11 @@ describe('EditArchiveModal', () => { return HttpResponse.json(mockProjects); }), http.get('/api/v1/archives/tags', () => { - return HttpResponse.json(['test', 'calibration', 'functional']); + return HttpResponse.json([ + { name: 'test', count: 2 }, + { name: 'calibration', count: 1 }, + { name: 'functional', count: 3 }, + ]); }), http.patch('/api/v1/archives/:id', async ({ request }) => { const body = await request.json(); diff --git a/frontend/src/__tests__/components/SmartPlugCard.test.tsx b/frontend/src/__tests__/components/SmartPlugCard.test.tsx index 561d7cc0b..51d43f5e3 100644 --- a/frontend/src/__tests__/components/SmartPlugCard.test.tsx +++ b/frontend/src/__tests__/components/SmartPlugCard.test.tsx @@ -21,6 +21,24 @@ const createMockPlug = (overrides: Partial = {}): SmartPlug => ({ plug_type: 'tasmota', ip_address: '192.168.1.100', ha_entity_id: null, + ha_power_entity: null, + ha_energy_today_entity: null, + ha_energy_total_entity: null, + // MQTT fields (legacy) + mqtt_topic: null, + mqtt_multiplier: 1.0, + // MQTT power fields + mqtt_power_topic: null, + mqtt_power_path: null, + mqtt_power_multiplier: 1.0, + // MQTT energy fields + mqtt_energy_topic: null, + mqtt_energy_path: null, + mqtt_energy_multiplier: 1.0, + // MQTT state fields + mqtt_state_topic: null, + mqtt_state_path: null, + mqtt_state_on_value: null, printer_id: 1, enabled: true, auto_on: true, @@ -272,4 +290,73 @@ describe('SmartPlugCard', () => { expect(buttons.length).toBeGreaterThan(0); }); }); + + describe('MQTT plugs', () => { + it('renders MQTT plug with topic instead of IP', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'zigbee2mqtt/shelly-power', + mqtt_power_path: 'power_l1', + }); + render(); + + // Should show topic, not IP + expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument(); + expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument(); + }); + + it('renders MQTT plug name correctly', () => { + const plug = createMockPlug({ + name: 'MQTT Energy Monitor', + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'sensors/power', + mqtt_power_path: 'power', + }); + render(); + + expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument(); + }); + + it('shows Monitor Only badge for MQTT plug', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + expect(screen.getByText('Monitor Only')).toBeInTheDocument(); + }); + + it('does not show power control buttons for MQTT plug', () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + // On/Off buttons should not be present for monitor-only plugs + expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument(); + }); + + it('shows Settings instead of Automation Settings for MQTT plug', async () => { + const plug = createMockPlug({ + plug_type: 'mqtt', + ip_address: null, + mqtt_topic: 'test/topic', + mqtt_power_path: 'power', + }); + render(); + + // Should show "Settings" not "Automation Settings" + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/__tests__/components/TagManagementModal.test.tsx b/frontend/src/__tests__/components/TagManagementModal.test.tsx new file mode 100644 index 000000000..db064d193 --- /dev/null +++ b/frontend/src/__tests__/components/TagManagementModal.test.tsx @@ -0,0 +1,300 @@ +/** + * Tests for the TagManagementModal component. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { TagManagementModal } from '../../components/TagManagementModal'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const mockTags = [ + { name: 'functional', count: 5 }, + { name: 'calibration', count: 3 }, + { name: 'test', count: 2 }, + { name: 'art', count: 1 }, +]; + +describe('TagManagementModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.get('/api/v1/archives/tags', () => { + return HttpResponse.json(mockTags); + }), + http.put('/api/v1/archives/tags/:tagName', async () => { + return HttpResponse.json({ affected: 2 }); + }), + http.delete('/api/v1/archives/tags/:tagName', () => { + return HttpResponse.json({ affected: 1 }); + }) + ); + }); + + describe('rendering', () => { + it('renders the modal title', async () => { + render(); + + expect(screen.getByText('Manage Tags')).toBeInTheDocument(); + }); + + it('shows loading state initially', () => { + render(); + + // Should show loading spinner before data loads + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + + it('displays tags with counts', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('calibration')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + }); + + it('shows total tag count and usage', async () => { + render(); + + await waitFor(() => { + // 4 tags, 11 total usages + expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument(); + }); + }); + }); + + describe('search functionality', () => { + it('filters tags by search input', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search tags...'); + await user.type(searchInput, 'cal'); + + await waitFor(() => { + expect(screen.getByText('calibration')).toBeInTheDocument(); + expect(screen.queryByText('functional')).not.toBeInTheDocument(); + expect(screen.queryByText('art')).not.toBeInTheDocument(); + }); + }); + + it('shows no results message when search has no matches', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search tags...'); + await user.type(searchInput, 'nonexistent'); + + await waitFor(() => { + expect(screen.getByText('No tags match your search')).toBeInTheDocument(); + }); + }); + }); + + describe('sorting', () => { + it('sorts by count by default', async () => { + render(); + + await waitFor(() => { + const tagElements = screen.getAllByText(/functional|calibration|test|art/); + // First should be functional (count 5) + expect(tagElements[0]).toHaveTextContent('functional'); + }); + }); + + it('can sort by name', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const sortSelect = screen.getByDisplayValue('Sort by Count'); + await user.selectOptions(sortSelect, 'name'); + + await waitFor(() => { + const tagElements = screen.getAllByText(/functional|calibration|test|art/); + // First should be 'art' alphabetically + expect(tagElements[0]).toHaveTextContent('art'); + }); + }); + }); + + describe('rename functionality', () => { + it('enters edit mode when clicking edit button', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + // Find the tag row and click its edit button + const tagRow = screen.getByText('functional').closest('div'); + const editButton = within(tagRow!).getByTitle('Rename tag'); + await user.click(editButton); + + // Should show input with current value + await waitFor(() => { + expect(screen.getByDisplayValue('functional')).toBeInTheDocument(); + }); + }); + + it('submits rename on Enter key', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const editButton = within(tagRow!).getByTitle('Rename tag'); + await user.click(editButton); + + const input = screen.getByDisplayValue('functional'); + await user.clear(input); + await user.type(input, 'new-name{Enter}'); + + // Should show success (mutation called) + await waitFor(() => { + // After successful rename, edit mode should close + expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument(); + }); + }); + + it('cancels edit on Escape key', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const editButton = within(tagRow!).getByTitle('Rename tag'); + await user.click(editButton); + + const input = screen.getByDisplayValue('functional'); + await user.type(input, '-modified{Escape}'); + + // Should exit edit mode without saving + await waitFor(() => { + expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument(); + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + }); + }); + + describe('delete functionality', () => { + it('shows delete confirmation when clicking delete button', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const deleteButton = within(tagRow!).getByTitle('Delete tag'); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument(); + }); + }); + + it('cancels delete confirmation on X button', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + + const tagRow = screen.getByText('functional').closest('div'); + const deleteButton = within(tagRow!).getByTitle('Delete tag'); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument(); + }); + + // Find the confirmation row and click the cancel (X) button within it + const confirmationText = screen.getByText(/Delete "functional"/i); + const confirmationRow = confirmationText.closest('div'); + // The X button is the last button in the confirmation row + const buttons = within(confirmationRow!.parentElement!).getAllByRole('button'); + const cancelButton = buttons[buttons.length - 1]; // X button is last + await user.click(cancelButton); + + await waitFor(() => { + // Should return to normal display - the tag name should be visible again + expect(screen.getByText('functional')).toBeInTheDocument(); + }); + }); + }); + + describe('modal behavior', () => { + it('calls onClose when close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Manage Tags')).toBeInTheDocument(); + }); + + // Find close button in header (X icon) + const headerCloseButton = screen.getAllByRole('button')[0]; + await user.click(headerCloseButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Manage Tags')).toBeInTheDocument(); + }); + + const closeButton = screen.getByRole('button', { name: /close/i }); + await user.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + }); + + describe('empty state', () => { + it('shows empty message when no tags exist', async () => { + server.use( + http.get('/api/v1/archives/tags', () => { + return HttpResponse.json([]); + }) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('No tags found')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx new file mode 100644 index 000000000..405fb1c82 --- /dev/null +++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx @@ -0,0 +1,230 @@ +/** + * Tests for the StreamOverlayPage component. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { screen, waitFor, render as rtlRender } from '@testing-library/react'; +import { StreamOverlayPage } from '../../pages/StreamOverlayPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider } from '../../contexts/ThemeContext'; +import { ToastProvider } from '../../contexts/ToastContext'; + +const mockPrinter = { + id: 1, + name: 'X1 Carbon', + ip_address: '192.168.1.100', + serial_number: '00M09A350100001', + access_code: '12345678', + model: 'X1C', + enabled: true, +}; + +const mockStatusIdle = { + id: 1, + name: 'X1 Carbon', + connected: true, + state: 'IDLE', + progress: 0, + current_print: null, + remaining_time: null, + layer_num: null, + total_layers: null, + stg_cur_name: null, +}; + +const mockStatusPrinting = { + id: 1, + name: 'X1 Carbon', + connected: true, + state: 'RUNNING', + progress: 45, + current_print: 'Benchy.gcode.3mf', + remaining_time: 82, + layer_num: 150, + total_layers: 300, + stg_cur_name: null, +}; + +// Custom render for StreamOverlayPage +function renderOverlayPage(printerId: number, queryParams = '') { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + + return rtlRender( + + + + + + } /> + + + + + + ); +} + +describe('StreamOverlayPage', () => { + const originalTitle = document.title; + + beforeEach(() => { + // Mock WebSocket + vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({ + close: vi.fn(), + onmessage: null, + onerror: null, + }))); + + server.use( + http.get('/api/v1/printers/:id', () => { + return HttpResponse.json(mockPrinter); + }), + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json(mockStatusIdle); + }) + ); + }); + + afterEach(() => { + document.title = originalTitle; + vi.unstubAllGlobals(); + }); + + describe('rendering', () => { + it('renders overlay page for printer', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + }); + + it('shows Bambuddy logo', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByAltText('Bambuddy')).toBeInTheDocument(); + }); + }); + + it('logo links to GitHub', async () => { + renderOverlayPage(1); + + await waitFor(() => { + const logo = screen.getByAltText('Bambuddy'); + const link = logo.closest('a'); + expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy'); + }); + }); + }); + + describe('printing state', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json(mockStatusPrinting); + }) + ); + }); + + it('shows filename when printing', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('Benchy')).toBeInTheDocument(); + }); + }); + + it('shows progress percentage', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + }); + + it('shows layer count', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('150')).toBeInTheDocument(); + expect(screen.getByText('300')).toBeInTheDocument(); + }); + }); + + it('shows status text', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('Printing')).toBeInTheDocument(); + }); + }); + }); + + describe('invalid printer', () => { + it('shows invalid printer message for ID 0', async () => { + renderOverlayPage(0); + + await waitFor(() => { + expect(screen.getByText('Invalid printer ID')).toBeInTheDocument(); + }); + }); + }); + + describe('query parameters', () => { + it('respects size parameter', async () => { + renderOverlayPage(1, '?size=large'); + + await waitFor(() => { + // Just verify it renders without error + expect(screen.getByAltText('Bambuddy')).toBeInTheDocument(); + }); + }); + + it('respects show parameter to hide elements', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json(mockStatusPrinting); + }) + ); + + renderOverlayPage(1, '?show=progress'); + + await waitFor(() => { + // Progress should be visible + expect(screen.getByText('45%')).toBeInTheDocument(); + // Status text should be hidden when not in show list + expect(screen.queryByText('Printing')).not.toBeInTheDocument(); + }); + }); + }); + + describe('offline state', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json({ + ...mockStatusIdle, + connected: false, + }); + }) + ); + }); + + it('shows offline message when printer disconnected', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('Printer offline')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cb9a4ed75..5ad78680d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -341,6 +341,11 @@ export interface ArchiveStats { total_energy_cost: number; } +export interface TagInfo { + name: string; + count: number; +} + export interface FailureAnalysis { period_days: number; total_prints: number; @@ -847,13 +852,29 @@ export interface CloudDevice { export interface SmartPlug { id: number; name: string; - plug_type: 'tasmota' | 'homeassistant'; + plug_type: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address: string | null; // Required for Tasmota - ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug") + ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer") // Home Assistant energy sensor entities (optional) ha_power_entity: string | null; ha_energy_today_entity: string | null; ha_energy_total_entity: string | null; + // MQTT fields (required when plug_type="mqtt") + // Legacy field - kept for backward compatibility + mqtt_topic: string | null; // Deprecated, use mqtt_power_topic + mqtt_multiplier: number; // Deprecated, use mqtt_power_multiplier + // Power monitoring + mqtt_power_topic: string | null; // Topic for power data + mqtt_power_path: string | null; // e.g., "power_l1" or "data.power" + mqtt_power_multiplier: number; // Unit conversion for power + // Energy monitoring + mqtt_energy_topic: string | null; // Topic for energy data + mqtt_energy_path: string | null; // e.g., "energy_l1" + mqtt_energy_multiplier: number; // Unit conversion for energy + // State monitoring + mqtt_state_topic: string | null; // Topic for state data + mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF + mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1") printer_id: number | null; enabled: boolean; auto_on: boolean; @@ -872,8 +893,9 @@ export interface SmartPlug { schedule_enabled: boolean; schedule_on_time: string | null; schedule_off_time: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar: boolean; + show_on_printer_card: boolean; // For scripts: show on printer card // Status last_state: string | null; last_checked: string | null; @@ -884,13 +906,29 @@ export interface SmartPlug { export interface SmartPlugCreate { name: string; - plug_type?: 'tasmota' | 'homeassistant'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address?: string | null; // Required for Tasmota ha_entity_id?: string | null; // Required for Home Assistant // Home Assistant energy sensor entities (optional) ha_power_entity?: string | null; ha_energy_today_entity?: string | null; ha_energy_total_entity?: string | null; + // MQTT fields (required when plug_type="mqtt") + // Legacy fields - kept for backward compatibility + mqtt_topic?: string | null; + mqtt_multiplier?: number; + // Power monitoring + mqtt_power_topic?: string | null; + mqtt_power_path?: string | null; + mqtt_power_multiplier?: number; + // Energy monitoring + mqtt_energy_topic?: string | null; + mqtt_energy_path?: string | null; + mqtt_energy_multiplier?: number; + // State monitoring + mqtt_state_topic?: string | null; + mqtt_state_path?: string | null; + mqtt_state_on_value?: string | null; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -908,19 +946,35 @@ export interface SmartPlugCreate { schedule_enabled?: boolean; schedule_on_time?: string | null; schedule_off_time?: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar?: boolean; + show_on_printer_card?: boolean; } export interface SmartPlugUpdate { name?: string; - plug_type?: 'tasmota' | 'homeassistant'; + plug_type?: 'tasmota' | 'homeassistant' | 'mqtt'; ip_address?: string | null; ha_entity_id?: string | null; // Home Assistant energy sensor entities (optional) ha_power_entity?: string | null; ha_energy_today_entity?: string | null; ha_energy_total_entity?: string | null; + // MQTT fields (legacy) + mqtt_topic?: string | null; + mqtt_multiplier?: number; + // MQTT power fields + mqtt_power_topic?: string | null; + mqtt_power_path?: string | null; + mqtt_power_multiplier?: number; + // MQTT energy fields + mqtt_energy_topic?: string | null; + mqtt_energy_path?: string | null; + mqtt_energy_multiplier?: number; + // MQTT state fields + mqtt_state_topic?: string | null; + mqtt_state_path?: string | null; + mqtt_state_on_value?: string | null; printer_id?: number | null; enabled?: boolean; auto_on?: boolean; @@ -938,8 +992,9 @@ export interface SmartPlugUpdate { schedule_enabled?: boolean; schedule_on_time?: string | null; schedule_off_time?: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar?: boolean; + show_on_printer_card?: boolean; } // Home Assistant entity for smart plug selection @@ -947,7 +1002,7 @@ export interface HAEntity { entity_id: string; friendly_name: string; state: string | null; - domain: string; // "switch", "light", "input_boolean" + domain: string; // "switch", "light", "input_boolean", "script" } // Home Assistant sensor entity for energy monitoring @@ -2050,6 +2105,17 @@ export const api = { deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), getArchiveStats: () => request('/archives/stats'), + // Tag management + getTags: () => request('/archives/tags'), + renameTag: (oldName: string, newName: string) => + request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, { + method: 'PUT', + body: JSON.stringify({ new_name: newName }), + }), + deleteTag: (name: string) => + request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, { + method: 'DELETE', + }), recalculateCosts: () => request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }), getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => { @@ -2543,6 +2609,7 @@ export const api = { getSmartPlugs: () => request('/smart-plugs/'), getSmartPlug: (id: number) => request(`/smart-plugs/${id}`), getSmartPlugByPrinter: (printerId: number) => request(`/smart-plugs/by-printer/${printerId}`), + getScriptPlugsByPrinter: (printerId: number) => request(`/smart-plugs/by-printer/${printerId}/scripts`), createSmartPlug: (data: SmartPlugCreate) => request('/smart-plugs/', { method: 'POST', diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx index bba6c11d0..f5436ff35 100644 --- a/frontend/src/components/AddSmartPlugModal.tsx +++ b/frontend/src/components/AddSmartPlugModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react'; +import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client'; import { Button } from './Button'; @@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const isEditing = !!plug; // Plug type selection - const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota'); + const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota'); const [name, setName] = useState(plug?.name || ''); // Tasmota fields @@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const [password, setPassword] = useState(plug?.password || ''); // Home Assistant fields const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || ''); + // MQTT fields - Power + const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || ''); + const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || ''); + const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState( + (plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString() + ); + // MQTT fields - Energy + const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || ''); + const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || ''); + const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState( + (plug?.mqtt_energy_multiplier ?? 1).toString() + ); + // MQTT fields - State + const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || ''); + const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || ''); + const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || ''); // HA energy sensor entities (optional) const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || ''); const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || ''); @@ -279,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { return; } + if (plugType === 'mqtt') { + // Check that at least one topic is configured (path is optional) + const hasPower = mqttPowerTopic.trim(); + const hasEnergy = mqttEnergyTopic.trim(); + const hasState = mqttStateTopic.trim(); + + if (!hasPower && !hasEnergy && !hasState) { + setError('At least one MQTT topic must be configured for power, energy, or state monitoring'); + return; + } + } + const data = { name: name.trim(), plug_type: plugType, @@ -288,6 +316,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null, ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null, ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null, + // MQTT power fields + mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null, + mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null, + mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1, + // MQTT energy fields + mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null, + mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null, + mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1, + // MQTT state fields + mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null, + mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null, + mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null, username: plugType === 'tasmota' ? (username.trim() || null) : null, password: plugType === 'tasmota' ? (password.trim() || null) : null, printer_id: printerId, @@ -352,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { setTestResult(null); setError(null); }} - className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${ plugType === 'tasmota' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary' @@ -368,14 +408,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { setTestResult(null); setError(null); }} - className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${ plugType === 'homeassistant' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary' }`} > - Home Assistant + HA + + )} @@ -857,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} + {/* MQTT Configuration - only show when MQTT is selected */} + {plugType === 'mqtt' && ( +
+ {/* MQTT broker not configured */} + {!settings?.mqtt_broker && ( +
+ MQTT broker not configured. Set broker address in{' '} + Settings → Network → MQTT Publishing + {' '}(you don't need to enable publishing, just fill in the broker details). +
+ )} + + {/* MQTT broker configured - show fields */} + {settings?.mqtt_broker && ( + <> +
+

Monitor Only

+

+ MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system. +

+
+ + {/* Power Section */} +
+

Power Monitoring

+
+ + setMqttPowerTopic(e.target.value)} + placeholder="zigbee2mqtt/shelly-working-room" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+
+ + setMqttPowerPath(e.target.value)} + placeholder="power_l1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setMqttPowerMultiplier(e.target.value)} + placeholder="1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+

+ JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.
+ Use multiplier 0.001 for mW→W, 1000 for kW→W. +

+
+ + {/* Energy Section */} +
+

Energy Monitoring (optional)

+
+ + setMqttEnergyTopic(e.target.value)} + placeholder="Same as power topic, or different" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+
+ + setMqttEnergyPath(e.target.value)} + placeholder="energy_l1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setMqttEnergyMultiplier(e.target.value)} + placeholder="1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+

+ JSON path extracts value from JSON payload. Leave empty for raw values.
+ Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh. +

+
+ + {/* State Section */} +
+

State Monitoring (optional)

+
+ + setMqttStateTopic(e.target.value)} + placeholder="Same as power topic, or different" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+
+ + setMqttStatePath(e.target.value)} + placeholder="state_l1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+ + setMqttStateOnValue(e.target.value)} + placeholder="ON, true, 1" + className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none" + /> +
+
+

+ JSON path extracts value from JSON payload. Leave empty for raw values.
+ ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1). +

+
+ + )} +
+ )} + {/* IP Address - only show for Tasmota */} {plugType === 'tasmota' && (
@@ -959,25 +1164,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )} - {/* Link to Printer */} -
- - -

- Linking enables automatic on/off when prints start/complete -

-
+ {/* Link to Printer - not shown for MQTT plugs (monitor-only) */} + {plugType !== 'mqtt' && ( +
+ + +

+ Linking enables automatic on/off when prints start/complete +

+
+ )} {/* Power Alerts */}
@@ -1031,51 +1238,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { )}
- {/* Schedule */} -
-
-
- - Daily Schedule -
- -
- {scheduleEnabled && ( -
-
-
- - setScheduleOnTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
-
- - setScheduleOffTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
+ {/* Schedule - not shown for MQTT plugs (monitor-only) */} + {plugType !== 'mqtt' && ( +
+
+
+ + Daily Schedule
-

- Automatically turn the plug on/off at these times daily. Leave empty to skip that action. -

+
- )} -
+ {scheduleEnabled && ( +
+
+
+ + setScheduleOnTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+ + setScheduleOffTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+

+ Automatically turn the plug on/off at these times daily. Leave empty to skip that action. +

+
+ )} +
+ )} {/* Switchbar Visibility */}
diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 2de8e3ac7..22f6ddc88 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr queryFn: () => api.getProjects(), }); - // Get all archives to extract existing tags if not provided - const { data: archives } = useQuery({ - queryKey: ['archives'], - queryFn: () => api.getArchives(undefined, 1000, 0), + // Fetch all tags using the dedicated API + const { data: tagsData } = useQuery({ + queryKey: ['tags'], + queryFn: api.getTags, enabled: existingTags.length === 0, }); - // Extract unique tags from all archives + // Use existing tags prop if provided, otherwise use fetched tags const allTags = existingTags.length > 0 ? existingTags - : [...new Set( - archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || [] - )].sort(); + : (tagsData?.map(t => t.name) || []); // Get current tags as array const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean); - // Filter suggestions based on what's not already added - const tagSuggestions = allTags.filter(t => !currentTags.includes(t)); + // Get the text being typed after the last comma (for autocomplete filtering) + const currentInput = tags.includes(',') + ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase() + : tags.trim().toLowerCase(); - // Add a tag + // Filter suggestions: not already added AND matches current input (if any) + const tagSuggestions = allTags.filter(t => + !currentTags.includes(t) && + (currentInput === '' || t.toLowerCase().includes(currentInput)) + ); + + // Add a tag (replaces any partial input with the selected tag) const addTag = (tag: string) => { - if (!currentTags.includes(tag)) { - const newTags = [...currentTags, tag].join(', '); + // If there's partial input being typed, replace it with the selected tag + // Otherwise, just append the tag + let baseTags: string[]; + if (currentInput && !allTags.includes(currentInput)) { + // User is typing a partial tag - replace it with the selected one + baseTags = tags.includes(',') + ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean) + : []; + } else { + // No partial input or input is already a complete tag - append + baseTags = currentTags; + } + + if (!baseTags.includes(tag)) { + const newTags = [...baseTags, tag].join(', '); setTags(newTags); } // Clear any pending blur timeout to prevent hiding suggestions @@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr {showTagSuggestions && tagSuggestions.length > 0 && (
- Existing tags (click to add) + {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
{tagSuggestions.map((tag) => ( diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx index ed1d4c01c..0895dd0a1 100644 --- a/frontend/src/components/SmartPlugCard.tsx +++ b/frontend/src/components/SmartPlugCard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react'; +import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugUpdate } from '../api/client'; import { Card, CardContent } from './Card'; @@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { }); const isOn = status?.state === 'ON'; - const isReachable = status?.reachable ?? false; + // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable) + const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined); + const isReachable = (status?.reachable ?? false) || hasMqttData; const isPending = controlMutation.isPending; // Generate admin URL with auto-login credentials (Tasmota only) @@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {/* Header Row */} -
+
-
- {plug.plug_type === 'homeassistant' ? ( +
+ {plug.plug_type === 'mqtt' ? ( + + ) : plug.plug_type === 'homeassistant' ? ( ) : ( )}
-

{plug.name}

-

- {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address} +

{plug.name}

+

+ {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}

{/* Status indicator */} -
+
{statusLoading ? ( + ) : plug.plug_type === 'mqtt' ? ( + /* MQTT plugs - show badge and checkmark when receiving data */ +
+ MQTT + {isReachable && ✓} +
+ ) : plug.plug_type === 'homeassistant' ? ( +
+ HA + + {isReachable ? (status?.state || '?') : 'Offline'} + +
) : isReachable ? (
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { )} {/* Feature Badges */} - {(plug.power_alert_enabled || plug.schedule_enabled) && ( + {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
+ {plug.plug_type === 'mqtt' && ( + + + Monitor Only + + )} {plug.power_alert_enabled && ( @@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)} - {/* Quick Controls */} -
- - -
+ {/* Quick Controls - hidden for MQTT plugs (monitor-only) */} + {plug.plug_type !== 'mqtt' && ( +
+ + +
+ )} + + {/* Energy display for MQTT plugs */} + {plug.plug_type === 'mqtt' && status?.energy && ( +
+ {status.energy.power !== null && status.energy.power !== undefined && ( +
+

{Math.round(status.energy.power)}W

+

Power

+
+ )} + {status.energy.today !== null && status.energy.today !== undefined && ( +
+

{status.energy.today.toFixed(2)}

+

kWh Today

+
+ )} +
+ )} {/* Toggle Settings Panel */} @@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
- {/* Enabled Toggle */} -
-
-

Enabled

-

Enable automation for this plug

-
- -
+ {/* Automation controls - only for controllable plugs (not MQTT) */} + {plug.plug_type !== 'mqtt' && ( + <> + {/* Enabled Toggle */} +
+
+

Enabled

+

Enable automation for this plug

+
+ +
- {/* Auto On */} -
-
-

Auto On

-

Turn on when print starts

-
- -
+ {/* Auto On */} +
+
+

Auto On

+

Turn on when print starts

+
+ +
- {/* Auto Off */} -
-
-

Auto Off

-

Turn off when print completes (one-shot)

+ {/* Auto Off */} +
+
+

Auto Off

+

Turn off when print completes (one-shot)

)} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index d86ce7754..4bfe8f32f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -175,13 +175,17 @@ export function SettingsPage() { let totalLifetime = 0; let reachableCount = 0; - for (const { status } of statuses) { - if (status?.reachable && status.energy) { + for (const { plug, status } of statuses) { + // For MQTT plugs, consider reachable if we have power data + const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null); + const isReachable = (status?.reachable || hasMqttData) && status?.energy; + + if (isReachable) { reachableCount++; - if (status.energy.power != null) totalPower += status.energy.power; - if (status.energy.today != null) totalToday += status.energy.today; - if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday; - if (status.energy.total != null) totalLifetime += status.energy.total; + if (status.energy?.power != null) totalPower += status.energy.power; + if (status.energy?.today != null) totalToday += status.energy.today; + if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday; + if (status.energy?.total != null) totalLifetime += status.energy.total; } } @@ -1950,7 +1954,7 @@ export function SettingsPage() {

- Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities. + Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.

diff --git a/frontend/src/pages/StreamOverlayPage.tsx b/frontend/src/pages/StreamOverlayPage.tsx new file mode 100644 index 000000000..2f1c25cf0 --- /dev/null +++ b/frontend/src/pages/StreamOverlayPage.tsx @@ -0,0 +1,308 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Layers, Clock, Timer, Printer } from 'lucide-react'; +import { api } from '../api/client'; +import type { PrinterStatus } from '../api/client'; + +type OverlaySize = 'small' | 'medium' | 'large'; + +interface OverlayConfig { + size: OverlaySize; + showProgress: boolean; + showLayers: boolean; + showEta: boolean; + showFilename: boolean; + showStatus: boolean; + showPrinter: boolean; +} + +function parseConfig(params: URLSearchParams): OverlayConfig { + const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status']; + + return { + size: (params.get('size') as OverlaySize) || 'medium', + showProgress: show.includes('progress'), + showLayers: show.includes('layers'), + showEta: show.includes('eta'), + showFilename: show.includes('filename'), + showStatus: show.includes('status'), + showPrinter: show.includes('printer'), + }; +} + +function formatTime(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +function formatETA(remainingMinutes: number): string { + const now = new Date(); + const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const etaDay = new Date(eta); + etaDay.setHours(0, 0, 0, 0); + + const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + if (etaDay.getTime() === today.getTime()) { + return timeStr; + } else if (etaDay.getTime() === today.getTime() + 86400000) { + return `Tomorrow ${timeStr}`; + } else { + return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr; + } +} + +function getStatusText(status: PrinterStatus): string { + if (status.stg_cur_name) return status.stg_cur_name; + + switch (status.state) { + case 'RUNNING': return 'Printing'; + case 'PAUSE': return 'Paused'; + case 'FINISH': return 'Finished'; + case 'FAILED': return 'Failed'; + case 'IDLE': return 'Idle'; + default: return status.state || 'Unknown'; + } +} + +function getSizeClasses(size: OverlaySize) { + switch (size) { + case 'small': + return { + container: 'p-3', + text: 'text-sm', + textLarge: 'text-lg', + progressHeight: 'h-2', + icon: 'w-3 h-3', + gap: 'gap-2', + logoHeight: 'h-12', + }; + case 'large': + return { + container: 'p-6', + text: 'text-xl', + textLarge: 'text-3xl', + progressHeight: 'h-4', + icon: 'w-6 h-6', + gap: 'gap-4', + logoHeight: 'h-24', + }; + case 'medium': + default: + return { + container: 'p-4', + text: 'text-base', + textLarge: 'text-xl', + progressHeight: 'h-3', + icon: 'w-4 h-4', + gap: 'gap-3', + logoHeight: 'h-16', + }; + } +} + +export function StreamOverlayPage() { + const { printerId } = useParams<{ printerId: string }>(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const id = parseInt(printerId || '0', 10); + const [imageKey, setImageKey] = useState(Date.now()); + + const config = useMemo(() => parseConfig(searchParams), [searchParams]); + const sizes = getSizeClasses(config.size); + + // Fetch printer info + const { data: printer } = useQuery({ + queryKey: ['printer', id], + queryFn: () => api.getPrinter(id), + enabled: id > 0, + }); + + // Fetch printer status with polling + const { data: status } = useQuery({ + queryKey: ['printerStatus', id], + queryFn: () => api.getPrinterStatus(id), + enabled: id > 0, + refetchInterval: 2000, + }); + + // WebSocket for real-time updates + useEffect(() => { + if (!id) return; + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`; + const ws = new WebSocket(wsUrl); + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'printer_status' && data.printer_id === id) { + queryClient.setQueryData(['printerStatus', id], data.status); + } + } catch { + // Ignore parse errors + } + }; + + ws.onerror = () => { + // WebSocket error - polling will continue as fallback + }; + + return () => { + ws.close(); + }; + }, [id, queryClient]); + + // Update document title + useEffect(() => { + document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay'; + return () => { + document.title = 'Bambuddy'; + }; + }, [printer]); + + // Refresh stream on error + const handleStreamError = () => { + setTimeout(() => { + setImageKey(Date.now()); + }, 3000); + }; + + if (!id) { + return ( +
+

Invalid printer ID

+
+ ); + } + + if (!status) { + return ( +
+

Loading...

+
+ ); + } + + const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE'; + const progress = status.progress || 0; + const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`; + + return ( +
+ {/* Camera feed - fullscreen background */} + Camera stream + + {/* Bambuddy logo - top right */} + + Bambuddy + + + {/* Status overlay - bottom */} +
+
+ {/* Printer name */} + {config.showPrinter && printer && ( +
+ + {printer.name} +
+ )} + + {/* Filename */} + {config.showFilename && status.current_print && ( +
+ {status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')} +
+ )} + + {/* Status text */} + {config.showStatus && ( +
+ {getStatusText(status)} +
+ )} + + {/* Progress bar */} + {config.showProgress && isPrinting && ( +
+
+ Progress + {Math.round(progress)}% +
+
+
+
+
+ )} + + {/* Stats row */} + {isPrinting && (config.showLayers || config.showEta) && ( +
+ {/* Layers */} + {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && ( +
+ + + {status.layer_num} + / + {status.total_layers} + +
+ )} + + {/* Remaining time */} + {config.showEta && status.remaining_time != null && status.remaining_time > 0 && ( + <> +
+ + + {formatTime(status.remaining_time * 60)} + +
+ +
+ + + ETA {formatETA(status.remaining_time)} + +
+ + )} +
+ )} + + {/* Idle state */} + {!isPrinting && ( +
+ {status.connected ? 'Printer is idle' : 'Printer offline'} +
+ )} +
+
+
+ ); +} diff --git a/install/README.md b/install/README.md new file mode 100644 index 000000000..658303ef2 --- /dev/null +++ b/install/README.md @@ -0,0 +1,234 @@ +# BamBuddy Installation Scripts + +Interactive installation scripts for BamBuddy with support for both native and Docker deployments. + +## Quick Start + +### Docker Installation (Recommended) + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash +``` + +### Native Installation + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash +``` + +--- + +## Scripts Overview + +| Script | Platform | Method | +|--------|----------|--------| +| `install.sh` | Linux, macOS | Native (Python venv) | +| `docker-install.sh` | Linux, macOS | Docker | + +--- + +## Native Installation Scripts + +### `install.sh` (Linux/macOS) + +Installs BamBuddy with Python virtual environment and optional systemd/launchd service. + +**Supported Systems:** +- Debian/Ubuntu (apt) +- RHEL/Fedora/CentOS (dnf/yum) +- Arch Linux (pacman) +- openSUSE (zypper) +- macOS (Homebrew) + +**Options:** +``` +--path PATH Installation directory (default: /opt/bambuddy) +--port PORT Port to listen on (default: 8000) +--tz TIMEZONE Timezone (default: system timezone) +--data-dir PATH Data directory (default: INSTALL_PATH/data) +--log-dir PATH Log directory (default: INSTALL_PATH/logs) +--debug Enable debug mode +--log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO) +--no-service Skip systemd/launchd service setup +--yes, -y Non-interactive mode, accept defaults +``` + +**Examples:** +```bash +# Interactive installation +./install.sh + +# Unattended with custom settings +./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes + +# Minimal unattended +./install.sh -y + +# Skip service setup +./install.sh --no-service -y +``` + +--- + +## Docker Installation Scripts + +### `docker-install.sh` (Linux/macOS) + +Installs BamBuddy using Docker containers. + +**Options:** +``` +--path PATH Installation directory (default: ~/bambuddy) +--port PORT Port to expose (default: 8000) +--tz TIMEZONE Timezone (default: system timezone) +--build Build from source instead of using pre-built image +--yes, -y Non-interactive mode, accept defaults +``` + +**Examples:** +```bash +# Interactive installation +./docker-install.sh + +# Unattended with custom settings +./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes + +# Build from source +./docker-install.sh --build --yes +``` + +--- + +## Configuration Options + +All scripts support these configuration options: + +| Option | Description | Default | +|--------|-------------|---------| +| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) | +| Port | HTTP port for web interface | `8000` | +| Timezone | Server timezone | System timezone or `UTC` | +| Data Directory | Database and archives | `INSTALL_PATH/data` | +| Log Directory | Application logs | `INSTALL_PATH/logs` | +| Debug Mode | Enable verbose logging | `false` | +| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` | + +--- + +## Post-Installation + +### Accessing BamBuddy + +After installation, open your browser to: +``` +http://localhost:8000 +``` + +Or use the port you specified during installation. + +### Service Management + +**Linux (systemd):** +```bash +sudo systemctl status bambuddy # Check status +sudo systemctl start bambuddy # Start +sudo systemctl stop bambuddy # Stop +sudo systemctl restart bambuddy # Restart +sudo journalctl -u bambuddy -f # View logs +``` + +**macOS (launchd):** +```bash +launchctl list | grep bambuddy # Check status +launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist # Start +launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist # Stop +``` + +**Docker:** +```bash +docker compose ps # Check status +docker compose up -d # Start +docker compose down # Stop +docker compose restart # Restart +docker compose logs -f # View logs +``` + +### Updating + +**Native installation:** +```bash +cd /opt/bambuddy +git pull +source venv/bin/activate +pip install -r requirements.txt +cd frontend && npm ci && npm run build +sudo systemctl restart bambuddy # Linux +``` + +**Docker (pre-built image):** +```bash +cd ~/bambuddy +docker compose pull +docker compose up -d +``` + +**Docker (from source):** +```bash +cd ~/bambuddy +git pull +docker compose up -d --build +``` + +--- + +## Troubleshooting + +### Permission Denied (Linux) +Run with `sudo` or ensure your user has appropriate permissions: +```bash +sudo ./install.sh +``` + +### Docker: Printer Discovery Not Working +Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface. + +### Service Won't Start +Check logs for errors: +```bash +# Linux +sudo journalctl -u bambuddy -n 50 + +# Docker +docker compose logs bambuddy +``` + +### Port Already in Use +Choose a different port during installation or stop the conflicting service: +```bash +# Find what's using port 8000 +sudo lsof -i :8000 # Linux/macOS +``` + +--- + +## Requirements + +### Native Installation +- Python 3.10+ (automatically installed if missing) +- Node.js 18+ (automatically installed if missing) +- Git (automatically installed if missing) +- ~500MB disk space + +### Docker Installation +- Docker Engine 20+ or Docker Desktop +- ~1GB disk space (includes image) + +--- + +## Support + +- **Documentation:** https://wiki.bambuddy.cool +- **Discord:** https://discord.gg/aFS3ZfScHM +- **Issues:** https://github.com/maziggy/bambuddy/issues diff --git a/install/docker-install.sh b/install/docker-install.sh new file mode 100755 index 000000000..c8d48833a --- /dev/null +++ b/install/docker-install.sh @@ -0,0 +1,541 @@ +#!/usr/bin/env bash +# +# BamBuddy Docker Installation Script +# Supports: Linux (all distros), macOS +# +# Usage: +# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash +# Unattended: ./docker-install.sh --path /opt/bambuddy --port 8000 --yes +# +# Options: +# --path PATH Installation directory (default: /opt/bambuddy) +# --port PORT Port to expose (default: 8000) +# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only) +# --tz TIMEZONE Timezone (default: system timezone or UTC) +# --build Build from source instead of using pre-built image +# --yes, -y Non-interactive mode, accept defaults +# --help, -h Show this help message +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Default values +DEFAULT_INSTALL_PATH="/opt/bambuddy" +DEFAULT_PORT="8000" +DEFAULT_BIND_ADDRESS="0.0.0.0" + +# Script variables +INSTALL_PATH="" +PORT="" +BIND_ADDRESS="" +TIMEZONE="" +BUILD_FROM_SOURCE="false" +NON_INTERACTIVE="false" +OS_TYPE="" +DOCKER_CMD="" + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +print_banner() { + echo -e "${CYAN}" + echo "╔════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ____ _ _ _ ║" + echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║" + echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║" + echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║" + echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║" + echo "║ |___/ ║" + echo "║ ║" + echo "║ Docker Installation Script ║" + echo "║ ║" + echo "╚════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +prompt() { + local prompt_text="$1" + local default_value="$2" + local var_name="$3" + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + eval "$var_name=\"$default_value\"" + return + fi + + if [[ -n "$default_value" ]]; then + echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: " + else + echo -en "${BOLD}$prompt_text${NC}: " + fi + + read -r input + if [[ -z "$input" ]]; then + eval "$var_name=\"$default_value\"" + else + eval "$var_name=\"$input\"" + fi +} + +prompt_yes_no() { + local prompt_text="$1" + local default="$2" # y or n + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + [[ "$default" == "y" ]] && return 0 || return 1 + fi + + local yn_hint="[y/n]" + [[ "$default" == "y" ]] && yn_hint="[Y/n]" + [[ "$default" == "n" ]] && yn_hint="[y/N]" + + while true; do + echo -en "${BOLD}$prompt_text${NC} $yn_hint: " + read -r yn + [[ -z "$yn" ]] && yn="$default" + case "$yn" in + [Yy]* ) return 0;; + [Nn]* ) return 1;; + * ) echo "Please answer yes or no.";; + esac + done +} + +show_help() { + echo "BamBuddy Docker Installation Script" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --path PATH Installation directory (default: /opt/bambuddy)" + echo " --port PORT Port to expose (default: 8000)" + echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)" + echo " --tz TIMEZONE Timezone (default: system timezone or UTC)" + echo " --build Build from source instead of using pre-built image" + echo " --yes, -y Non-interactive mode, accept defaults" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " Interactive installation:" + echo " ./docker-install.sh" + echo "" + echo " Unattended installation with custom settings:" + echo " ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes" + echo "" + echo " Build from source:" + echo " ./docker-install.sh --build --yes" + exit 0 +} + +# ----------------------------------------------------------------------------- +# System Detection +# ----------------------------------------------------------------------------- + +detect_os() { + if [[ "$OSTYPE" == "darwin"* ]]; then + OS_TYPE="macos" + return + fi + + if [[ -f /etc/os-release ]]; then + OS_TYPE="linux" + else + log_error "Cannot detect operating system" + exit 1 + fi +} + +detect_docker() { + # Check for docker compose (v2) or docker-compose (v1) + if docker compose version &>/dev/null 2>&1; then + DOCKER_CMD="docker compose" + log_success "Found Docker Compose v2" + return 0 + elif docker-compose --version &>/dev/null 2>&1; then + DOCKER_CMD="docker-compose" + log_success "Found Docker Compose v1" + return 0 + fi + return 1 +} + +detect_timezone() { + if [[ -n "$TIMEZONE" ]]; then + return 0 + fi + + # Try to get system timezone (with error handling for set -e) + TIMEZONE="" + if [[ -f /etc/timezone ]]; then + TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true + fi + + if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then + TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true + fi + + if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then + TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true + fi + + # Default to UTC if not found (use if/then to avoid set -e issue with &&) + if [[ -z "$TIMEZONE" ]]; then + TIMEZONE="UTC" + fi + return 0 +} + +# ----------------------------------------------------------------------------- +# Installation Functions +# ----------------------------------------------------------------------------- + +install_docker() { + log_info "Docker not found, installing..." + + case "$OS_TYPE" in + linux) + # Use Docker's convenience script + curl -fsSL https://get.docker.com | sh + + # Add current user to docker group + if [[ -n "$SUDO_USER" ]]; then + sudo usermod -aG docker "$SUDO_USER" + log_warn "Added $SUDO_USER to docker group. You may need to log out and back in." + else + sudo usermod -aG docker "$USER" + log_warn "Added $USER to docker group. You may need to log out and back in." + fi + + # Start Docker service + sudo systemctl enable docker + sudo systemctl start docker + ;; + macos) + log_error "Docker Desktop not found." + log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop" + exit 1 + ;; + esac + + log_success "Docker installed" +} + +create_install_dir() { + log_info "Creating installation directory..." + + mkdir -p "$INSTALL_PATH" + cd "$INSTALL_PATH" + + log_success "Directory created: $INSTALL_PATH" +} + +download_compose_file() { + log_info "Downloading docker-compose.yml..." + + if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then + # Clone the full repo for building + if [[ -d ".git" ]]; then + log_info "Existing repository found, updating..." + git fetch origin + git reset --hard origin/main + else + git clone https://github.com/maziggy/bambuddy.git . + fi + else + # Just download the compose file + curl -fsSL -o docker-compose.yml \ + https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml + fi + + log_success "docker-compose.yml ready" +} + +create_env_file() { + log_info "Creating environment configuration..." + + cat > .env << EOF +# BamBuddy Docker Configuration +# Generated by docker-install.sh on $(date) + +# Port BamBuddy runs on +PORT=$PORT + +# Timezone +TZ=$TIMEZONE +EOF + + log_success "Environment file created" +} + +customize_compose() { + # Detect if we need to disable host networking (macOS/Windows in Docker Desktop) + if [[ "$OS_TYPE" == "macos" ]]; then + log_warn "Docker Desktop detected. Host networking is not supported." + log_info "Modifying docker-compose.yml for port mapping..." + + # Create a modified compose file for macOS + if [[ -f docker-compose.yml ]]; then + # Comment out network_mode: host and uncomment ports section + sed -i.bak \ + -e 's/^[[:space:]]*network_mode: host/# network_mode: host/' \ + -e 's/^[[:space:]]*#ports:/ ports:/' \ + -e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/ - "\${PORT:-8000}:8000"/' \ + docker-compose.yml + + log_warn "Printer discovery may not work. Add printers manually by IP address." + fi + fi +} + +start_container() { + log_info "Starting BamBuddy..." + + if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then + $DOCKER_CMD up -d --build + else + $DOCKER_CMD up -d + fi + + # Wait for container to start + log_info "Waiting for container to start..." + local max_attempts=15 + local attempt=0 + + while [[ $attempt -lt $max_attempts ]]; do + # Check if container is running (Up) + if $DOCKER_CMD ps | grep -q "Up"; then + log_success "BamBuddy container is running" + return 0 + fi + + # Check if container failed + if $DOCKER_CMD ps -a | grep -q "Exited"; then + log_error "Container failed to start" + log_info "Check logs with: $DOCKER_CMD logs bambuddy" + return 1 + fi + + sleep 2 + ((attempt++)) + done + + log_warn "Container may still be starting. Check with: $DOCKER_CMD ps" +} + +# ----------------------------------------------------------------------------- +# Main Installation Flow +# ----------------------------------------------------------------------------- + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --path) + INSTALL_PATH="$2" + shift 2 + ;; + --port) + PORT="$2" + shift 2 + ;; + --bind) + BIND_ADDRESS="$2" + shift 2 + ;; + --tz) + TIMEZONE="$2" + shift 2 + ;; + --build) + BUILD_FROM_SOURCE="true" + shift + ;; + --yes|-y) + NON_INTERACTIVE="true" + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $1" + show_help + ;; + esac + done +} + +gather_config() { + echo "" + echo -e "${BOLD}Installation Configuration${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + # Installation path + [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH + + # Port + [[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT + + # Bind address + if [[ -z "$BIND_ADDRESS" ]]; then + echo "" + echo "Network access:" + echo " 0.0.0.0 - Accessible from other devices on your network (recommended)" + echo " 127.0.0.1 - Only accessible from this machine" + prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS + fi + + # Timezone + detect_timezone + prompt "Timezone" "$TIMEZONE" TIMEZONE + + # Build from source? + if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then + if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then + BUILD_FROM_SOURCE="true" + fi + fi + + # Confirm + echo "" + echo -e "${BOLD}Installation Summary${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}" + echo -e " Port: ${GREEN}$PORT${NC}" + echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}" + echo -e " Timezone: ${GREEN}$TIMEZONE${NC}" + echo -e " Build source: ${GREEN}$BUILD_FROM_SOURCE${NC}" + echo "" + + if ! prompt_yes_no "Proceed with installation?" "y"; then + echo "Installation cancelled." + exit 0 + fi +} + +main() { + parse_args "$@" + print_banner + + # Check if running via pipe (curl | bash) - interactive mode won't work + if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then + log_error "Interactive mode requires a terminal." + log_info "When using 'curl | bash', you must use non-interactive mode:" + echo "" + echo " curl -fsSL URL | bash -s -- --yes" + echo "" + log_info "Or download and run directly:" + echo "" + echo " curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh" + echo "" + exit 1 + fi + + # Detect system + log_info "Detecting system..." + detect_os + log_success "Detected: $OS_TYPE" + + # Check for Docker + if ! command -v docker &>/dev/null; then + install_docker + fi + + if ! detect_docker; then + log_error "Docker Compose not found. Please install Docker Compose." + exit 1 + fi + + # Check if Docker daemon is running + if ! docker info &>/dev/null; then + log_error "Docker daemon is not running. Please start Docker and try again." + exit 1 + fi + + # Gather configuration + gather_config + + # Install steps + echo "" + echo -e "${BOLD}Starting Installation${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + create_install_dir + download_compose_file + create_env_file + customize_compose + start_container + + # Done! + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ Installation Complete! ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + # Show appropriate URL based on bind address + if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then + local ip_addr + ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="" + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)" + else + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + fi + echo "" + echo -e " ${BOLD}Manage container:${NC}" + echo -e " Status: cd $INSTALL_PATH && $DOCKER_CMD ps" + echo -e " Logs: cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy" + echo -e " Stop: cd $INSTALL_PATH && $DOCKER_CMD down" + echo -e " Start: cd $INSTALL_PATH && $DOCKER_CMD up -d" + echo -e " Restart: cd $INSTALL_PATH && $DOCKER_CMD restart" + echo "" + echo -e " ${BOLD}Update BamBuddy:${NC}" + if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then + echo -e " cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build" + else + echo -e " cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d" + fi + echo "" + echo -e " ${BOLD}Data location:${NC} Docker volumes (bambuddy_data, bambuddy_logs)" + echo "" + echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}" + echo "" + + if [[ "$OS_TYPE" == "macos" ]]; then + echo -e " ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop." + echo -e " Add printers manually using their IP address." + echo "" + fi +} + +main "$@" diff --git a/install/install.sh b/install/install.sh new file mode 100755 index 000000000..a6b15709d --- /dev/null +++ b/install/install.sh @@ -0,0 +1,883 @@ +#!/usr/bin/env bash +# +# BamBuddy Native Installation Script +# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS +# +# Usage: +# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash +# Unattended: ./install.sh --path /opt/bambuddy --port 8000 --yes +# +# Options: +# --path PATH Installation directory (default: /opt/bambuddy) +# --port PORT Port to listen on (default: 8000) +# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only) +# --tz TIMEZONE Timezone (default: system timezone or UTC) +# --data-dir PATH Data directory (default: INSTALL_PATH/data) +# --log-dir PATH Log directory (default: INSTALL_PATH/logs) +# --debug Enable debug mode +# --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO) +# --no-service Skip systemd service setup (Linux only) +# --set-system-tz Set system timezone to match (for unattended installs) +# --yes, -y Non-interactive mode, accept defaults +# --help, -h Show this help message +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Default values +DEFAULT_INSTALL_PATH="/opt/bambuddy" +DEFAULT_PORT="8000" +DEFAULT_BIND_ADDRESS="0.0.0.0" +DEFAULT_LOG_LEVEL="INFO" +DEFAULT_DEBUG="false" + +# Script variables +INSTALL_PATH="" +PORT="" +BIND_ADDRESS="" +TIMEZONE="" +DATA_DIR="" +LOG_DIR="" +DEBUG_MODE="" +LOG_LEVEL="" +SKIP_SERVICE="false" +SET_SYSTEM_TZ="" +NON_INTERACTIVE="false" +OS_TYPE="" +PKG_MANAGER="" +PYTHON_CMD="" +SERVICE_USER="bambuddy" + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +print_banner() { + echo -e "${CYAN}" + echo "╔════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ____ _ _ _ ║" + echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║" + echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║" + echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║" + echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║" + echo "║ |___/ ║" + echo "║ ║" + echo "║ Native Installation Script ║" + echo "║ ║" + echo "╚════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +prompt() { + local prompt_text="$1" + local default_value="$2" + local var_name="$3" + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + eval "$var_name=\"$default_value\"" + return + fi + + if [[ -n "$default_value" ]]; then + echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: " + else + echo -en "${BOLD}$prompt_text${NC}: " + fi + + read -r input + if [[ -z "$input" ]]; then + eval "$var_name=\"$default_value\"" + else + eval "$var_name=\"$input\"" + fi +} + +prompt_yes_no() { + local prompt_text="$1" + local default="$2" # y or n + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + [[ "$default" == "y" ]] && return 0 || return 1 + fi + + local yn_hint="[y/n]" + [[ "$default" == "y" ]] && yn_hint="[Y/n]" + [[ "$default" == "n" ]] && yn_hint="[y/N]" + + while true; do + echo -en "${BOLD}$prompt_text${NC} $yn_hint: " + read -r yn + [[ -z "$yn" ]] && yn="$default" + case "$yn" in + [Yy]* ) return 0;; + [Nn]* ) return 1;; + * ) echo "Please answer yes or no.";; + esac + done +} + +show_help() { + echo "BamBuddy Native Installation Script" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --path PATH Installation directory (default: /opt/bambuddy)" + echo " --port PORT Port to listen on (default: 8000)" + echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)" + echo " --tz TIMEZONE Timezone (default: system timezone or UTC)" + echo " --data-dir PATH Data directory (default: INSTALL_PATH/data)" + echo " --log-dir PATH Log directory (default: INSTALL_PATH/logs)" + echo " --debug Enable debug mode" + echo " --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)" + echo " --no-service Skip systemd service setup (Linux only)" + echo " --set-system-tz Set system timezone to match (for unattended installs)" + echo " --yes, -y Non-interactive mode, accept defaults" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " Interactive installation:" + echo " ./install.sh" + echo "" + echo " Unattended installation with custom settings:" + echo " ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes" + echo "" + echo " Minimal unattended installation:" + echo " ./install.sh -y" + exit 0 +} + +# ----------------------------------------------------------------------------- +# System Detection +# ----------------------------------------------------------------------------- + +detect_os() { + if [[ "$OSTYPE" == "darwin"* ]]; then + OS_TYPE="macos" + PKG_MANAGER="brew" + return + fi + + if [[ -f /etc/os-release ]]; then + . /etc/os-release + case "$ID" in + ubuntu|debian|raspbian|linuxmint|pop) + OS_TYPE="debian" + PKG_MANAGER="apt" + ;; + fedora|rhel|centos|rocky|almalinux|ol) + OS_TYPE="rhel" + if command -v dnf &>/dev/null; then + PKG_MANAGER="dnf" + else + PKG_MANAGER="yum" + fi + ;; + arch|manjaro|endeavouros) + OS_TYPE="arch" + PKG_MANAGER="pacman" + ;; + opensuse*|sles) + OS_TYPE="suse" + PKG_MANAGER="zypper" + ;; + *) + log_error "Unsupported Linux distribution: $ID" + exit 1 + ;; + esac + else + log_error "Cannot detect operating system" + exit 1 + fi +} + +detect_python() { + # Try python3 first, then python + if command -v python3 &>/dev/null; then + PYTHON_CMD="python3" + elif command -v python &>/dev/null; then + local version + version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1) + if [[ "$version" -ge 3 ]]; then + PYTHON_CMD="python" + fi + fi + + if [[ -z "$PYTHON_CMD" ]]; then + return 1 + fi + + # Check version >= 3.10 + local version + version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + local major minor + major=$(echo "$version" | cut -d'.' -f1) + minor=$(echo "$version" | cut -d'.' -f2) + + if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then + log_warn "Python $version found, but 3.10+ is required" + return 1 + fi + + log_success "Found Python $version" + return 0 +} + +detect_timezone() { + if [[ -n "$TIMEZONE" ]]; then + return 0 + fi + + # Try to get system timezone (with error handling for set -e) + TIMEZONE="" + if [[ -f /etc/timezone ]]; then + TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true + fi + + if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then + TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true + fi + + if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then + TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true + fi + + # Default to UTC if not found (use if/then to avoid set -e issue with &&) + if [[ -z "$TIMEZONE" ]]; then + TIMEZONE="UTC" + fi + return 0 +} + +# ----------------------------------------------------------------------------- +# Package Installation +# ----------------------------------------------------------------------------- + +install_dependencies() { + log_info "Installing system dependencies..." + + case "$PKG_MANAGER" in + apt) + sudo apt-get update + sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg + ;; + dnf|yum) + sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg + ;; + pacman) + sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg + ;; + zypper) + sudo zypper install -y python3 python3-pip git curl ffmpeg + ;; + brew) + # Check if Homebrew is installed + if ! command -v brew &>/dev/null; then + log_error "Homebrew not found. Please install it first: https://brew.sh" + exit 1 + fi + brew install python git curl ffmpeg + ;; + esac + + log_success "System dependencies installed" +} + +# ----------------------------------------------------------------------------- +# Installation Steps +# ----------------------------------------------------------------------------- + +create_user() { + if [[ "$OS_TYPE" == "macos" ]]; then + return # Skip user creation on macOS + fi + + if id "$SERVICE_USER" &>/dev/null; then + log_info "User '$SERVICE_USER' already exists" + return + fi + + log_info "Creating service user '$SERVICE_USER'..." + sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER" + log_success "Service user created" +} + +download_bambuddy() { + log_info "Downloading BamBuddy..." + + if [[ -d "$INSTALL_PATH/.git" ]]; then + log_info "Existing installation found, updating..." + # Add safe.directory to avoid "dubious ownership" error when running as root + git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true + cd "$INSTALL_PATH" + git fetch origin + git reset --hard origin/main + # Ensure correct ownership after update + sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true + else + sudo mkdir -p "$INSTALL_PATH" + sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true + git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH" + sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true + fi + + log_success "BamBuddy downloaded to $INSTALL_PATH" +} + +setup_virtualenv() { + log_info "Setting up Python virtual environment..." + + cd "$INSTALL_PATH" + + if [[ "$OS_TYPE" == "macos" ]]; then + $PYTHON_CMD -m venv venv + source venv/bin/activate + else + sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv + source venv/bin/activate + fi + + pip install --upgrade pip + pip install -r requirements.txt + + log_success "Virtual environment configured" +} + +check_node_version() { + # Returns 0 if Node.js 20+ is available, 1 otherwise + if ! command -v node &>/dev/null; then + return 1 + fi + + local version + version=$(node --version 2>/dev/null | sed 's/^v//') + local major + major=$(echo "$version" | cut -d'.' -f1) + + if [[ "$major" -ge 20 ]]; then + log_success "Found Node.js v$version" + return 0 + else + log_warn "Found Node.js v$version (need 20+)" + return 1 + fi +} + +install_nodejs() { + log_info "Installing Node.js 22..." + case "$PKG_MANAGER" in + apt) + # Remove old nodejs if present + sudo apt-get remove -y nodejs npm 2>/dev/null || true + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + ;; + dnf|yum) + sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true + curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - + sudo $PKG_MANAGER install -y nodejs + ;; + pacman) + sudo pacman -S --noconfirm nodejs npm + ;; + zypper) + sudo zypper install -y nodejs22 + ;; + brew) + brew install node@22 + brew link --overwrite node@22 + ;; + *) + log_error "Please install Node.js 20+ manually: https://nodejs.org/" + exit 1 + ;; + esac + # Refresh PATH + hash -r 2>/dev/null || true +} + +build_frontend() { + log_info "Building frontend..." + + cd "$INSTALL_PATH/frontend" + + # Check for Node.js 20+ + if ! check_node_version; then + install_nodejs + # Verify installation + if ! check_node_version; then + log_error "Failed to install Node.js 20+. Please install manually." + exit 1 + fi + fi + + npm ci + npm run build + + log_success "Frontend built" +} + +create_directories() { + log_info "Creating data directories..." + + sudo mkdir -p "$DATA_DIR" "$LOG_DIR" + + if [[ "$OS_TYPE" != "macos" ]]; then + sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR" + fi + + log_success "Directories created" +} + +create_env_file() { + log_info "Creating environment configuration..." + + local env_file="$INSTALL_PATH/.env" + + # Note: Only include settings recognized by the app's pydantic Settings class + # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service + cat > /tmp/bambuddy.env << EOF +# BamBuddy Configuration +# Generated by install.sh on $(date) + +# Debug mode (true = verbose logging) +DEBUG=$DEBUG_MODE + +# Log level (only used when DEBUG=false) +# Options: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL=$LOG_LEVEL + +# Enable file logging +LOG_TO_FILE=true +EOF + + sudo mv /tmp/bambuddy.env "$env_file" + if [[ "$OS_TYPE" != "macos" ]]; then + sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file" + fi + sudo chmod 600 "$env_file" + + log_success "Environment file created at $env_file" +} + +create_systemd_service() { + if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then + return + fi + + log_info "Creating systemd service..." + + cat > /tmp/bambuddy.service << EOF +[Unit] +Description=BamBuddy - Bambu Lab Print Management +Documentation=https://github.com/maziggy/bambuddy +After=network.target + +[Service] +Type=simple +User=$SERVICE_USER +Group=$SERVICE_USER +WorkingDirectory=$INSTALL_PATH + +# App settings from .env file +EnvironmentFile=$INSTALL_PATH/.env + +# Service settings (not in .env to avoid pydantic validation errors) +Environment="DATA_DIR=$DATA_DIR" +Environment="LOG_DIR=$LOG_DIR" +Environment="TZ=$TIMEZONE" + +ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH + +[Install] +WantedBy=multi-user.target +EOF + + sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service + sudo systemctl daemon-reload + + log_success "Systemd service created" + + if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then + sudo systemctl enable bambuddy + log_success "Service enabled" + fi + + if prompt_yes_no "Start BamBuddy now?" "y"; then + sudo systemctl start bambuddy + sleep 2 + if sudo systemctl is-active --quiet bambuddy; then + log_success "BamBuddy is running" + else + log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f" + fi + fi +} + +create_launchd_service() { + if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then + return + fi + + log_info "Creating launchd service..." + + local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist" + + cat > "$plist_path" << EOF + + + + + Label + com.bambuddy.app + ProgramArguments + + $INSTALL_PATH/venv/bin/uvicorn + backend.app.main:app + --host + $BIND_ADDRESS + --port + $PORT + + WorkingDirectory + $INSTALL_PATH + EnvironmentVariables + + DEBUG + $DEBUG_MODE + LOG_LEVEL + $LOG_LEVEL + DATA_DIR + $DATA_DIR + LOG_DIR + $LOG_DIR + TZ + $TIMEZONE + + RunAtLoad + + KeepAlive + + StandardOutPath + $LOG_DIR/bambuddy.log + StandardErrorPath + $LOG_DIR/bambuddy.error.log + + +EOF + + log_success "Launchd plist created at $plist_path" + + if prompt_yes_no "Load BamBuddy service now?" "y"; then + launchctl load "$plist_path" + sleep 2 + if launchctl list | grep -q "com.bambuddy.app"; then + log_success "BamBuddy is running" + else + log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log" + fi + fi +} + +# ----------------------------------------------------------------------------- +# Main Installation Flow +# ----------------------------------------------------------------------------- + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --path) + INSTALL_PATH="$2" + shift 2 + ;; + --port) + PORT="$2" + shift 2 + ;; + --bind) + BIND_ADDRESS="$2" + shift 2 + ;; + --tz) + TIMEZONE="$2" + shift 2 + ;; + --data-dir) + DATA_DIR="$2" + shift 2 + ;; + --log-dir) + LOG_DIR="$2" + shift 2 + ;; + --debug) + DEBUG_MODE="true" + shift + ;; + --log-level) + LOG_LEVEL="$2" + shift 2 + ;; + --no-service) + SKIP_SERVICE="true" + shift + ;; + --set-system-tz) + SET_SYSTEM_TZ="true" + shift + ;; + --yes|-y) + NON_INTERACTIVE="true" + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $1" + show_help + ;; + esac + done +} + +gather_config() { + echo "" + echo -e "${BOLD}Installation Configuration${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + # Installation path + [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH + + # Port + [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT + + # Bind address + if [[ -z "$BIND_ADDRESS" ]]; then + echo "" + echo "Network access:" + echo " 0.0.0.0 - Accessible from other devices on your network (recommended)" + echo " 127.0.0.1 - Only accessible from this machine" + prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS + fi + + # Timezone + detect_timezone + prompt "Timezone" "$TIMEZONE" TIMEZONE + + # Offer to set system timezone if different from current (skip if already set via --set-system-tz) + if [[ -z "$SET_SYSTEM_TZ" ]]; then + local current_tz + current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true + if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then + # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used + if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then + SET_SYSTEM_TZ="true" + else + SET_SYSTEM_TZ="false" + fi + else + SET_SYSTEM_TZ="false" + fi + fi + + # Data directory + [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data" + prompt "Data directory" "$DATA_DIR" DATA_DIR + + # Log directory + [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs" + prompt "Log directory" "$LOG_DIR" LOG_DIR + + # Debug mode + if [[ -z "$DEBUG_MODE" ]]; then + if prompt_yes_no "Enable debug mode?" "n"; then + DEBUG_MODE="true" + else + DEBUG_MODE="false" + fi + fi + + # Log level + if [[ -z "$LOG_LEVEL" ]]; then + echo "" + echo "Log levels: DEBUG, INFO, WARNING, ERROR" + prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL + fi + + # Confirm + echo "" + echo -e "${BOLD}Installation Summary${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}" + echo -e " Port: ${GREEN}$PORT${NC}" + echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}" + echo -e " Timezone: ${GREEN}$TIMEZONE${NC}" + echo -e " Data dir: ${GREEN}$DATA_DIR${NC}" + echo -e " Log dir: ${GREEN}$LOG_DIR${NC}" + echo -e " Debug mode: ${GREEN}$DEBUG_MODE${NC}" + echo -e " Log level: ${GREEN}$LOG_LEVEL${NC}" + echo "" + + if ! prompt_yes_no "Proceed with installation?" "y"; then + echo "Installation cancelled." + exit 0 + fi +} + +main() { + parse_args "$@" + print_banner + + # Check if running via pipe (curl | bash) - interactive mode won't work + if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then + log_error "Interactive mode requires a terminal." + log_info "When using 'curl | bash', you must use non-interactive mode:" + echo "" + echo " curl -fsSL URL | bash -s -- --yes" + echo "" + log_info "Or download and run directly:" + echo "" + echo " curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh" + echo "" + exit 1 + fi + + # Check for root (we need sudo for some operations) + if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then + log_warn "Running as root. Consider using a regular user with sudo privileges." + fi + + # Detect system + log_info "Detecting system..." + detect_os + log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)" + + # Check/install Python + if ! detect_python; then + log_info "Python 3.10+ not found, will install..." + fi + + # Gather configuration + gather_config + + # Install steps + echo "" + echo -e "${BOLD}Starting Installation${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + install_dependencies + detect_python || { log_error "Failed to install Python"; exit 1; } + + # Set system timezone if requested + if [[ "$SET_SYSTEM_TZ" == "true" ]]; then + log_info "Setting system timezone to $TIMEZONE..." + if [[ "$OS_TYPE" == "macos" ]]; then + sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true + else + sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true + fi + log_success "System timezone set to $TIMEZONE" + fi + + if [[ "$OS_TYPE" != "macos" ]]; then + create_user + else + SERVICE_USER="$USER" + fi + + download_bambuddy + setup_virtualenv + build_frontend + create_directories + create_env_file + + if [[ "$OS_TYPE" == "macos" ]]; then + create_launchd_service + else + create_systemd_service + fi + + # Done! + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ Installation Complete! ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + # Show appropriate URL based on bind address + if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then + local ip_addr + ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="" + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)" + else + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + fi + echo "" + if [[ "$OS_TYPE" == "macos" ]]; then + echo -e " ${BOLD}Manage service:${NC}" + echo -e " Start: launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist" + echo -e " Stop: launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist" + echo -e " Logs: tail -f $LOG_DIR/bambuddy.log" + else + echo -e " ${BOLD}Manage service:${NC}" + echo -e " Status: sudo systemctl status bambuddy" + echo -e " Start: sudo systemctl start bambuddy" + echo -e " Stop: sudo systemctl stop bambuddy" + echo -e " Logs: sudo journalctl -u bambuddy -f" + fi + echo "" + echo -e " ${BOLD}Update BamBuddy:${NC}" + echo -e " cd $INSTALL_PATH && git pull && source venv/bin/activate" + echo -e " pip install -r requirements.txt && cd frontend && npm ci && npm run build" + if [[ "$OS_TYPE" != "macos" ]]; then + echo -e " sudo systemctl restart bambuddy" + fi + echo "" + echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}" + echo "" +} + +main "$@" diff --git a/static/assets/index-BJQC4Kk-.js b/static/assets/index-BJQC4Kk-.js new file mode 100644 index 000000000..d36ca8b4f --- /dev/null +++ b/static/assets/index-BJQC4Kk-.js @@ -0,0 +1,8665 @@ +function Pae(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var n_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Uo(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var _T={exports:{}},Qx={};var SU;function Rae(){if(SU)return Qx;SU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return Qx.Fragment=e,Qx.jsx=n,Qx.jsxs=n,Qx}var NU;function Dae(){return NU||(NU=1,_T.exports=Rae()),_T.exports}var a=Dae(),wT={exports:{}},gn={};var MU;function Oae(){if(MU)return gn;MU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),m=Symbol.iterator;function g(z){return z===null||typeof z!="object"?null:(z=m&&z[m]||z["@@iterator"],typeof z=="function"?z:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y=Object.assign,b={};function v(z,W,se){this.props=z,this.context=W,this.refs=b,this.updater=se||x}v.prototype.isReactComponent={},v.prototype.setState=function(z,W){if(typeof z!="object"&&typeof z!="function"&&z!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,z,W,"setState")},v.prototype.forceUpdate=function(z){this.updater.enqueueForceUpdate(this,z,"forceUpdate")};function N(){}N.prototype=v.prototype;function S(z,W,se){this.props=z,this.context=W,this.refs=b,this.updater=se||x}var T=S.prototype=new N;T.constructor=S,y(T,v.prototype),T.isPureReactComponent=!0;var E=Array.isArray;function k(){}var C={H:null,A:null,T:null,S:null},D=Object.prototype.hasOwnProperty;function A(z,W,se){var ae=se.ref;return{$$typeof:t,type:z,key:W,ref:ae!==void 0?ae:null,props:se}}function R(z,W){return A(z.type,W,z.props)}function F(z){return typeof z=="object"&&z!==null&&z.$$typeof===t}function O(z){var W={"=":"=0",":":"=2"};return"$"+z.replace(/[=:]/g,function(se){return W[se]})}var q=/\/+/g;function j(z,W){return typeof z=="object"&&z!==null&&z.key!=null?O(""+z.key):W.toString(36)}function G(z){switch(z.status){case"fulfilled":return z.value;case"rejected":throw z.reason;default:switch(typeof z.status=="string"?z.then(k,k):(z.status="pending",z.then(function(W){z.status==="pending"&&(z.status="fulfilled",z.value=W)},function(W){z.status==="pending"&&(z.status="rejected",z.reason=W)})),z.status){case"fulfilled":return z.value;case"rejected":throw z.reason}}throw z}function Y(z,W,se,ae,de){var me=typeof z;(me==="undefined"||me==="boolean")&&(z=null);var xe=!1;if(z===null)xe=!0;else switch(me){case"bigint":case"string":case"number":xe=!0;break;case"object":switch(z.$$typeof){case t:case e:xe=!0;break;case d:return xe=z._init,Y(xe(z._payload),W,se,ae,de)}}if(xe)return de=de(z),xe=ae===""?"."+j(z,0):ae,E(de)?(se="",xe!=null&&(se=xe.replace(q,"$&/")+"/"),Y(de,W,se,"",function(Ce){return Ce})):de!=null&&(F(de)&&(de=R(de,se+(de.key==null||z&&z.key===de.key?"":(""+de.key).replace(q,"$&/")+"/")+xe)),W.push(de)),1;xe=0;var be=ae===""?".":ae+":";if(E(z))for(var fe=0;fe>>1,ne=Y[te];if(0>>1;tei(se,Z))aei(de,se)?(Y[te]=de,Y[ae]=Z,te=ae):(Y[te]=se,Y[W]=Z,te=W);else if(aei(de,Z))Y[te]=de,Y[ae]=Z,te=ae;else break e}}return K}function i(Y,K){var Z=Y.sortIndex-K.sortIndex;return Z!==0?Z:Y.id-K.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,f=null,m=3,g=!1,x=!1,y=!1,b=!1,v=typeof setTimeout=="function"?setTimeout:null,N=typeof clearTimeout=="function"?clearTimeout:null,S=typeof setImmediate<"u"?setImmediate:null;function T(Y){for(var K=n(u);K!==null;){if(K.callback===null)r(u);else if(K.startTime<=Y)r(u),K.sortIndex=K.expirationTime,e(c,K);else break;K=n(u)}}function E(Y){if(y=!1,T(Y),!x)if(n(c)!==null)x=!0,k||(k=!0,O());else{var K=n(u);K!==null&&G(E,K.startTime-Y)}}var k=!1,C=-1,D=5,A=-1;function R(){return b?!0:!(t.unstable_now()-AY&&R());){var te=f.callback;if(typeof te=="function"){f.callback=null,m=f.priorityLevel;var ne=te(f.expirationTime<=Y);if(Y=t.unstable_now(),typeof ne=="function"){f.callback=ne,T(Y),K=!0;break t}f===n(c)&&r(c),T(Y)}else r(c);f=n(c)}if(f!==null)K=!0;else{var z=n(u);z!==null&&G(E,z.startTime-Y),K=!1}}break e}finally{f=null,m=Z,g=!1}K=void 0}}finally{K?O():k=!1}}}var O;if(typeof S=="function")O=function(){S(F)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,j=q.port2;q.port1.onmessage=F,O=function(){j.postMessage(null)}}else O=function(){v(F,0)};function G(Y,K){C=v(function(){Y(t.unstable_now())},K)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(Y){Y.callback=null},t.unstable_forceFrameRate=function(Y){0>Y||125te?(Y.sortIndex=Z,e(u,Y),n(c)===null&&Y===n(u)&&(y?(N(C),C=-1):y=!0,G(E,Z-te))):(Y.sortIndex=ne,e(c,Y),x||g||(x=!0,k||(k=!0,O()))),Y},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(Y){var K=m;return function(){var Z=m;m=K;try{return Y.apply(this,arguments)}finally{m=Z}}}})(MT)),MT}var CU;function Fae(){return CU||(CU=1,NT.exports=Iae()),NT.exports}var ET={exports:{}},Ta={};var AU;function Uae(){if(AU)return Ta;AU=1;var t=Ig();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),ET.exports=Uae(),ET.exports}var jU;function zae(){if(jU)return Zx;jU=1;var t=Fae(),e=Ig(),n=oq();function r(h){var p="https://react.dev/errors/"+h;if(1ne||(h.current=te[ne],te[ne]=null,ne--)}function se(h,p){ne++,te[ne]=h.current,h.current=p}var ae=z(null),de=z(null),me=z(null),xe=z(null);function be(h,p){switch(se(me,p),se(de,h),se(ae,null),p.nodeType){case 9:case 11:h=(h=p.documentElement)&&(h=h.namespaceURI)?qF(h):0;break;default:if(h=p.tagName,p=p.namespaceURI)p=qF(p),h=WF(p,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}W(ae),se(ae,h)}function fe(){W(ae),W(de),W(me)}function Ce(h){h.memoizedState!==null&&se(xe,h);var p=ae.current,_=WF(p,h.type);p!==_&&(se(de,h),se(ae,_))}function Ne(h){de.current===h&&(W(ae),W(de)),xe.current===h&&(W(xe),Wx._currentValue=Z)}var re,He;function H(h){if(re===void 0)try{throw Error()}catch(_){var p=_.stack.trim().match(/\n( *(at )?)/);re=p&&p[1]||"",He=-1<_.stack.indexOf(` + at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` +`+re+h+He}var B=!1;function X(h,p){if(!h||B)return"";B=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(p){var vt=function(){throw Error()};if(Object.defineProperty(vt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(vt,[])}catch(ft){var at=ft}Reflect.construct(h,[],vt)}else{try{vt.call()}catch(ft){at=ft}h.call(vt.prototype)}}else{try{throw Error()}catch(ft){at=ft}(vt=h())&&typeof vt.catch=="function"&&vt.catch(function(){})}}catch(ft){if(ft&&at&&typeof ft.stack=="string")return[ft.stack,at.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var P=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");P&&P.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var L=M.DetermineComponentFrameRoot(),J=L[0],pe=L[1];if(J&&pe){var ze=J.split(` +`),rt=pe.split(` +`);for(P=M=0;MP||ze[M]!==rt[P]){var xt=` +`+ze[M].replace(" at new "," at ");return h.displayName&&xt.includes("")&&(xt=xt.replace("",h.displayName)),xt}while(1<=M&&0<=P);break}}}finally{B=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?H(_):""}function U(h,p){switch(h.tag){case 26:case 27:case 5:return H(h.type);case 16:return H("Lazy");case 13:return h.child!==p&&p!==null?H("Suspense Fallback"):H("Suspense");case 19:return H("SuspenseList");case 0:case 15:return X(h.type,!1);case 11:return X(h.type.render,!1);case 1:return X(h.type,!0);case 31:return H("Activity");default:return""}}function Q(h){try{var p="",_=null;do p+=U(h,_),_=h,h=h.return;while(h);return p}catch(M){return` +Error generating stack: `+M.message+` +`+M.stack}}var le=Object.prototype.hasOwnProperty,Pe=t.unstable_scheduleCallback,Oe=t.unstable_cancelCallback,je=t.unstable_shouldYield,$=t.unstable_requestPaint,I=t.unstable_now,V=t.unstable_getCurrentPriorityLevel,ie=t.unstable_ImmediatePriority,Se=t.unstable_UserBlockingPriority,ye=t.unstable_NormalPriority,$e=t.unstable_LowPriority,Ue=t.unstable_IdlePriority,Ge=t.log,We=t.unstable_setDisableYieldValue,Ye=null,oe=null;function ve(h){if(typeof Ge=="function"&&We(h),oe&&typeof oe.setStrictMode=="function")try{oe.setStrictMode(Ye,h)}catch{}}var Le=Math.clz32?Math.clz32:_e,Ke=Math.log,lt=Math.LN2;function _e(h){return h>>>=0,h===0?32:31-(Ke(h)/lt|0)|0}var ce=256,ee=262144,ke=4194304;function De(h){var p=h&42;if(p!==0)return p;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function Ae(h,p,_){var M=h.pendingLanes;if(M===0)return 0;var P=0,L=h.suspendedLanes,J=h.pingedLanes;h=h.warmLanes;var pe=M&134217727;return pe!==0?(M=pe&~L,M!==0?P=De(M):(J&=pe,J!==0?P=De(J):_||(_=pe&~h,_!==0&&(P=De(_))))):(pe=M&~L,pe!==0?P=De(pe):J!==0?P=De(J):_||(_=M&~h,_!==0&&(P=De(_)))),P===0?0:p!==0&&p!==P&&(p&L)===0&&(L=P&-P,_=p&-p,L>=_||L===32&&(_&4194048)!==0)?p:P}function Je(h,p){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&p)===0}function gt(h,p){switch(h){case 1:case 2:case 4:case 8:case 64:return p+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return p+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function At(){var h=ke;return ke<<=1,(ke&62914560)===0&&(ke=4194304),h}function jt(h){for(var p=[],_=0;31>_;_++)p.push(h);return p}function ln(h,p){h.pendingLanes|=p,p!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function Lt(h,p,_,M,P,L){var J=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var pe=h.entanglements,ze=h.expirationTimes,rt=h.hiddenUpdates;for(_=J&~_;0<_;){var xt=31-Le(_),vt=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Wt=/[\n"\\]/g;function dt(h){return h.replace(Wt,function(p){return"\\"+p.charCodeAt(0).toString(16)+" "})}function Kt(h,p,_,M,P,L,J,pe){h.name="",J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"?h.type=J:h.removeAttribute("type"),p!=null?J==="number"?(p===0&&h.value===""||h.value!=p)&&(h.value=""+cn(p)):h.value!==""+cn(p)&&(h.value=""+cn(p)):J!=="submit"&&J!=="reset"||h.removeAttribute("value"),p!=null?En(h,J,cn(p)):_!=null?En(h,J,cn(_)):M!=null&&h.removeAttribute("value"),P==null&&L!=null&&(h.defaultChecked=!!L),P!=null&&(h.checked=P&&typeof P!="function"&&typeof P!="symbol"),pe!=null&&typeof pe!="function"&&typeof pe!="symbol"&&typeof pe!="boolean"?h.name=""+cn(pe):h.removeAttribute("name")}function sn(h,p,_,M,P,L,J,pe){if(L!=null&&typeof L!="function"&&typeof L!="symbol"&&typeof L!="boolean"&&(h.type=L),p!=null||_!=null){if(!(L!=="submit"&&L!=="reset"||p!=null)){$t(h);return}_=_!=null?""+cn(_):"",p=p!=null?""+cn(p):_,pe||p===h.value||(h.value=p),h.defaultValue=p}M=M??P,M=typeof M!="function"&&typeof M!="symbol"&&!!M,h.checked=pe?h.checked:!!M,h.defaultChecked=!!M,J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(h.name=J),$t(h)}function En(h,p,_){p==="number"&&Et(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function On(h,p,_,M){if(h=h.options,p){p={};for(var P=0;P<_.length;P++)p["$"+_[P]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),_M=!1;if(ic)try{var dx={};Object.defineProperty(dx,"passive",{get:function(){_M=!0}}),window.addEventListener("test",dx,dx),window.removeEventListener("test",dx,dx)}catch{_M=!1}var Mu=null,wM=null,Ky=null;function WL(){if(Ky)return Ky;var h,p=wM,_=p.length,M,P="value"in Mu?Mu.value:Mu.textContent,L=P.length;for(h=0;h<_&&p[h]===P[h];h++);var J=_-h;for(M=1;M<=J&&p[_-M]===P[L-M];M++);return Ky=P.slice(h,1=mx),JL=" ",eI=!1;function tI(h,p){switch(h){case"keyup":return eie.indexOf(p.keyCode)!==-1;case"keydown":return p.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function nI(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var hm=!1;function nie(h,p){switch(h){case"compositionend":return nI(p);case"keypress":return p.which!==32?null:(eI=!0,JL);case"textInput":return h=p.data,h===JL&&eI?null:h;default:return null}}function rie(h,p){if(hm)return h==="compositionend"||!TM&&tI(h,p)?(h=WL(),Ky=wM=Mu=null,hm=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(p.ctrlKey||p.altKey||p.metaKey)||p.ctrlKey&&p.altKey){if(p.char&&1=p)return{node:_,offset:p-h};h=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=uI(_)}}function hI(h,p){return h&&p?h===p?!0:h&&h.nodeType===3?!1:p&&p.nodeType===3?hI(h,p.parentNode):"contains"in h?h.contains(p):h.compareDocumentPosition?!!(h.compareDocumentPosition(p)&16):!1:!1}function fI(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var p=Et(h.document);p instanceof h.HTMLIFrameElement;){try{var _=typeof p.contentWindow.location.href=="string"}catch{_=!1}if(_)h=p.contentWindow;else break;p=Et(h.document)}return p}function kM(h){var p=h&&h.nodeName&&h.nodeName.toLowerCase();return p&&(p==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||p==="textarea"||h.contentEditable==="true")}var die=ic&&"documentMode"in document&&11>=document.documentMode,fm=null,jM=null,bx=null,PM=!1;function mI(h,p,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;PM||fm==null||fm!==Et(M)||(M=fm,"selectionStart"in M&&kM(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),bx&&xx(bx,M)||(bx=M,M=Bv(jM,"onSelect"),0>=J,P-=J,gl=1<<32-Le(p)+P|_<yn?(In=qt,qt=null):In=qt.sibling;var Xn=at(Ze,qt,nt[yn],yt);if(Xn===null){qt===null&&(qt=In);break}h&&qt&&Xn.alternate===null&&p(Ze,qt),Ve=L(Xn,Ve,yn),Kn===null?Yt=Xn:Kn.sibling=Xn,Kn=Xn,qt=In}if(yn===nt.length)return _(Ze,qt),Bn&&sc(Ze,yn),Yt;if(qt===null){for(;ynyn?(In=qt,qt=null):In=qt.sibling;var Wu=at(Ze,qt,Xn.value,yt);if(Wu===null){qt===null&&(qt=In);break}h&&qt&&Wu.alternate===null&&p(Ze,qt),Ve=L(Wu,Ve,yn),Kn===null?Yt=Wu:Kn.sibling=Wu,Kn=Wu,qt=In}if(Xn.done)return _(Ze,qt),Bn&&sc(Ze,yn),Yt;if(qt===null){for(;!Xn.done;yn++,Xn=nt.next())Xn=vt(Ze,Xn.value,yt),Xn!==null&&(Ve=L(Xn,Ve,yn),Kn===null?Yt=Xn:Kn.sibling=Xn,Kn=Xn);return Bn&&sc(Ze,yn),Yt}for(qt=M(qt);!Xn.done;yn++,Xn=nt.next())Xn=ft(qt,Ze,yn,Xn.value,yt),Xn!==null&&(h&&Xn.alternate!==null&&qt.delete(Xn.key===null?yn:Xn.key),Ve=L(Xn,Ve,yn),Kn===null?Yt=Xn:Kn.sibling=Xn,Kn=Xn);return h&&qt.forEach(function(jae){return p(Ze,jae)}),Bn&&sc(Ze,yn),Yt}function fr(Ze,Ve,nt,yt){if(typeof nt=="object"&&nt!==null&&nt.type===y&&nt.key===null&&(nt=nt.props.children),typeof nt=="object"&&nt!==null){switch(nt.$$typeof){case g:e:{for(var Yt=nt.key;Ve!==null;){if(Ve.key===Yt){if(Yt=nt.type,Yt===y){if(Ve.tag===7){_(Ze,Ve.sibling),yt=P(Ve,nt.props.children),yt.return=Ze,Ze=yt;break e}}else if(Ve.elementType===Yt||typeof Yt=="object"&&Yt!==null&&Yt.$$typeof===D&&Ch(Yt)===Ve.type){_(Ze,Ve.sibling),yt=P(Ve,nt.props),Nx(yt,nt),yt.return=Ze,Ze=yt;break e}_(Ze,Ve);break}else p(Ze,Ve);Ve=Ve.sibling}nt.type===y?(yt=Sh(nt.props.children,Ze.mode,yt,nt.key),yt.return=Ze,Ze=yt):(yt=iv(nt.type,nt.key,nt.props,null,Ze.mode,yt),Nx(yt,nt),yt.return=Ze,Ze=yt)}return J(Ze);case x:e:{for(Yt=nt.key;Ve!==null;){if(Ve.key===Yt)if(Ve.tag===4&&Ve.stateNode.containerInfo===nt.containerInfo&&Ve.stateNode.implementation===nt.implementation){_(Ze,Ve.sibling),yt=P(Ve,nt.children||[]),yt.return=Ze,Ze=yt;break e}else{_(Ze,Ve);break}else p(Ze,Ve);Ve=Ve.sibling}yt=UM(nt,Ze.mode,yt),yt.return=Ze,Ze=yt}return J(Ze);case D:return nt=Ch(nt),fr(Ze,Ve,nt,yt)}if(G(nt))return Vt(Ze,Ve,nt,yt);if(O(nt)){if(Yt=O(nt),typeof Yt!="function")throw Error(r(150));return nt=Yt.call(nt),rn(Ze,Ve,nt,yt)}if(typeof nt.then=="function")return fr(Ze,Ve,dv(nt),yt);if(nt.$$typeof===S)return fr(Ze,Ve,ov(Ze,nt),yt);hv(Ze,nt)}return typeof nt=="string"&&nt!==""||typeof nt=="number"||typeof nt=="bigint"?(nt=""+nt,Ve!==null&&Ve.tag===6?(_(Ze,Ve.sibling),yt=P(Ve,nt),yt.return=Ze,Ze=yt):(_(Ze,Ve),yt=FM(nt,Ze.mode,yt),yt.return=Ze,Ze=yt),J(Ze)):_(Ze,Ve)}return function(Ze,Ve,nt,yt){try{Sx=0;var Yt=fr(Ze,Ve,nt,yt);return Nm=null,Yt}catch(qt){if(qt===Sm||qt===cv)throw qt;var Kn=js(29,qt,null,Ze.mode);return Kn.lanes=yt,Kn.return=Ze,Kn}finally{}}}var kh=II(!0),FI=II(!1),ku=!1;function QM(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ZM(h,p){h=h.updateQueue,p.updateQueue===h&&(p.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function ju(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Pu(h,p,_){var M=h.updateQueue;if(M===null)return null;if(M=M.shared,(Jn&2)!==0){var P=M.pending;return P===null?p.next=p:(p.next=P.next,P.next=p),M.pending=p,p=rv(h),_I(h,null,_),p}return nv(h,M,p,_),rv(h)}function Mx(h,p,_){if(p=p.updateQueue,p!==null&&(p=p.shared,(_&4194048)!==0)){var M=p.lanes;M&=h.pendingLanes,_|=M,p.lanes=_,en(h,_)}}function JM(h,p){var _=h.updateQueue,M=h.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var P=null,L=null;if(_=_.firstBaseUpdate,_!==null){do{var J={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};L===null?P=L=J:L=L.next=J,_=_.next}while(_!==null);L===null?P=L=p:L=L.next=p}else P=L=p;_={baseState:M.baseState,firstBaseUpdate:P,lastBaseUpdate:L,shared:M.shared,callbacks:M.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=p:h.next=p,_.lastBaseUpdate=p}var eE=!1;function Ex(){if(eE){var h=wm;if(h!==null)throw h}}function Tx(h,p,_,M){eE=!1;var P=h.updateQueue;ku=!1;var L=P.firstBaseUpdate,J=P.lastBaseUpdate,pe=P.shared.pending;if(pe!==null){P.shared.pending=null;var ze=pe,rt=ze.next;ze.next=null,J===null?L=rt:J.next=rt,J=ze;var xt=h.alternate;xt!==null&&(xt=xt.updateQueue,pe=xt.lastBaseUpdate,pe!==J&&(pe===null?xt.firstBaseUpdate=rt:pe.next=rt,xt.lastBaseUpdate=ze))}if(L!==null){var vt=P.baseState;J=0,xt=rt=ze=null,pe=L;do{var at=pe.lane&-536870913,ft=at!==pe.lane;if(ft?(Ln&at)===at:(M&at)===at){at!==0&&at===_m&&(eE=!0),xt!==null&&(xt=xt.next={lane:0,tag:pe.tag,payload:pe.payload,callback:null,next:null});e:{var Vt=h,rn=pe;at=p;var fr=_;switch(rn.tag){case 1:if(Vt=rn.payload,typeof Vt=="function"){vt=Vt.call(fr,vt,at);break e}vt=Vt;break e;case 3:Vt.flags=Vt.flags&-65537|128;case 0:if(Vt=rn.payload,at=typeof Vt=="function"?Vt.call(fr,vt,at):Vt,at==null)break e;vt=f({},vt,at);break e;case 2:ku=!0}}at=pe.callback,at!==null&&(h.flags|=64,ft&&(h.flags|=8192),ft=P.callbacks,ft===null?P.callbacks=[at]:ft.push(at))}else ft={lane:at,tag:pe.tag,payload:pe.payload,callback:pe.callback,next:null},xt===null?(rt=xt=ft,ze=vt):xt=xt.next=ft,J|=at;if(pe=pe.next,pe===null){if(pe=P.shared.pending,pe===null)break;ft=pe,pe=ft.next,ft.next=null,P.lastBaseUpdate=ft,P.shared.pending=null}}while(!0);xt===null&&(ze=vt),P.baseState=ze,P.firstBaseUpdate=rt,P.lastBaseUpdate=xt,L===null&&(P.shared.lanes=0),Iu|=J,h.lanes=J,h.memoizedState=vt}}function UI(h,p){if(typeof h!="function")throw Error(r(191,h));h.call(p)}function zI(h,p){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)UI(_[h],p)}var Mm=z(null),fv=z(0);function BI(h,p){h=xc,se(fv,h),se(Mm,p),xc=h|p.baseLanes}function tE(){se(fv,xc),se(Mm,Mm.current)}function nE(){xc=fv.current,W(Mm),W(fv)}var Ps=z(null),oo=null;function Ru(h){var p=h.alternate;se(ci,ci.current&1),se(Ps,h),oo===null&&(p===null||Mm.current!==null||p.memoizedState!==null)&&(oo=h)}function rE(h){se(ci,ci.current),se(Ps,h),oo===null&&(oo=h)}function $I(h){h.tag===22?(se(ci,ci.current),se(Ps,h),oo===null&&(oo=h)):Du()}function Du(){se(ci,ci.current),se(Ps,Ps.current)}function Rs(h){W(Ps),oo===h&&(oo=null),W(ci)}var ci=z(0);function mv(h){for(var p=h;p!==null;){if(p.tag===13){var _=p.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||cT(_)||uT(_)))return p}else if(p.tag===19&&(p.memoizedProps.revealOrder==="forwards"||p.memoizedProps.revealOrder==="backwards"||p.memoizedProps.revealOrder==="unstable_legacy-backwards"||p.memoizedProps.revealOrder==="together")){if((p.flags&128)!==0)return p}else if(p.child!==null){p.child.return=p,p=p.child;continue}if(p===h)break;for(;p.sibling===null;){if(p.return===null||p.return===h)return null;p=p.return}p.sibling.return=p.return,p=p.sibling}return null}var cc=0,bn=null,dr=null,gi=null,pv=!1,Em=!1,jh=!1,gv=0,Cx=0,Tm=null,_ie=0;function Jr(){throw Error(r(321))}function iE(h,p){if(p===null)return!1;for(var _=0;_L?L:8;var J=Y.T,pe={};Y.T=pe,yE(h,!1,p,_);try{var ze=P(),rt=Y.S;if(rt!==null&&rt(pe,ze),ze!==null&&typeof ze=="object"&&typeof ze.then=="function"){var xt=vie(ze,M);kx(h,p,xt,Ls(h))}else kx(h,p,M,Ls(h))}catch(vt){kx(h,p,{then:function(){},status:"rejected",reason:vt},Ls())}finally{K.p=L,J!==null&&pe.types!==null&&(J.types=pe.types),Y.T=J}}function Eie(){}function xE(h,p,_,M){if(h.tag!==5)throw Error(r(476));var P=b5(h).queue;x5(h,P,p,Z,_===null?Eie:function(){return y5(h),_(M)})}function b5(h){var p=h.memoizedState;if(p!==null)return p;p={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:uc,lastRenderedState:Z},next:null};var _={};return p.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:uc,lastRenderedState:_},next:null},h.memoizedState=p,h=h.alternate,h!==null&&(h.memoizedState=p),p}function y5(h){var p=b5(h);p.next===null&&(p=h.alternate.memoizedState),kx(h,p.next.queue,{},Ls())}function bE(){return ha(Wx)}function v5(){return ui().memoizedState}function _5(){return ui().memoizedState}function Tie(h){for(var p=h.return;p!==null;){switch(p.tag){case 24:case 3:var _=Ls();h=ju(_);var M=Pu(p,h,_);M!==null&&(ds(M,p,_),Mx(M,p,_)),p={cache:WM()},h.payload=p;return}p=p.return}}function Cie(h,p,_){var M=Ls();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},wv(h)?S5(p,_):(_=LM(h,p,_,M),_!==null&&(ds(_,h,M),N5(_,p,M)))}function w5(h,p,_){var M=Ls();kx(h,p,_,M)}function kx(h,p,_,M){var P={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(wv(h))S5(p,P);else{var L=h.alternate;if(h.lanes===0&&(L===null||L.lanes===0)&&(L=p.lastRenderedReducer,L!==null))try{var J=p.lastRenderedState,pe=L(J,_);if(P.hasEagerState=!0,P.eagerState=pe,ks(pe,J))return nv(h,p,P,0),Sr===null&&tv(),!1}catch{}finally{}if(_=LM(h,p,P,M),_!==null)return ds(_,h,M),N5(_,p,M),!0}return!1}function yE(h,p,_,M){if(M={lane:2,revertLane:QE(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},wv(h)){if(p)throw Error(r(479))}else p=LM(h,_,M,2),p!==null&&ds(p,h,2)}function wv(h){var p=h.alternate;return h===bn||p!==null&&p===bn}function S5(h,p){Em=pv=!0;var _=h.pending;_===null?p.next=p:(p.next=_.next,_.next=p),h.pending=p}function N5(h,p,_){if((_&4194048)!==0){var M=p.lanes;M&=h.pendingLanes,_|=M,p.lanes=_,en(h,_)}}var jx={readContext:ha,use:bv,useCallback:Jr,useContext:Jr,useEffect:Jr,useImperativeHandle:Jr,useLayoutEffect:Jr,useInsertionEffect:Jr,useMemo:Jr,useReducer:Jr,useRef:Jr,useState:Jr,useDebugValue:Jr,useDeferredValue:Jr,useTransition:Jr,useSyncExternalStore:Jr,useId:Jr,useHostTransitionStatus:Jr,useFormState:Jr,useActionState:Jr,useOptimistic:Jr,useMemoCache:Jr,useCacheRefresh:Jr};jx.useEffectEvent=Jr;var M5={readContext:ha,use:bv,useCallback:function(h,p){return Ha().memoizedState=[h,p===void 0?null:p],h},useContext:ha,useEffect:l5,useImperativeHandle:function(h,p,_){_=_!=null?_.concat([h]):null,vv(4194308,4,h5.bind(null,p,h),_)},useLayoutEffect:function(h,p){return vv(4194308,4,h,p)},useInsertionEffect:function(h,p){vv(4,2,h,p)},useMemo:function(h,p){var _=Ha();p=p===void 0?null:p;var M=h();if(jh){ve(!0);try{h()}finally{ve(!1)}}return _.memoizedState=[M,p],M},useReducer:function(h,p,_){var M=Ha();if(_!==void 0){var P=_(p);if(jh){ve(!0);try{_(p)}finally{ve(!1)}}}else P=p;return M.memoizedState=M.baseState=P,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:P},M.queue=h,h=h.dispatch=Cie.bind(null,bn,h),[M.memoizedState,h]},useRef:function(h){var p=Ha();return h={current:h},p.memoizedState=h},useState:function(h){h=hE(h);var p=h.queue,_=w5.bind(null,bn,p);return p.dispatch=_,[h.memoizedState,_]},useDebugValue:pE,useDeferredValue:function(h,p){var _=Ha();return gE(_,h,p)},useTransition:function(){var h=hE(!1);return h=x5.bind(null,bn,h.queue,!0,!1),Ha().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,p,_){var M=bn,P=Ha();if(Bn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=p(),Sr===null)throw Error(r(349));(Ln&127)!==0||qI(M,p,_)}P.memoizedState=_;var L={value:_,getSnapshot:p};return P.queue=L,l5(KI.bind(null,M,L,h),[h]),M.flags|=2048,Cm(9,{destroy:void 0},WI.bind(null,M,L,_,p),null),_},useId:function(){var h=Ha(),p=Sr.identifierPrefix;if(Bn){var _=xl,M=gl;_=(M&~(1<<32-Le(M)-1)).toString(32)+_,p="_"+p+"R_"+_,_=gv++,0<_&&(p+="H"+_.toString(32)),p+="_"}else _=_ie++,p="_"+p+"r_"+_.toString(32)+"_";return h.memoizedState=p},useHostTransitionStatus:bE,useFormState:r5,useActionState:r5,useOptimistic:function(h){var p=Ha();p.memoizedState=p.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return p.queue=_,p=yE.bind(null,bn,!0,_),_.dispatch=p,[h,p]},useMemoCache:cE,useCacheRefresh:function(){return Ha().memoizedState=Tie.bind(null,bn)},useEffectEvent:function(h){var p=Ha(),_={impl:h};return p.memoizedState=_,function(){if((Jn&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},vE={readContext:ha,use:bv,useCallback:m5,useContext:ha,useEffect:mE,useImperativeHandle:f5,useInsertionEffect:u5,useLayoutEffect:d5,useMemo:p5,useReducer:yv,useRef:o5,useState:function(){return yv(uc)},useDebugValue:pE,useDeferredValue:function(h,p){var _=ui();return g5(_,dr.memoizedState,h,p)},useTransition:function(){var h=yv(uc)[0],p=ui().memoizedState;return[typeof h=="boolean"?h:Ax(h),p]},useSyncExternalStore:GI,useId:v5,useHostTransitionStatus:bE,useFormState:i5,useActionState:i5,useOptimistic:function(h,p){var _=ui();return QI(_,dr,h,p)},useMemoCache:cE,useCacheRefresh:_5};vE.useEffectEvent=c5;var E5={readContext:ha,use:bv,useCallback:m5,useContext:ha,useEffect:mE,useImperativeHandle:f5,useInsertionEffect:u5,useLayoutEffect:d5,useMemo:p5,useReducer:dE,useRef:o5,useState:function(){return dE(uc)},useDebugValue:pE,useDeferredValue:function(h,p){var _=ui();return dr===null?gE(_,h,p):g5(_,dr.memoizedState,h,p)},useTransition:function(){var h=dE(uc)[0],p=ui().memoizedState;return[typeof h=="boolean"?h:Ax(h),p]},useSyncExternalStore:GI,useId:v5,useHostTransitionStatus:bE,useFormState:s5,useActionState:s5,useOptimistic:function(h,p){var _=ui();return dr!==null?QI(_,dr,h,p):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:cE,useCacheRefresh:_5};E5.useEffectEvent=c5;function _E(h,p,_,M){p=h.memoizedState,_=_(M,p),_=_==null?p:f({},p,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var wE={enqueueSetState:function(h,p,_){h=h._reactInternals;var M=Ls(),P=ju(M);P.payload=p,_!=null&&(P.callback=_),p=Pu(h,P,M),p!==null&&(ds(p,h,M),Mx(p,h,M))},enqueueReplaceState:function(h,p,_){h=h._reactInternals;var M=Ls(),P=ju(M);P.tag=1,P.payload=p,_!=null&&(P.callback=_),p=Pu(h,P,M),p!==null&&(ds(p,h,M),Mx(p,h,M))},enqueueForceUpdate:function(h,p){h=h._reactInternals;var _=Ls(),M=ju(_);M.tag=2,p!=null&&(M.callback=p),p=Pu(h,M,_),p!==null&&(ds(p,h,_),Mx(p,h,_))}};function T5(h,p,_,M,P,L,J){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(M,L,J):p.prototype&&p.prototype.isPureReactComponent?!xx(_,M)||!xx(P,L):!0}function C5(h,p,_,M){h=p.state,typeof p.componentWillReceiveProps=="function"&&p.componentWillReceiveProps(_,M),typeof p.UNSAFE_componentWillReceiveProps=="function"&&p.UNSAFE_componentWillReceiveProps(_,M),p.state!==h&&wE.enqueueReplaceState(p,p.state,null)}function Ph(h,p){var _=p;if("ref"in p){_={};for(var M in p)M!=="ref"&&(_[M]=p[M])}if(h=h.defaultProps){_===p&&(_=f({},_));for(var P in h)_[P]===void 0&&(_[P]=h[P])}return _}function A5(h){ev(h)}function k5(h){console.error(h)}function j5(h){ev(h)}function Sv(h,p){try{var _=h.onUncaughtError;_(p.value,{componentStack:p.stack})}catch(M){setTimeout(function(){throw M})}}function P5(h,p,_){try{var M=h.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:p.tag===1?p.stateNode:null})}catch(P){setTimeout(function(){throw P})}}function SE(h,p,_){return _=ju(_),_.tag=3,_.payload={element:null},_.callback=function(){Sv(h,p)},_}function R5(h){return h=ju(h),h.tag=3,h}function D5(h,p,_,M){var P=_.type.getDerivedStateFromError;if(typeof P=="function"){var L=M.value;h.payload=function(){return P(L)},h.callback=function(){P5(p,_,M)}}var J=_.stateNode;J!==null&&typeof J.componentDidCatch=="function"&&(h.callback=function(){P5(p,_,M),typeof P!="function"&&(Fu===null?Fu=new Set([this]):Fu.add(this));var pe=M.stack;this.componentDidCatch(M.value,{componentStack:pe!==null?pe:""})})}function Aie(h,p,_,M,P){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(p=_.alternate,p!==null&&vm(p,_,P,!0),_=Ps.current,_!==null){switch(_.tag){case 31:case 13:return oo===null?Ov():_.alternate===null&&ei===0&&(ei=3),_.flags&=-257,_.flags|=65536,_.lanes=P,M===uv?_.flags|=16384:(p=_.updateQueue,p===null?_.updateQueue=new Set([M]):p.add(M),KE(h,M,P)),!1;case 22:return _.flags|=65536,M===uv?_.flags|=16384:(p=_.updateQueue,p===null?(p={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=p):(_=p.retryQueue,_===null?p.retryQueue=new Set([M]):_.add(M)),KE(h,M,P)),!1}throw Error(r(435,_.tag))}return KE(h,M,P),Ov(),!1}if(Bn)return p=Ps.current,p!==null?((p.flags&65536)===0&&(p.flags|=256),p.flags|=65536,p.lanes=P,M!==$M&&(h=Error(r(422),{cause:M}),vx(ro(h,_)))):(M!==$M&&(p=Error(r(423),{cause:M}),vx(ro(p,_))),h=h.current.alternate,h.flags|=65536,P&=-P,h.lanes|=P,M=ro(M,_),P=SE(h.stateNode,M,P),JM(h,P),ei!==4&&(ei=2)),!1;var L=Error(r(520),{cause:M});if(L=ro(L,_),Ux===null?Ux=[L]:Ux.push(L),ei!==4&&(ei=2),p===null)return!0;M=ro(M,_),_=p;do{switch(_.tag){case 3:return _.flags|=65536,h=P&-P,_.lanes|=h,h=SE(_.stateNode,M,h),JM(_,h),!1;case 1:if(p=_.type,L=_.stateNode,(_.flags&128)===0&&(typeof p.getDerivedStateFromError=="function"||L!==null&&typeof L.componentDidCatch=="function"&&(Fu===null||!Fu.has(L))))return _.flags|=65536,P&=-P,_.lanes|=P,P=R5(P),D5(P,h,_,M),JM(_,P),!1}_=_.return}while(_!==null);return!1}var NE=Error(r(461)),xi=!1;function fa(h,p,_,M){p.child=h===null?FI(p,null,_,M):kh(p,h.child,_,M)}function O5(h,p,_,M,P){_=_.render;var L=p.ref;if("ref"in M){var J={};for(var pe in M)pe!=="ref"&&(J[pe]=M[pe])}else J=M;return Eh(p),M=aE(h,p,_,J,L,P),pe=sE(),h!==null&&!xi?(oE(h,p,P),dc(h,p,P)):(Bn&&pe&&zM(p),p.flags|=1,fa(h,p,M,P),p.child)}function L5(h,p,_,M,P){if(h===null){var L=_.type;return typeof L=="function"&&!IM(L)&&L.defaultProps===void 0&&_.compare===null?(p.tag=15,p.type=L,I5(h,p,L,M,P)):(h=iv(_.type,null,M,p,p.mode,P),h.ref=p.ref,h.return=p,p.child=h)}if(L=h.child,!PE(h,P)){var J=L.memoizedProps;if(_=_.compare,_=_!==null?_:xx,_(J,M)&&h.ref===p.ref)return dc(h,p,P)}return p.flags|=1,h=ac(L,M),h.ref=p.ref,h.return=p,p.child=h}function I5(h,p,_,M,P){if(h!==null){var L=h.memoizedProps;if(xx(L,M)&&h.ref===p.ref)if(xi=!1,p.pendingProps=M=L,PE(h,P))(h.flags&131072)!==0&&(xi=!0);else return p.lanes=h.lanes,dc(h,p,P)}return ME(h,p,_,M,P)}function F5(h,p,_,M){var P=M.children,L=h!==null?h.memoizedState:null;if(h===null&&p.stateNode===null&&(p.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((p.flags&128)!==0){if(L=L!==null?L.baseLanes|_:_,h!==null){for(M=p.child=h.child,P=0;M!==null;)P=P|M.lanes|M.childLanes,M=M.sibling;M=P&~L}else M=0,p.child=null;return U5(h,p,L,_,M)}if((_&536870912)!==0)p.memoizedState={baseLanes:0,cachePool:null},h!==null&&lv(p,L!==null?L.cachePool:null),L!==null?BI(p,L):tE(),$I(p);else return M=p.lanes=536870912,U5(h,p,L!==null?L.baseLanes|_:_,_,M)}else L!==null?(lv(p,L.cachePool),BI(p,L),Du(),p.memoizedState=null):(h!==null&&lv(p,null),tE(),Du());return fa(h,p,P,_),p.child}function Px(h,p){return h!==null&&h.tag===22||p.stateNode!==null||(p.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),p.sibling}function U5(h,p,_,M,P){var L=XM();return L=L===null?null:{parent:pi._currentValue,pool:L},p.memoizedState={baseLanes:_,cachePool:L},h!==null&&lv(p,null),tE(),$I(p),h!==null&&vm(h,p,M,!0),p.childLanes=P,null}function Nv(h,p){return p=Ev({mode:p.mode,children:p.children},h.mode),p.ref=h.ref,h.child=p,p.return=h,p}function z5(h,p,_){return kh(p,h.child,null,_),h=Nv(p,p.pendingProps),h.flags|=2,Rs(p),p.memoizedState=null,h}function kie(h,p,_){var M=p.pendingProps,P=(p.flags&128)!==0;if(p.flags&=-129,h===null){if(Bn){if(M.mode==="hidden")return h=Nv(p,M),p.lanes=536870912,Px(null,h);if(rE(p),(h=Pr)?(h=ZF(h,so),h=h!==null&&h.data==="&"?h:null,h!==null&&(p.memoizedState={dehydrated:h,treeContext:Eu!==null?{id:gl,overflow:xl}:null,retryLane:536870912,hydrationErrors:null},_=SI(h),_.return=p,p.child=_,da=p,Pr=null)):h=null,h===null)throw Cu(p);return p.lanes=536870912,null}return Nv(p,M)}var L=h.memoizedState;if(L!==null){var J=L.dehydrated;if(rE(p),P)if(p.flags&256)p.flags&=-257,p=z5(h,p,_);else if(p.memoizedState!==null)p.child=h.child,p.flags|=128,p=null;else throw Error(r(558));else if(xi||vm(h,p,_,!1),P=(_&h.childLanes)!==0,xi||P){if(M=Sr,M!==null&&(J=er(M,_),J!==0&&J!==L.retryLane))throw L.retryLane=J,wh(h,J),ds(M,h,J),NE;Ov(),p=z5(h,p,_)}else h=L.treeContext,Pr=lo(J.nextSibling),da=p,Bn=!0,Tu=null,so=!1,h!==null&&EI(p,h),p=Nv(p,M),p.flags|=4096;return p}return h=ac(h.child,{mode:M.mode,children:M.children}),h.ref=p.ref,p.child=h,h.return=p,h}function Mv(h,p){var _=p.ref;if(_===null)h!==null&&h.ref!==null&&(p.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(p.flags|=4194816)}}function ME(h,p,_,M,P){return Eh(p),_=aE(h,p,_,M,void 0,P),M=sE(),h!==null&&!xi?(oE(h,p,P),dc(h,p,P)):(Bn&&M&&zM(p),p.flags|=1,fa(h,p,_,P),p.child)}function B5(h,p,_,M,P,L){return Eh(p),p.updateQueue=null,_=VI(p,M,_,P),HI(h),M=sE(),h!==null&&!xi?(oE(h,p,L),dc(h,p,L)):(Bn&&M&&zM(p),p.flags|=1,fa(h,p,_,L),p.child)}function $5(h,p,_,M,P){if(Eh(p),p.stateNode===null){var L=gm,J=_.contextType;typeof J=="object"&&J!==null&&(L=ha(J)),L=new _(M,L),p.memoizedState=L.state!==null&&L.state!==void 0?L.state:null,L.updater=wE,p.stateNode=L,L._reactInternals=p,L=p.stateNode,L.props=M,L.state=p.memoizedState,L.refs={},QM(p),J=_.contextType,L.context=typeof J=="object"&&J!==null?ha(J):gm,L.state=p.memoizedState,J=_.getDerivedStateFromProps,typeof J=="function"&&(_E(p,_,J,M),L.state=p.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof L.getSnapshotBeforeUpdate=="function"||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(J=L.state,typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount(),J!==L.state&&wE.enqueueReplaceState(L,L.state,null),Tx(p,M,L,P),Ex(),L.state=p.memoizedState),typeof L.componentDidMount=="function"&&(p.flags|=4194308),M=!0}else if(h===null){L=p.stateNode;var pe=p.memoizedProps,ze=Ph(_,pe);L.props=ze;var rt=L.context,xt=_.contextType;J=gm,typeof xt=="object"&&xt!==null&&(J=ha(xt));var vt=_.getDerivedStateFromProps;xt=typeof vt=="function"||typeof L.getSnapshotBeforeUpdate=="function",pe=p.pendingProps!==pe,xt||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(pe||rt!==J)&&C5(p,L,M,J),ku=!1;var at=p.memoizedState;L.state=at,Tx(p,M,L,P),Ex(),rt=p.memoizedState,pe||at!==rt||ku?(typeof vt=="function"&&(_E(p,_,vt,M),rt=p.memoizedState),(ze=ku||T5(p,_,ze,M,at,rt,J))?(xt||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount()),typeof L.componentDidMount=="function"&&(p.flags|=4194308)):(typeof L.componentDidMount=="function"&&(p.flags|=4194308),p.memoizedProps=M,p.memoizedState=rt),L.props=M,L.state=rt,L.context=J,M=ze):(typeof L.componentDidMount=="function"&&(p.flags|=4194308),M=!1)}else{L=p.stateNode,ZM(h,p),J=p.memoizedProps,xt=Ph(_,J),L.props=xt,vt=p.pendingProps,at=L.context,rt=_.contextType,ze=gm,typeof rt=="object"&&rt!==null&&(ze=ha(rt)),pe=_.getDerivedStateFromProps,(rt=typeof pe=="function"||typeof L.getSnapshotBeforeUpdate=="function")||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(J!==vt||at!==ze)&&C5(p,L,M,ze),ku=!1,at=p.memoizedState,L.state=at,Tx(p,M,L,P),Ex();var ft=p.memoizedState;J!==vt||at!==ft||ku||h!==null&&h.dependencies!==null&&sv(h.dependencies)?(typeof pe=="function"&&(_E(p,_,pe,M),ft=p.memoizedState),(xt=ku||T5(p,_,xt,M,at,ft,ze)||h!==null&&h.dependencies!==null&&sv(h.dependencies))?(rt||typeof L.UNSAFE_componentWillUpdate!="function"&&typeof L.componentWillUpdate!="function"||(typeof L.componentWillUpdate=="function"&&L.componentWillUpdate(M,ft,ze),typeof L.UNSAFE_componentWillUpdate=="function"&&L.UNSAFE_componentWillUpdate(M,ft,ze)),typeof L.componentDidUpdate=="function"&&(p.flags|=4),typeof L.getSnapshotBeforeUpdate=="function"&&(p.flags|=1024)):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&&at===h.memoizedState||(p.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&at===h.memoizedState||(p.flags|=1024),p.memoizedProps=M,p.memoizedState=ft),L.props=M,L.state=ft,L.context=ze,M=xt):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&&at===h.memoizedState||(p.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&at===h.memoizedState||(p.flags|=1024),M=!1)}return L=M,Mv(h,p),M=(p.flags&128)!==0,L||M?(L=p.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:L.render(),p.flags|=1,h!==null&&M?(p.child=kh(p,h.child,null,P),p.child=kh(p,null,_,P)):fa(h,p,_,P),p.memoizedState=L.state,h=p.child):h=dc(h,p,P),h}function H5(h,p,_,M){return Nh(),p.flags|=256,fa(h,p,_,M),p.child}var EE={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function TE(h){return{baseLanes:h,cachePool:PI()}}function CE(h,p,_){return h=h!==null?h.childLanes&~_:0,p&&(h|=Os),h}function V5(h,p,_){var M=p.pendingProps,P=!1,L=(p.flags&128)!==0,J;if((J=L)||(J=h!==null&&h.memoizedState===null?!1:(ci.current&2)!==0),J&&(P=!0,p.flags&=-129),J=(p.flags&32)!==0,p.flags&=-33,h===null){if(Bn){if(P?Ru(p):Du(),(h=Pr)?(h=ZF(h,so),h=h!==null&&h.data!=="&"?h:null,h!==null&&(p.memoizedState={dehydrated:h,treeContext:Eu!==null?{id:gl,overflow:xl}:null,retryLane:536870912,hydrationErrors:null},_=SI(h),_.return=p,p.child=_,da=p,Pr=null)):h=null,h===null)throw Cu(p);return uT(h)?p.lanes=32:p.lanes=536870912,null}var pe=M.children;return M=M.fallback,P?(Du(),P=p.mode,pe=Ev({mode:"hidden",children:pe},P),M=Sh(M,P,_,null),pe.return=p,M.return=p,pe.sibling=M,p.child=pe,M=p.child,M.memoizedState=TE(_),M.childLanes=CE(h,J,_),p.memoizedState=EE,Px(null,M)):(Ru(p),AE(p,pe))}var ze=h.memoizedState;if(ze!==null&&(pe=ze.dehydrated,pe!==null)){if(L)p.flags&256?(Ru(p),p.flags&=-257,p=kE(h,p,_)):p.memoizedState!==null?(Du(),p.child=h.child,p.flags|=128,p=null):(Du(),pe=M.fallback,P=p.mode,M=Ev({mode:"visible",children:M.children},P),pe=Sh(pe,P,_,null),pe.flags|=2,M.return=p,pe.return=p,M.sibling=pe,p.child=M,kh(p,h.child,null,_),M=p.child,M.memoizedState=TE(_),M.childLanes=CE(h,J,_),p.memoizedState=EE,p=Px(null,M));else if(Ru(p),uT(pe)){if(J=pe.nextSibling&&pe.nextSibling.dataset,J)var rt=J.dgst;J=rt,M=Error(r(419)),M.stack="",M.digest=J,vx({value:M,source:null,stack:null}),p=kE(h,p,_)}else if(xi||vm(h,p,_,!1),J=(_&h.childLanes)!==0,xi||J){if(J=Sr,J!==null&&(M=er(J,_),M!==0&&M!==ze.retryLane))throw ze.retryLane=M,wh(h,M),ds(J,h,M),NE;cT(pe)||Ov(),p=kE(h,p,_)}else cT(pe)?(p.flags|=192,p.child=h.child,p=null):(h=ze.treeContext,Pr=lo(pe.nextSibling),da=p,Bn=!0,Tu=null,so=!1,h!==null&&EI(p,h),p=AE(p,M.children),p.flags|=4096);return p}return P?(Du(),pe=M.fallback,P=p.mode,ze=h.child,rt=ze.sibling,M=ac(ze,{mode:"hidden",children:M.children}),M.subtreeFlags=ze.subtreeFlags&65011712,rt!==null?pe=ac(rt,pe):(pe=Sh(pe,P,_,null),pe.flags|=2),pe.return=p,M.return=p,M.sibling=pe,p.child=M,Px(null,M),M=p.child,pe=h.child.memoizedState,pe===null?pe=TE(_):(P=pe.cachePool,P!==null?(ze=pi._currentValue,P=P.parent!==ze?{parent:ze,pool:ze}:P):P=PI(),pe={baseLanes:pe.baseLanes|_,cachePool:P}),M.memoizedState=pe,M.childLanes=CE(h,J,_),p.memoizedState=EE,Px(h.child,M)):(Ru(p),_=h.child,h=_.sibling,_=ac(_,{mode:"visible",children:M.children}),_.return=p,_.sibling=null,h!==null&&(J=p.deletions,J===null?(p.deletions=[h],p.flags|=16):J.push(h)),p.child=_,p.memoizedState=null,_)}function AE(h,p){return p=Ev({mode:"visible",children:p},h.mode),p.return=h,h.child=p}function Ev(h,p){return h=js(22,h,null,p),h.lanes=0,h}function kE(h,p,_){return kh(p,h.child,null,_),h=AE(p,p.pendingProps.children),h.flags|=2,p.memoizedState=null,h}function G5(h,p,_){h.lanes|=p;var M=h.alternate;M!==null&&(M.lanes|=p),GM(h.return,p,_)}function jE(h,p,_,M,P,L){var J=h.memoizedState;J===null?h.memoizedState={isBackwards:p,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:P,treeForkCount:L}:(J.isBackwards=p,J.rendering=null,J.renderingStartTime=0,J.last=M,J.tail=_,J.tailMode=P,J.treeForkCount=L)}function q5(h,p,_){var M=p.pendingProps,P=M.revealOrder,L=M.tail;M=M.children;var J=ci.current,pe=(J&2)!==0;if(pe?(J=J&1|2,p.flags|=128):J&=1,se(ci,J),fa(h,p,M,_),M=Bn?yx:0,!pe&&h!==null&&(h.flags&128)!==0)e:for(h=p.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&G5(h,_,p);else if(h.tag===19)G5(h,_,p);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===p)break e;for(;h.sibling===null;){if(h.return===null||h.return===p)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(P){case"forwards":for(_=p.child,P=null;_!==null;)h=_.alternate,h!==null&&mv(h)===null&&(P=_),_=_.sibling;_=P,_===null?(P=p.child,p.child=null):(P=_.sibling,_.sibling=null),jE(p,!1,P,_,L,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,P=p.child,p.child=null;P!==null;){if(h=P.alternate,h!==null&&mv(h)===null){p.child=P;break}h=P.sibling,P.sibling=_,_=P,P=h}jE(p,!0,_,null,L,M);break;case"together":jE(p,!1,null,null,void 0,M);break;default:p.memoizedState=null}return p.child}function dc(h,p,_){if(h!==null&&(p.dependencies=h.dependencies),Iu|=p.lanes,(_&p.childLanes)===0)if(h!==null){if(vm(h,p,_,!1),(_&p.childLanes)===0)return null}else return null;if(h!==null&&p.child!==h.child)throw Error(r(153));if(p.child!==null){for(h=p.child,_=ac(h,h.pendingProps),p.child=_,_.return=p;h.sibling!==null;)h=h.sibling,_=_.sibling=ac(h,h.pendingProps),_.return=p;_.sibling=null}return p.child}function PE(h,p){return(h.lanes&p)!==0?!0:(h=h.dependencies,!!(h!==null&&sv(h)))}function jie(h,p,_){switch(p.tag){case 3:be(p,p.stateNode.containerInfo),Au(p,pi,h.memoizedState.cache),Nh();break;case 27:case 5:Ce(p);break;case 4:be(p,p.stateNode.containerInfo);break;case 10:Au(p,p.type,p.memoizedProps.value);break;case 31:if(p.memoizedState!==null)return p.flags|=128,rE(p),null;break;case 13:var M=p.memoizedState;if(M!==null)return M.dehydrated!==null?(Ru(p),p.flags|=128,null):(_&p.child.childLanes)!==0?V5(h,p,_):(Ru(p),h=dc(h,p,_),h!==null?h.sibling:null);Ru(p);break;case 19:var P=(h.flags&128)!==0;if(M=(_&p.childLanes)!==0,M||(vm(h,p,_,!1),M=(_&p.childLanes)!==0),P){if(M)return q5(h,p,_);p.flags|=128}if(P=p.memoizedState,P!==null&&(P.rendering=null,P.tail=null,P.lastEffect=null),se(ci,ci.current),M)break;return null;case 22:return p.lanes=0,F5(h,p,_,p.pendingProps);case 24:Au(p,pi,h.memoizedState.cache)}return dc(h,p,_)}function W5(h,p,_){if(h!==null)if(h.memoizedProps!==p.pendingProps)xi=!0;else{if(!PE(h,_)&&(p.flags&128)===0)return xi=!1,jie(h,p,_);xi=(h.flags&131072)!==0}else xi=!1,Bn&&(p.flags&1048576)!==0&&MI(p,yx,p.index);switch(p.lanes=0,p.tag){case 16:e:{var M=p.pendingProps;if(h=Ch(p.elementType),p.type=h,typeof h=="function")IM(h)?(M=Ph(h,M),p.tag=1,p=$5(null,p,h,M,_)):(p.tag=0,p=ME(null,p,h,M,_));else{if(h!=null){var P=h.$$typeof;if(P===T){p.tag=11,p=O5(null,p,h,M,_);break e}else if(P===C){p.tag=14,p=L5(null,p,h,M,_);break e}}throw p=j(h)||h,Error(r(306,p,""))}}return p;case 0:return ME(h,p,p.type,p.pendingProps,_);case 1:return M=p.type,P=Ph(M,p.pendingProps),$5(h,p,M,P,_);case 3:e:{if(be(p,p.stateNode.containerInfo),h===null)throw Error(r(387));M=p.pendingProps;var L=p.memoizedState;P=L.element,ZM(h,p),Tx(p,M,null,_);var J=p.memoizedState;if(M=J.cache,Au(p,pi,M),M!==L.cache&&qM(p,[pi],_,!0),Ex(),M=J.element,L.isDehydrated)if(L={element:M,isDehydrated:!1,cache:J.cache},p.updateQueue.baseState=L,p.memoizedState=L,p.flags&256){p=H5(h,p,M,_);break e}else if(M!==P){P=ro(Error(r(424)),p),vx(P),p=H5(h,p,M,_);break e}else{switch(h=p.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(Pr=lo(h.firstChild),da=p,Bn=!0,Tu=null,so=!0,_=FI(p,null,M,_),p.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(Nh(),M===P){p=dc(h,p,_);break e}fa(h,p,M,_)}p=p.child}return p;case 26:return Mv(h,p),h===null?(_=iU(p.type,null,p.pendingProps,null))?p.memoizedState=_:Bn||(_=p.type,h=p.pendingProps,M=$v(me.current).createElement(_),M[Xt]=p,M[zn]=h,ma(M,_,h),Ie(M),p.stateNode=M):p.memoizedState=iU(p.type,h.memoizedProps,p.pendingProps,h.memoizedState),null;case 27:return Ce(p),h===null&&Bn&&(M=p.stateNode=tU(p.type,p.pendingProps,me.current),da=p,so=!0,P=Pr,$u(p.type)?(dT=P,Pr=lo(M.firstChild)):Pr=P),fa(h,p,p.pendingProps.children,_),Mv(h,p),h===null&&(p.flags|=4194304),p.child;case 5:return h===null&&Bn&&((P=M=Pr)&&(M=oae(M,p.type,p.pendingProps,so),M!==null?(p.stateNode=M,da=p,Pr=lo(M.firstChild),so=!1,P=!0):P=!1),P||Cu(p)),Ce(p),P=p.type,L=p.pendingProps,J=h!==null?h.memoizedProps:null,M=L.children,sT(P,L)?M=null:J!==null&&sT(P,J)&&(p.flags|=32),p.memoizedState!==null&&(P=aE(h,p,wie,null,null,_),Wx._currentValue=P),Mv(h,p),fa(h,p,M,_),p.child;case 6:return h===null&&Bn&&((h=_=Pr)&&(_=lae(_,p.pendingProps,so),_!==null?(p.stateNode=_,da=p,Pr=null,h=!0):h=!1),h||Cu(p)),null;case 13:return V5(h,p,_);case 4:return be(p,p.stateNode.containerInfo),M=p.pendingProps,h===null?p.child=kh(p,null,M,_):fa(h,p,M,_),p.child;case 11:return O5(h,p,p.type,p.pendingProps,_);case 7:return fa(h,p,p.pendingProps,_),p.child;case 8:return fa(h,p,p.pendingProps.children,_),p.child;case 12:return fa(h,p,p.pendingProps.children,_),p.child;case 10:return M=p.pendingProps,Au(p,p.type,M.value),fa(h,p,M.children,_),p.child;case 9:return P=p.type._context,M=p.pendingProps.children,Eh(p),P=ha(P),M=M(P),p.flags|=1,fa(h,p,M,_),p.child;case 14:return L5(h,p,p.type,p.pendingProps,_);case 15:return I5(h,p,p.type,p.pendingProps,_);case 19:return q5(h,p,_);case 31:return kie(h,p,_);case 22:return F5(h,p,_,p.pendingProps);case 24:return Eh(p),M=ha(pi),h===null?(P=XM(),P===null&&(P=Sr,L=WM(),P.pooledCache=L,L.refCount++,L!==null&&(P.pooledCacheLanes|=_),P=L),p.memoizedState={parent:M,cache:P},QM(p),Au(p,pi,P)):((h.lanes&_)!==0&&(ZM(h,p),Tx(p,null,null,_),Ex()),P=h.memoizedState,L=p.memoizedState,P.parent!==M?(P={parent:M,cache:M},p.memoizedState=P,p.lanes===0&&(p.memoizedState=p.updateQueue.baseState=P),Au(p,pi,M)):(M=L.cache,Au(p,pi,M),M!==P.cache&&qM(p,[pi],_,!0))),fa(h,p,p.pendingProps.children,_),p.child;case 29:throw p.pendingProps}throw Error(r(156,p.tag))}function hc(h){h.flags|=4}function RE(h,p,_,M,P){if((p=(h.mode&32)!==0)&&(p=!1),p){if(h.flags|=16777216,(P&335544128)===P)if(h.stateNode.complete)h.flags|=8192;else if(vF())h.flags|=8192;else throw Ah=uv,YM}else h.flags&=-16777217}function K5(h,p){if(p.type!=="stylesheet"||(p.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!cU(p))if(vF())h.flags|=8192;else throw Ah=uv,YM}function Tv(h,p){p!==null&&(h.flags|=4),h.flags&16384&&(p=h.tag!==22?At():536870912,h.lanes|=p,Pm|=p)}function Rx(h,p){if(!Bn)switch(h.tailMode){case"hidden":p=h.tail;for(var _=null;p!==null;)p.alternate!==null&&(_=p),p=p.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?p||h.tail===null?h.tail=null:h.tail.sibling=null:M.sibling=null}}function Rr(h){var p=h.alternate!==null&&h.alternate.child===h.child,_=0,M=0;if(p)for(var P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags&65011712,M|=P.flags&65011712,P.return=h,P=P.sibling;else for(P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags,M|=P.flags,P.return=h,P=P.sibling;return h.subtreeFlags|=M,h.childLanes=_,p}function Pie(h,p,_){var M=p.pendingProps;switch(BM(p),p.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Rr(p),null;case 1:return Rr(p),null;case 3:return _=p.stateNode,M=null,h!==null&&(M=h.memoizedState.cache),p.memoizedState.cache!==M&&(p.flags|=2048),lc(pi),fe(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(ym(p)?hc(p):h===null||h.memoizedState.isDehydrated&&(p.flags&256)===0||(p.flags|=1024,HM())),Rr(p),null;case 26:var P=p.type,L=p.memoizedState;return h===null?(hc(p),L!==null?(Rr(p),K5(p,L)):(Rr(p),RE(p,P,null,M,_))):L?L!==h.memoizedState?(hc(p),Rr(p),K5(p,L)):(Rr(p),p.flags&=-16777217):(h=h.memoizedProps,h!==M&&hc(p),Rr(p),RE(p,P,h,M,_)),null;case 27:if(Ne(p),_=me.current,P=p.type,h!==null&&p.stateNode!=null)h.memoizedProps!==M&&hc(p);else{if(!M){if(p.stateNode===null)throw Error(r(166));return Rr(p),null}h=ae.current,ym(p)?TI(p):(h=tU(P,M,_),p.stateNode=h,hc(p))}return Rr(p),null;case 5:if(Ne(p),P=p.type,h!==null&&p.stateNode!=null)h.memoizedProps!==M&&hc(p);else{if(!M){if(p.stateNode===null)throw Error(r(166));return Rr(p),null}if(L=ae.current,ym(p))TI(p);else{var J=$v(me.current);switch(L){case 1:L=J.createElementNS("http://www.w3.org/2000/svg",P);break;case 2:L=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;default:switch(P){case"svg":L=J.createElementNS("http://www.w3.org/2000/svg",P);break;case"math":L=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;case"script":L=J.createElement("div"),L.innerHTML="