| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899 |
- from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, WebSocket, WebSocketDisconnect, Request
- from fastapi.responses import JSONResponse, FileResponse, Response
- from fastapi.staticfiles import StaticFiles
- from fastapi.middleware.cors import CORSMiddleware
- from fastapi.templating import Jinja2Templates
- from pydantic import BaseModel
- from typing import List, Optional, Tuple, Dict, Any, Union
- import atexit
- import os
- import logging
- from datetime import datetime, time
- from modules.connection import connection_manager
- from modules.core import pattern_manager
- from modules.core.pattern_manager import parse_theta_rho_file, THETA_RHO_DIR
- from modules.core import playlist_manager
- from modules.update import update_manager
- from modules.core.state import state
- from modules import mqtt
- import signal
- import sys
- import asyncio
- from contextlib import asynccontextmanager
- from modules.led.led_controller import LEDController, effect_idle
- from modules.led.led_interface import LEDInterface
- from modules.led.idle_timeout_manager import idle_timeout_manager
- import math
- from modules.core.cache_manager import generate_all_image_previews, get_cache_path, generate_image_preview, get_pattern_metadata
- from modules.core.version_manager import version_manager
- from modules.core.log_handler import init_memory_handler, get_memory_handler
- import json
- import base64
- import time
- import argparse
- import subprocess
- import platform
- # Get log level from environment variable, default to INFO
- log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
- log_level = getattr(logging, log_level_str, logging.INFO)
- logging.basicConfig(
- level=log_level,
- format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
- handlers=[
- logging.StreamHandler(),
- ]
- )
- # Initialize memory log handler for web UI log viewer
- # Increased to 5000 entries to support lazy loading in the UI
- init_memory_handler(max_entries=5000)
- logger = logging.getLogger(__name__)
- async def _check_table_is_idle() -> bool:
- """Helper function to check if table is idle."""
- return not state.current_playing_file or state.pause_requested
- def _start_idle_led_timeout():
- """Start idle LED timeout if enabled."""
- if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
- return
- logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
- idle_timeout_manager.start_idle_timeout(
- timeout_minutes=state.dw_led_idle_timeout_minutes,
- state=state,
- check_idle_callback=_check_table_is_idle
- )
- def check_homing_in_progress():
- """Check if homing is in progress and raise exception if so."""
- if state.is_homing:
- raise HTTPException(status_code=409, detail="Cannot perform this action while homing is in progress")
- def normalize_file_path(file_path: str) -> str:
- """Normalize file path separators for consistent cross-platform handling."""
- if not file_path:
- return ''
-
- # First normalize path separators
- normalized = file_path.replace('\\', '/')
-
- # Remove only the patterns directory prefix from the beginning, not patterns within the path
- if normalized.startswith('./patterns/'):
- normalized = normalized[11:]
- elif normalized.startswith('patterns/'):
- normalized = normalized[9:]
-
- return normalized
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- # Startup
- logger.info("Starting Dune Weaver application...")
- # Register signal handlers
- signal.signal(signal.SIGINT, signal_handler)
- signal.signal(signal.SIGTERM, signal_handler)
- # Connect device in background so the web server starts immediately
- async def connect_and_home():
- """Connect to device and perform homing in background."""
- try:
- # Connect without homing first (fast)
- await asyncio.to_thread(connection_manager.connect_device, False)
- # If connected, perform homing in background
- if state.conn and state.conn.is_connected():
- logger.info("Device connected, starting homing in background...")
- state.is_homing = True
- try:
- success = await asyncio.to_thread(connection_manager.home)
- if not success:
- logger.warning("Background homing failed or was skipped")
- # If sensor homing failed, close connection and wait for user action
- if state.sensor_homing_failed:
- logger.error("Sensor homing failed - closing connection. User must check sensor or switch to crash homing.")
- if state.conn:
- await asyncio.to_thread(state.conn.close)
- state.conn = None
- return # Don't proceed with auto-play
- finally:
- state.is_homing = False
- logger.info("Background homing completed")
- # After homing, check for auto_play mode
- if state.auto_play_enabled and state.auto_play_playlist:
- logger.info(f"Homing complete, checking auto_play playlist: {state.auto_play_playlist}")
- try:
- playlist_exists = playlist_manager.get_playlist(state.auto_play_playlist) is not None
- if not playlist_exists:
- logger.warning(f"Auto-play playlist '{state.auto_play_playlist}' not found. Clearing invalid reference.")
- state.auto_play_playlist = None
- state.save()
- elif state.conn and state.conn.is_connected():
- logger.info(f"Starting auto-play playlist: {state.auto_play_playlist}")
- asyncio.create_task(playlist_manager.run_playlist(
- state.auto_play_playlist,
- pause_time=state.auto_play_pause_time,
- clear_pattern=state.auto_play_clear_pattern,
- run_mode=state.auto_play_run_mode,
- shuffle=state.auto_play_shuffle
- ))
- except Exception as e:
- logger.error(f"Failed to auto-play playlist: {str(e)}")
- except Exception as e:
- logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
- # Start connection/homing in background - doesn't block server startup
- asyncio.create_task(connect_and_home())
- # Initialize LED controller based on saved configuration
- try:
- # Auto-detect provider for backward compatibility with existing installations
- if not state.led_provider or state.led_provider == "none":
- if state.wled_ip:
- state.led_provider = "wled"
- logger.info("Auto-detected WLED provider from existing configuration")
- # Initialize the appropriate controller
- if state.led_provider == "wled" and state.wled_ip:
- state.led_controller = LEDInterface("wled", state.wled_ip)
- logger.info(f"LED controller initialized: WLED at {state.wled_ip}")
- elif state.led_provider == "dw_leds":
- state.led_controller = LEDInterface(
- "dw_leds",
- num_leds=state.dw_led_num_leds,
- gpio_pin=state.dw_led_gpio_pin,
- pixel_order=state.dw_led_pixel_order,
- brightness=state.dw_led_brightness / 100.0,
- speed=state.dw_led_speed,
- intensity=state.dw_led_intensity
- )
- logger.info(f"LED controller initialized: DW LEDs ({state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}, pixel order: {state.dw_led_pixel_order})")
- # Initialize hardware and start idle effect (matches behavior of /set_led_config)
- status = state.led_controller.check_status()
- if status.get("connected", False):
- state.led_controller.effect_idle(state.dw_led_idle_effect)
- _start_idle_led_timeout()
- logger.info("DW LEDs hardware initialized and idle effect started")
- else:
- error_msg = status.get("error", "Unknown error")
- logger.warning(f"DW LED hardware initialization failed: {error_msg}")
- else:
- state.led_controller = None
- logger.info("LED controller not configured")
- # Save if provider was auto-detected
- if state.led_provider and state.wled_ip:
- state.save()
- except Exception as e:
- logger.warning(f"Failed to initialize LED controller: {str(e)}")
- state.led_controller = None
- # Note: auto_play is now handled in connect_and_home() after homing completes
- try:
- mqtt_handler = mqtt.init_mqtt()
- except Exception as e:
- logger.warning(f"Failed to initialize MQTT: {str(e)}")
-
- # Schedule cache generation check for later (non-blocking startup)
- async def delayed_cache_check():
- """Check and generate cache in background."""
- try:
- logger.info("Starting cache check...")
- from modules.core.cache_manager import is_cache_generation_needed_async, generate_cache_background
- if await is_cache_generation_needed_async():
- logger.info("Cache generation needed, starting background task...")
- asyncio.create_task(generate_cache_background()) # Don't await - run in background
- else:
- logger.info("Cache is up to date, skipping generation")
- except Exception as e:
- logger.warning(f"Failed during cache generation: {str(e)}")
- # Start cache check in background immediately
- asyncio.create_task(delayed_cache_check())
- # Start idle timeout monitor
- async def idle_timeout_monitor():
- """Monitor LED idle timeout and turn off LEDs when timeout expires."""
- import time
- while True:
- try:
- await asyncio.sleep(30) # Check every 30 seconds
- if not state.dw_led_idle_timeout_enabled:
- continue
- if not state.led_controller or not state.led_controller.is_configured:
- continue
- # Check if we're currently playing a pattern
- is_playing = bool(state.current_playing_file or state.current_playlist)
- if is_playing:
- # Reset activity time when playing
- state.dw_led_last_activity_time = time.time()
- continue
- # If no activity time set, initialize it
- if state.dw_led_last_activity_time is None:
- state.dw_led_last_activity_time = time.time()
- continue
- # Calculate idle duration
- idle_seconds = time.time() - state.dw_led_last_activity_time
- timeout_seconds = state.dw_led_idle_timeout_minutes * 60
- # Turn off LEDs if timeout expired
- if idle_seconds >= timeout_seconds:
- status = state.led_controller.check_status()
- # Check both "power" (WLED) and "power_on" (DW LEDs) keys
- is_powered_on = status.get("power", False) or status.get("power_on", False)
- if is_powered_on: # Only turn off if currently on
- logger.info(f"Idle timeout ({state.dw_led_idle_timeout_minutes} minutes) expired, turning off LEDs")
- state.led_controller.set_power(0)
- # Reset activity time to prevent repeated turn-off attempts
- state.dw_led_last_activity_time = time.time()
- except Exception as e:
- logger.error(f"Error in idle timeout monitor: {e}")
- await asyncio.sleep(60) # Wait longer on error
- asyncio.create_task(idle_timeout_monitor())
- yield # This separates startup from shutdown code
- # Shutdown
- logger.info("Shutting down Dune Weaver application...")
- app = FastAPI(lifespan=lifespan)
- # Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
- # This enables multi-table control from a single frontend
- # Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
- app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"], # Allow all origins for local network access
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
- )
- templates = Jinja2Templates(directory="templates")
- app.mount("/static", StaticFiles(directory="static"), name="static")
- # Global semaphore to limit concurrent preview processing
- # Prevents resource exhaustion when loading many previews simultaneously
- # Lazily initialized to avoid "attached to a different loop" errors
- _preview_semaphore: Optional[asyncio.Semaphore] = None
- def get_preview_semaphore() -> asyncio.Semaphore:
- """Get or create the preview semaphore in the current event loop."""
- global _preview_semaphore
- if _preview_semaphore is None:
- _preview_semaphore = asyncio.Semaphore(5)
- return _preview_semaphore
- # Pydantic models for request/response validation
- class ConnectRequest(BaseModel):
- port: Optional[str] = None
- class auto_playModeRequest(BaseModel):
- enabled: bool
- playlist: Optional[str] = None
- run_mode: Optional[str] = "loop"
- pause_time: Optional[float] = 5.0
- clear_pattern: Optional[str] = "adaptive"
- shuffle: Optional[bool] = False
- class TimeSlot(BaseModel):
- start_time: str # HH:MM format
- end_time: str # HH:MM format
- days: str # "daily", "weekdays", "weekends", or "custom"
- custom_days: Optional[List[str]] = [] # ["monday", "tuesday", etc.]
- class ScheduledPauseRequest(BaseModel):
- enabled: bool
- control_wled: Optional[bool] = False
- finish_pattern: Optional[bool] = False # Finish current pattern before pausing
- timezone: Optional[str] = None # IANA timezone or None for system default
- time_slots: List[TimeSlot] = []
- class CoordinateRequest(BaseModel):
- theta: float
- rho: float
- class PlaylistRequest(BaseModel):
- playlist_name: str
- files: List[str] = []
- pause_time: float = 0
- clear_pattern: Optional[str] = None
- run_mode: str = "single"
- shuffle: bool = False
- class PlaylistRunRequest(BaseModel):
- playlist_name: str
- pause_time: Optional[float] = 0
- clear_pattern: Optional[str] = None
- run_mode: Optional[str] = "single"
- shuffle: Optional[bool] = False
- start_time: Optional[str] = None
- end_time: Optional[str] = None
- class SpeedRequest(BaseModel):
- speed: float
- class WLEDRequest(BaseModel):
- wled_ip: Optional[str] = None
- class LEDConfigRequest(BaseModel):
- provider: str # "wled", "dw_leds", or "none"
- ip_address: Optional[str] = None # For WLED only
- # DW LED specific fields
- num_leds: Optional[int] = None
- gpio_pin: Optional[int] = None
- pixel_order: Optional[str] = None
- brightness: Optional[int] = None
- class DeletePlaylistRequest(BaseModel):
- playlist_name: str
- class RenamePlaylistRequest(BaseModel):
- old_name: str
- new_name: str
- class ThetaRhoRequest(BaseModel):
- file_name: str
- pre_execution: Optional[str] = "none"
- class GetCoordinatesRequest(BaseModel):
- file_name: str
- # ============================================================================
- # Unified Settings Models
- # ============================================================================
- class AppSettingsUpdate(BaseModel):
- name: Optional[str] = None
- custom_logo: Optional[str] = None # Filename or empty string to clear (favicon auto-generated)
- class ConnectionSettingsUpdate(BaseModel):
- preferred_port: Optional[str] = None
- class PatternSettingsUpdate(BaseModel):
- clear_pattern_speed: Optional[int] = None
- custom_clear_from_in: Optional[str] = None
- custom_clear_from_out: Optional[str] = None
- class AutoPlaySettingsUpdate(BaseModel):
- enabled: Optional[bool] = None
- playlist: Optional[str] = None
- run_mode: Optional[str] = None
- pause_time: Optional[float] = None
- clear_pattern: Optional[str] = None
- shuffle: Optional[bool] = None
- class ScheduledPauseSettingsUpdate(BaseModel):
- enabled: Optional[bool] = None
- control_wled: Optional[bool] = None
- finish_pattern: Optional[bool] = None
- timezone: Optional[str] = None # IANA timezone (e.g., "America/New_York") or None for system default
- time_slots: Optional[List[TimeSlot]] = None
- class HomingSettingsUpdate(BaseModel):
- mode: Optional[int] = None
- angular_offset_degrees: Optional[float] = None
- auto_home_enabled: Optional[bool] = None
- auto_home_after_patterns: Optional[int] = None
- hard_reset_theta: Optional[bool] = None # Enable hard reset ($Bye) when resetting theta
- class DwLedSettingsUpdate(BaseModel):
- num_leds: Optional[int] = None
- gpio_pin: Optional[int] = None
- pixel_order: Optional[str] = None
- brightness: Optional[int] = None
- speed: Optional[int] = None
- intensity: Optional[int] = None
- idle_effect: Optional[dict] = None
- playing_effect: Optional[dict] = None
- idle_timeout_enabled: Optional[bool] = None
- idle_timeout_minutes: Optional[int] = None
- class LedSettingsUpdate(BaseModel):
- provider: Optional[str] = None # "none", "wled", "dw_leds"
- wled_ip: Optional[str] = None
- dw_led: Optional[DwLedSettingsUpdate] = None
- class MqttSettingsUpdate(BaseModel):
- enabled: Optional[bool] = None
- broker: Optional[str] = None
- port: Optional[int] = None
- username: Optional[str] = None
- password: Optional[str] = None # Write-only, never returned in GET
- client_id: Optional[str] = None
- discovery_prefix: Optional[str] = None
- device_id: Optional[str] = None
- device_name: Optional[str] = None
- class MachineSettingsUpdate(BaseModel):
- table_type_override: Optional[str] = None # Override detected table type, or empty string/"auto" to clear
- timezone: Optional[str] = None # IANA timezone (e.g., "America/New_York", "UTC")
- class SettingsUpdate(BaseModel):
- """Request model for PATCH /api/settings - all fields optional for partial updates"""
- app: Optional[AppSettingsUpdate] = None
- connection: Optional[ConnectionSettingsUpdate] = None
- patterns: Optional[PatternSettingsUpdate] = None
- auto_play: Optional[AutoPlaySettingsUpdate] = None
- scheduled_pause: Optional[ScheduledPauseSettingsUpdate] = None
- homing: Optional[HomingSettingsUpdate] = None
- led: Optional[LedSettingsUpdate] = None
- mqtt: Optional[MqttSettingsUpdate] = None
- machine: Optional[MachineSettingsUpdate] = None
- # Store active WebSocket connections
- active_status_connections = set()
- active_cache_progress_connections = set()
- @app.websocket("/ws/status")
- async def websocket_status_endpoint(websocket: WebSocket):
- await websocket.accept()
- active_status_connections.add(websocket)
- try:
- while True:
- status = pattern_manager.get_status()
- try:
- await websocket.send_json({
- "type": "status_update",
- "data": status
- })
- except RuntimeError as e:
- if "close message has been sent" in str(e):
- break
- raise
- # Use longer interval during pattern execution to reduce asyncio overhead
- # This helps prevent timing-related serial corruption on Pi 3B+
- interval = 2.0 if state.current_playing_file else 1.0
- await asyncio.sleep(interval)
- except WebSocketDisconnect:
- pass
- finally:
- active_status_connections.discard(websocket)
- try:
- await websocket.close()
- except RuntimeError:
- pass
- async def broadcast_status_update(status: dict):
- """Broadcast status update to all connected clients."""
- disconnected = set()
- for websocket in active_status_connections:
- try:
- await websocket.send_json({
- "type": "status_update",
- "data": status
- })
- except WebSocketDisconnect:
- disconnected.add(websocket)
- except RuntimeError:
- disconnected.add(websocket)
-
- active_status_connections.difference_update(disconnected)
- @app.websocket("/ws/cache-progress")
- async def websocket_cache_progress_endpoint(websocket: WebSocket):
- from modules.core.cache_manager import get_cache_progress
- await websocket.accept()
- active_cache_progress_connections.add(websocket)
- try:
- while True:
- progress = get_cache_progress()
- try:
- await websocket.send_json({
- "type": "cache_progress",
- "data": progress
- })
- except RuntimeError as e:
- if "close message has been sent" in str(e):
- break
- raise
- await asyncio.sleep(1.0) # Update every 1 second (reduced frequency for better performance)
- except WebSocketDisconnect:
- pass
- finally:
- active_cache_progress_connections.discard(websocket)
- try:
- await websocket.close()
- except RuntimeError:
- pass
- # WebSocket endpoint for real-time log streaming
- @app.websocket("/ws/logs")
- async def websocket_logs_endpoint(websocket: WebSocket):
- """Stream application logs in real-time via WebSocket."""
- await websocket.accept()
- handler = get_memory_handler()
- if not handler:
- await websocket.close()
- return
- # Subscribe to log updates
- log_queue = handler.subscribe()
- try:
- while True:
- try:
- # Wait for new log entry with timeout
- log_entry = await asyncio.wait_for(log_queue.get(), timeout=30.0)
- await websocket.send_json({
- "type": "log_entry",
- "data": log_entry
- })
- except asyncio.TimeoutError:
- # Send heartbeat to keep connection alive
- await websocket.send_json({"type": "heartbeat"})
- except RuntimeError as e:
- if "close message has been sent" in str(e):
- break
- raise
- except WebSocketDisconnect:
- pass
- finally:
- handler.unsubscribe(log_queue)
- try:
- await websocket.close()
- except RuntimeError:
- pass
- # API endpoint to retrieve logs
- @app.get("/api/logs", tags=["logs"])
- async def get_logs(limit: int = 100, level: str = None, offset: int = 0):
- """
- Retrieve application logs from memory buffer with pagination.
- Args:
- limit: Maximum number of log entries to return (default: 100)
- level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
- offset: Number of entries to skip from newest (for lazy loading older logs)
- Returns:
- List of log entries with timestamp, level, logger, and message.
- Also returns total count and whether there are more logs available.
- """
- handler = get_memory_handler()
- if not handler:
- return {"logs": [], "count": 0, "total": 0, "has_more": False, "error": "Log handler not initialized"}
- # Clamp limit to reasonable range (no max limit for lazy loading)
- limit = max(1, limit)
- offset = max(0, offset)
- logs = handler.get_logs(limit=limit, level=level, offset=offset)
- total = handler.get_total_count(level=level)
- has_more = offset + len(logs) < total
- return {"logs": logs, "count": len(logs), "total": total, "has_more": has_more}
- @app.delete("/api/logs", tags=["logs"])
- async def clear_logs():
- """Clear all logs from the memory buffer."""
- handler = get_memory_handler()
- if handler:
- handler.clear()
- return {"status": "ok", "message": "Logs cleared"}
- # FastAPI routes - Redirect old frontend routes to new React frontend on port 80
- def get_redirect_response(request: Request):
- """Return redirect page pointing users to the new frontend."""
- host = request.headers.get("host", "localhost").split(":")[0] # Remove port if present
- return templates.TemplateResponse("redirect.html", {"request": request, "host": host})
- @app.get("/")
- async def index(request: Request):
- return get_redirect_response(request)
- @app.get("/settings")
- async def settings_page(request: Request):
- return get_redirect_response(request)
- # ============================================================================
- # Unified Settings API
- # ============================================================================
- @app.get("/api/settings", tags=["settings"])
- async def get_all_settings():
- """
- Get all application settings in a unified structure.
- This endpoint consolidates multiple settings endpoints into a single response.
- Individual settings endpoints are deprecated but still functional.
- """
- return {
- "app": {
- "name": state.app_name,
- "custom_logo": state.custom_logo
- },
- "connection": {
- "preferred_port": state.preferred_port
- },
- "patterns": {
- "clear_pattern_speed": state.clear_pattern_speed,
- "custom_clear_from_in": state.custom_clear_from_in,
- "custom_clear_from_out": state.custom_clear_from_out
- },
- "auto_play": {
- "enabled": state.auto_play_enabled,
- "playlist": state.auto_play_playlist,
- "run_mode": state.auto_play_run_mode,
- "pause_time": state.auto_play_pause_time,
- "clear_pattern": state.auto_play_clear_pattern,
- "shuffle": state.auto_play_shuffle
- },
- "scheduled_pause": {
- "enabled": state.scheduled_pause_enabled,
- "control_wled": state.scheduled_pause_control_wled,
- "finish_pattern": state.scheduled_pause_finish_pattern,
- "timezone": state.scheduled_pause_timezone,
- "time_slots": state.scheduled_pause_time_slots
- },
- "homing": {
- "mode": state.homing,
- "user_override": state.homing_user_override, # True if user explicitly set, False if auto-detected
- "angular_offset_degrees": state.angular_homing_offset_degrees,
- "auto_home_enabled": state.auto_home_enabled,
- "auto_home_after_patterns": state.auto_home_after_patterns,
- "hard_reset_theta": state.hard_reset_theta # Enable hard reset when resetting theta
- },
- "led": {
- "provider": state.led_provider,
- "wled_ip": state.wled_ip,
- "dw_led": {
- "num_leds": state.dw_led_num_leds,
- "gpio_pin": state.dw_led_gpio_pin,
- "pixel_order": state.dw_led_pixel_order,
- "brightness": state.dw_led_brightness,
- "speed": state.dw_led_speed,
- "intensity": state.dw_led_intensity,
- "idle_effect": state.dw_led_idle_effect,
- "playing_effect": state.dw_led_playing_effect,
- "idle_timeout_enabled": state.dw_led_idle_timeout_enabled,
- "idle_timeout_minutes": state.dw_led_idle_timeout_minutes
- }
- },
- "mqtt": {
- "enabled": state.mqtt_enabled,
- "broker": state.mqtt_broker,
- "port": state.mqtt_port,
- "username": state.mqtt_username,
- "has_password": bool(state.mqtt_password),
- "client_id": state.mqtt_client_id,
- "discovery_prefix": state.mqtt_discovery_prefix,
- "device_id": state.mqtt_device_id,
- "device_name": state.mqtt_device_name
- },
- "machine": {
- "detected_table_type": state.table_type,
- "table_type_override": state.table_type_override,
- "effective_table_type": state.table_type_override or state.table_type,
- "gear_ratio": state.gear_ratio,
- "x_steps_per_mm": state.x_steps_per_mm,
- "y_steps_per_mm": state.y_steps_per_mm,
- "timezone": state.timezone,
- "available_table_types": [
- {"value": "dune_weaver_mini", "label": "Dune Weaver Mini"},
- {"value": "dune_weaver_mini_pro", "label": "Dune Weaver Mini Pro"},
- {"value": "dune_weaver_mini_pro_byj", "label": "Dune Weaver Mini Pro (BYJ)"},
- {"value": "dune_weaver_gold", "label": "Dune Weaver Gold"},
- {"value": "dune_weaver", "label": "Dune Weaver"},
- {"value": "dune_weaver_pro", "label": "Dune Weaver Pro"}
- ]
- }
- }
- @app.get("/api/manifest.webmanifest", tags=["settings"])
- async def get_dynamic_manifest():
- """
- Get a dynamically generated web manifest.
- Returns manifest with custom icons and app name if custom branding is configured,
- otherwise returns defaults.
- """
- # Determine icon paths based on whether custom logo exists
- if state.custom_logo:
- icon_base = "/static/custom"
- else:
- icon_base = "/static"
- # Use custom app name or default
- app_name = state.app_name or "Dune Weaver"
- return {
- "name": app_name,
- "short_name": app_name,
- "description": "Control your kinetic sand table",
- "icons": [
- {
- "src": f"{icon_base}/android-chrome-192x192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "any"
- },
- {
- "src": f"{icon_base}/android-chrome-512x512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "any"
- },
- {
- "src": f"{icon_base}/android-chrome-192x192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": f"{icon_base}/android-chrome-512x512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "maskable"
- }
- ],
- "start_url": "/",
- "scope": "/",
- "display": "standalone",
- "orientation": "any",
- "theme_color": "#0a0a0a",
- "background_color": "#0a0a0a",
- "categories": ["utilities", "entertainment"]
- }
- @app.patch("/api/settings", tags=["settings"])
- async def update_settings(settings_update: SettingsUpdate):
- """
- Partially update application settings.
- Only include the categories and fields you want to update.
- All fields are optional - only provided values will be updated.
- Example: {"app": {"name": "Dune Weaver"}, "auto_play": {"enabled": true}}
- """
- updated_categories = []
- requires_restart = False
- led_reinit_needed = False
- old_led_provider = state.led_provider
- # App settings
- if settings_update.app:
- if settings_update.app.name is not None:
- state.app_name = settings_update.app.name or "Dune Weaver"
- if settings_update.app.custom_logo is not None:
- state.custom_logo = settings_update.app.custom_logo or None
- updated_categories.append("app")
- # Connection settings
- if settings_update.connection:
- if settings_update.connection.preferred_port is not None:
- # Store exactly what frontend sends: "__auto__", "__none__", or specific port
- state.preferred_port = settings_update.connection.preferred_port
- updated_categories.append("connection")
- # Pattern settings
- if settings_update.patterns:
- p = settings_update.patterns
- if p.clear_pattern_speed is not None:
- state.clear_pattern_speed = p.clear_pattern_speed if p.clear_pattern_speed > 0 else None
- if p.custom_clear_from_in is not None:
- state.custom_clear_from_in = p.custom_clear_from_in or None
- if p.custom_clear_from_out is not None:
- state.custom_clear_from_out = p.custom_clear_from_out or None
- updated_categories.append("patterns")
- # Auto-play settings
- if settings_update.auto_play:
- ap = settings_update.auto_play
- if ap.enabled is not None:
- state.auto_play_enabled = ap.enabled
- if ap.playlist is not None:
- state.auto_play_playlist = ap.playlist or None
- if ap.run_mode is not None:
- state.auto_play_run_mode = ap.run_mode
- if ap.pause_time is not None:
- state.auto_play_pause_time = ap.pause_time
- if ap.clear_pattern is not None:
- state.auto_play_clear_pattern = ap.clear_pattern
- if ap.shuffle is not None:
- state.auto_play_shuffle = ap.shuffle
- updated_categories.append("auto_play")
- # Scheduled pause (Still Sands) settings
- if settings_update.scheduled_pause:
- sp = settings_update.scheduled_pause
- if sp.enabled is not None:
- state.scheduled_pause_enabled = sp.enabled
- if sp.control_wled is not None:
- state.scheduled_pause_control_wled = sp.control_wled
- if sp.finish_pattern is not None:
- state.scheduled_pause_finish_pattern = sp.finish_pattern
- if sp.timezone is not None:
- # Empty string means use system default (store as None)
- state.scheduled_pause_timezone = sp.timezone if sp.timezone else None
- # Clear cached timezone in pattern_manager so it picks up the new setting
- from modules.core import pattern_manager
- pattern_manager._cached_timezone = None
- pattern_manager._cached_zoneinfo = None
- if sp.time_slots is not None:
- state.scheduled_pause_time_slots = [slot.model_dump() for slot in sp.time_slots]
- updated_categories.append("scheduled_pause")
- # Homing settings
- if settings_update.homing:
- h = settings_update.homing
- if h.mode is not None:
- state.homing = h.mode
- state.homing_user_override = True # User explicitly set preference
- if h.angular_offset_degrees is not None:
- state.angular_homing_offset_degrees = h.angular_offset_degrees
- if h.auto_home_enabled is not None:
- state.auto_home_enabled = h.auto_home_enabled
- if h.auto_home_after_patterns is not None:
- state.auto_home_after_patterns = h.auto_home_after_patterns
- if h.hard_reset_theta is not None:
- state.hard_reset_theta = h.hard_reset_theta
- updated_categories.append("homing")
- # LED settings
- if settings_update.led:
- led = settings_update.led
- if led.provider is not None:
- state.led_provider = led.provider
- if led.provider != old_led_provider:
- led_reinit_needed = True
- if led.wled_ip is not None:
- state.wled_ip = led.wled_ip or None
- if led.dw_led:
- dw = led.dw_led
- if dw.num_leds is not None:
- state.dw_led_num_leds = dw.num_leds
- if dw.gpio_pin is not None:
- state.dw_led_gpio_pin = dw.gpio_pin
- if dw.pixel_order is not None:
- state.dw_led_pixel_order = dw.pixel_order
- if dw.brightness is not None:
- state.dw_led_brightness = dw.brightness
- if dw.speed is not None:
- state.dw_led_speed = dw.speed
- if dw.intensity is not None:
- state.dw_led_intensity = dw.intensity
- if dw.idle_effect is not None:
- state.dw_led_idle_effect = dw.idle_effect
- if dw.playing_effect is not None:
- state.dw_led_playing_effect = dw.playing_effect
- if dw.idle_timeout_enabled is not None:
- state.dw_led_idle_timeout_enabled = dw.idle_timeout_enabled
- if dw.idle_timeout_minutes is not None:
- state.dw_led_idle_timeout_minutes = dw.idle_timeout_minutes
- updated_categories.append("led")
- # MQTT settings
- if settings_update.mqtt:
- m = settings_update.mqtt
- if m.enabled is not None:
- state.mqtt_enabled = m.enabled
- if m.broker is not None:
- state.mqtt_broker = m.broker
- if m.port is not None:
- state.mqtt_port = m.port
- if m.username is not None:
- state.mqtt_username = m.username
- if m.password is not None:
- state.mqtt_password = m.password
- if m.client_id is not None:
- state.mqtt_client_id = m.client_id
- if m.discovery_prefix is not None:
- state.mqtt_discovery_prefix = m.discovery_prefix
- if m.device_id is not None:
- state.mqtt_device_id = m.device_id
- if m.device_name is not None:
- state.mqtt_device_name = m.device_name
- updated_categories.append("mqtt")
- requires_restart = True
- # Machine settings
- if settings_update.machine:
- m = settings_update.machine
- if m.table_type_override is not None:
- # Empty string or "auto" clears the override
- state.table_type_override = None if m.table_type_override in ("", "auto") else m.table_type_override
- if m.timezone is not None:
- # Validate timezone by trying to create a ZoneInfo object
- try:
- from zoneinfo import ZoneInfo
- except ImportError:
- from backports.zoneinfo import ZoneInfo
- try:
- ZoneInfo(m.timezone) # Validate
- state.timezone = m.timezone
- # Also update scheduled_pause_timezone to keep in sync
- state.scheduled_pause_timezone = m.timezone
- # Clear cached timezone in pattern_manager so it picks up the new setting
- from modules.core import pattern_manager
- pattern_manager._cached_timezone = None
- pattern_manager._cached_zoneinfo = None
- logger.info(f"Timezone updated to: {m.timezone}")
- except Exception as e:
- logger.warning(f"Invalid timezone '{m.timezone}': {e}")
- updated_categories.append("machine")
- # Save state
- state.save()
- # Handle LED reinitialization if provider changed
- if led_reinit_needed:
- logger.info(f"LED provider changed from {old_led_provider} to {state.led_provider}, reinitialization may be needed")
- logger.info(f"Settings updated: {', '.join(updated_categories)}")
- return {
- "success": True,
- "updated_categories": updated_categories,
- "requires_restart": requires_restart,
- "led_reinit_needed": led_reinit_needed
- }
- # ============================================================================
- # Multi-Table Identity Endpoints
- # ============================================================================
- class TableInfoUpdate(BaseModel):
- name: Optional[str] = None
- class KnownTableAdd(BaseModel):
- id: str
- name: str
- url: str
- host: Optional[str] = None
- port: Optional[int] = None
- version: Optional[str] = None
- class KnownTableUpdate(BaseModel):
- name: Optional[str] = None
- @app.get("/api/table-info", tags=["multi-table"])
- async def get_table_info():
- """
- Get table identity information for multi-table discovery.
- Returns the table's unique ID, name, and version.
- """
- return {
- "id": state.table_id,
- "name": state.table_name,
- "version": await version_manager.get_current_version()
- }
- @app.patch("/api/table-info", tags=["multi-table"])
- async def update_table_info(update: TableInfoUpdate):
- """
- Update table identity information.
- Currently only the table name can be updated.
- The table ID is immutable after generation.
- """
- if update.name is not None:
- state.table_name = update.name.strip() or "Dune Weaver"
- state.save()
- logger.info(f"Table name updated to: {state.table_name}")
- return {
- "success": True,
- "id": state.table_id,
- "name": state.table_name
- }
- @app.get("/api/known-tables", tags=["multi-table"])
- async def get_known_tables():
- """
- Get list of known remote tables.
- These are tables that have been manually added and are persisted
- for multi-table management.
- """
- return {"tables": state.known_tables}
- @app.post("/api/known-tables", tags=["multi-table"])
- async def add_known_table(table: KnownTableAdd):
- """
- Add a known remote table.
- This persists the table information so it's available across
- browser sessions and devices.
- """
- # Check if table with same ID already exists
- existing_ids = [t.get("id") for t in state.known_tables]
- if table.id in existing_ids:
- raise HTTPException(status_code=400, detail="Table with this ID already exists")
- # Check if table with same URL already exists
- existing_urls = [t.get("url") for t in state.known_tables]
- if table.url in existing_urls:
- raise HTTPException(status_code=400, detail="Table with this URL already exists")
- new_table = {
- "id": table.id,
- "name": table.name,
- "url": table.url,
- }
- if table.host:
- new_table["host"] = table.host
- if table.port:
- new_table["port"] = table.port
- if table.version:
- new_table["version"] = table.version
- state.known_tables.append(new_table)
- state.save()
- logger.info(f"Added known table: {table.name} ({table.url})")
- return {"success": True, "table": new_table}
- @app.delete("/api/known-tables/{table_id}", tags=["multi-table"])
- async def remove_known_table(table_id: str):
- """
- Remove a known remote table by ID.
- """
- original_count = len(state.known_tables)
- state.known_tables = [t for t in state.known_tables if t.get("id") != table_id]
- if len(state.known_tables) == original_count:
- raise HTTPException(status_code=404, detail="Table not found")
- state.save()
- logger.info(f"Removed known table: {table_id}")
- return {"success": True}
- @app.patch("/api/known-tables/{table_id}", tags=["multi-table"])
- async def update_known_table(table_id: str, update: KnownTableUpdate):
- """
- Update a known remote table's name.
- """
- for table in state.known_tables:
- if table.get("id") == table_id:
- if update.name is not None:
- table["name"] = update.name.strip()
- state.save()
- logger.info(f"Updated known table {table_id}: name={update.name}")
- return {"success": True, "table": table}
- raise HTTPException(status_code=404, detail="Table not found")
- # ============================================================================
- # Individual Settings Endpoints (Deprecated - use /api/settings instead)
- # ============================================================================
- @app.get("/api/auto_play-mode", deprecated=True, tags=["settings-deprecated"])
- async def get_auto_play_mode():
- """DEPRECATED: Use GET /api/settings instead. Get current auto_play mode settings."""
- return {
- "enabled": state.auto_play_enabled,
- "playlist": state.auto_play_playlist,
- "run_mode": state.auto_play_run_mode,
- "pause_time": state.auto_play_pause_time,
- "clear_pattern": state.auto_play_clear_pattern,
- "shuffle": state.auto_play_shuffle
- }
- @app.post("/api/auto_play-mode", deprecated=True, tags=["settings-deprecated"])
- async def set_auto_play_mode(request: auto_playModeRequest):
- """DEPRECATED: Use PATCH /api/settings instead. Update auto_play mode settings."""
- state.auto_play_enabled = request.enabled
- if request.playlist is not None:
- state.auto_play_playlist = request.playlist
- if request.run_mode is not None:
- state.auto_play_run_mode = request.run_mode
- if request.pause_time is not None:
- state.auto_play_pause_time = request.pause_time
- if request.clear_pattern is not None:
- state.auto_play_clear_pattern = request.clear_pattern
- if request.shuffle is not None:
- state.auto_play_shuffle = request.shuffle
- state.save()
-
- logger.info(f"auto_play mode {'enabled' if request.enabled else 'disabled'}, playlist: {request.playlist}")
- return {"success": True, "message": "auto_play mode settings updated"}
- @app.get("/api/scheduled-pause", deprecated=True, tags=["settings-deprecated"])
- async def get_scheduled_pause():
- """DEPRECATED: Use GET /api/settings instead. Get current Still Sands settings."""
- return {
- "enabled": state.scheduled_pause_enabled,
- "control_wled": state.scheduled_pause_control_wled,
- "finish_pattern": state.scheduled_pause_finish_pattern,
- "timezone": state.scheduled_pause_timezone,
- "time_slots": state.scheduled_pause_time_slots
- }
- @app.post("/api/scheduled-pause", deprecated=True, tags=["settings-deprecated"])
- async def set_scheduled_pause(request: ScheduledPauseRequest):
- """Update Still Sands settings."""
- try:
- # Validate time slots
- for i, slot in enumerate(request.time_slots):
- # Validate time format (HH:MM)
- try:
- start_time = datetime.strptime(slot.start_time, "%H:%M").time()
- end_time = datetime.strptime(slot.end_time, "%H:%M").time()
- except ValueError:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid time format in slot {i+1}. Use HH:MM format."
- )
- # Validate days setting
- if slot.days not in ["daily", "weekdays", "weekends", "custom"]:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid days setting in slot {i+1}. Must be 'daily', 'weekdays', 'weekends', or 'custom'."
- )
- # Validate custom days if applicable
- if slot.days == "custom":
- if not slot.custom_days or len(slot.custom_days) == 0:
- raise HTTPException(
- status_code=400,
- detail=f"Custom days must be specified for slot {i+1} when days is set to 'custom'."
- )
- valid_days = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
- for day in slot.custom_days:
- if day not in valid_days:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid day '{day}' in slot {i+1}. Valid days are: {', '.join(valid_days)}"
- )
- # Update state
- state.scheduled_pause_enabled = request.enabled
- state.scheduled_pause_control_wled = request.control_wled
- state.scheduled_pause_finish_pattern = request.finish_pattern
- state.scheduled_pause_timezone = request.timezone if request.timezone else None
- state.scheduled_pause_time_slots = [slot.model_dump() for slot in request.time_slots]
- state.save()
- # Clear cached timezone so it picks up the new setting
- from modules.core import pattern_manager
- pattern_manager._cached_timezone = None
- pattern_manager._cached_zoneinfo = None
- wled_msg = " (with WLED control)" if request.control_wled else ""
- finish_msg = " (finish pattern first)" if request.finish_pattern else ""
- tz_msg = f" (timezone: {request.timezone})" if request.timezone else ""
- logger.info(f"Still Sands {'enabled' if request.enabled else 'disabled'} with {len(request.time_slots)} time slots{wled_msg}{finish_msg}{tz_msg}")
- return {"success": True, "message": "Still Sands settings updated"}
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error updating Still Sands settings: {str(e)}")
- raise HTTPException(status_code=500, detail=f"Failed to update Still Sands settings: {str(e)}")
- @app.get("/api/homing-config", deprecated=True, tags=["settings-deprecated"])
- async def get_homing_config():
- """Get homing configuration (mode, compass offset, and auto-home settings)."""
- return {
- "homing_mode": state.homing,
- "angular_homing_offset_degrees": state.angular_homing_offset_degrees,
- "auto_home_enabled": state.auto_home_enabled,
- "auto_home_after_patterns": state.auto_home_after_patterns
- }
- class HomingConfigRequest(BaseModel):
- homing_mode: int = 0 # 0 = crash, 1 = sensor
- angular_homing_offset_degrees: float = 0.0
- auto_home_enabled: Optional[bool] = None
- auto_home_after_patterns: Optional[int] = None
- @app.post("/api/homing-config", deprecated=True, tags=["settings-deprecated"])
- async def set_homing_config(request: HomingConfigRequest):
- """Set homing configuration (mode, compass offset, and auto-home settings)."""
- try:
- # Validate homing mode
- if request.homing_mode not in [0, 1]:
- raise HTTPException(status_code=400, detail="Homing mode must be 0 (crash) or 1 (sensor)")
- state.homing = request.homing_mode
- state.homing_user_override = True # User explicitly set preference
- state.angular_homing_offset_degrees = request.angular_homing_offset_degrees
- # Update auto-home settings if provided
- if request.auto_home_enabled is not None:
- state.auto_home_enabled = request.auto_home_enabled
- if request.auto_home_after_patterns is not None:
- if request.auto_home_after_patterns < 1:
- raise HTTPException(status_code=400, detail="Auto-home after patterns must be at least 1")
- state.auto_home_after_patterns = request.auto_home_after_patterns
- state.save()
- mode_name = "crash" if request.homing_mode == 0 else "sensor"
- logger.info(f"Homing mode set to {mode_name}, compass offset set to {request.angular_homing_offset_degrees}°")
- if request.auto_home_enabled is not None:
- logger.info(f"Auto-home enabled: {state.auto_home_enabled}, after {state.auto_home_after_patterns} patterns")
- return {"success": True, "message": "Homing configuration updated"}
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error updating homing configuration: {str(e)}")
- raise HTTPException(status_code=500, detail=f"Failed to update homing configuration: {str(e)}")
- @app.get("/list_serial_ports")
- async def list_ports():
- logger.debug("Listing available serial ports")
- return await asyncio.to_thread(connection_manager.list_serial_ports)
- @app.post("/connect")
- async def connect(request: ConnectRequest):
- if not request.port:
- state.conn = connection_manager.WebSocketConnection('ws://fluidnc.local:81')
- if not connection_manager.device_init():
- raise HTTPException(status_code=500, detail="Failed to initialize device - could not get machine parameters")
- logger.info('Successfully connected to websocket ws://fluidnc.local:81')
- return {"success": True}
- try:
- state.conn = connection_manager.SerialConnection(request.port)
- if not connection_manager.device_init():
- raise HTTPException(status_code=500, detail="Failed to initialize device - could not get machine parameters")
- logger.info(f'Successfully connected to serial port {request.port}')
- return {"success": True}
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f'Failed to connect to serial port {request.port}: {str(e)}')
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/disconnect")
- async def disconnect():
- try:
- state.conn.close()
- logger.info('Successfully disconnected from serial port')
- return {"success": True}
- except Exception as e:
- logger.error(f'Failed to disconnect serial: {str(e)}')
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/restart_connection")
- async def restart(request: ConnectRequest):
- if not request.port:
- logger.warning("Restart serial request received without port")
- raise HTTPException(status_code=400, detail="No port provided")
- try:
- logger.info(f"Restarting connection on port {request.port}")
- connection_manager.restart_connection()
- return {"success": True}
- except Exception as e:
- logger.error(f"Failed to restart serial on port {request.port}: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- ###############################################################################
- # Debug Serial Terminal - Independent raw serial communication
- ###############################################################################
- # Store for debug serial connections (separate from main connection)
- _debug_serial_connections: dict = {}
- _debug_serial_lock: Optional[asyncio.Lock] = None
- def get_debug_serial_lock() -> asyncio.Lock:
- """Get or create the debug serial lock in the current event loop."""
- global _debug_serial_lock
- if _debug_serial_lock is None:
- _debug_serial_lock = asyncio.Lock()
- return _debug_serial_lock
- class DebugSerialRequest(BaseModel):
- port: str
- baudrate: int = 115200
- timeout: float = 2.0
- class DebugSerialCommand(BaseModel):
- port: str
- command: str
- timeout: float = 2.0
- @app.post("/api/debug-serial/open", tags=["debug-serial"])
- async def debug_serial_open(request: DebugSerialRequest):
- """Open a debug serial connection (independent of main connection)."""
- import serial
- async with get_debug_serial_lock():
- # Close existing connection on this port if any
- if request.port in _debug_serial_connections:
- try:
- _debug_serial_connections[request.port].close()
- except:
- pass
- del _debug_serial_connections[request.port]
- try:
- ser = serial.Serial(
- request.port,
- baudrate=request.baudrate,
- timeout=request.timeout
- )
- _debug_serial_connections[request.port] = ser
- logger.info(f"Debug serial opened on {request.port}")
- return {"success": True, "port": request.port, "baudrate": request.baudrate}
- except Exception as e:
- logger.error(f"Failed to open debug serial on {request.port}: {e}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/debug-serial/close", tags=["debug-serial"])
- async def debug_serial_close(request: ConnectRequest):
- """Close a debug serial connection."""
- async with get_debug_serial_lock():
- if request.port not in _debug_serial_connections:
- return {"success": True, "message": "Port not open"}
- try:
- _debug_serial_connections[request.port].close()
- del _debug_serial_connections[request.port]
- logger.info(f"Debug serial closed on {request.port}")
- return {"success": True}
- except Exception as e:
- logger.error(f"Failed to close debug serial on {request.port}: {e}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/debug-serial/send", tags=["debug-serial"])
- async def debug_serial_send(request: DebugSerialCommand):
- """Send a command and receive response on debug serial connection."""
- import serial
- async with get_debug_serial_lock():
- if request.port not in _debug_serial_connections:
- raise HTTPException(status_code=400, detail="Port not open. Open it first.")
- ser = _debug_serial_connections[request.port]
- try:
- # Clear input buffer
- ser.reset_input_buffer()
- # Send command with newline
- command = request.command.strip()
- if not command.endswith('\n'):
- command += '\n'
- await asyncio.to_thread(ser.write, command.encode())
- await asyncio.to_thread(ser.flush)
- # Read response with timeout - use read() for more reliable data capture
- responses = []
- start_time = time.time()
- buffer = ""
- # Small delay to let response arrive
- await asyncio.sleep(0.05)
- while time.time() - start_time < request.timeout:
- try:
- # Read all available bytes
- waiting = ser.in_waiting
- if waiting > 0:
- data = await asyncio.to_thread(ser.read, waiting)
- if data:
- buffer += data.decode('utf-8', errors='replace')
- # Process complete lines from buffer
- while '\n' in buffer:
- line, buffer = buffer.split('\n', 1)
- line = line.strip()
- if line:
- responses.append(line)
- # Check for ok/error to know command completed
- if line.lower() in ['ok', 'error'] or line.lower().startswith('error:'):
- # Give a tiny bit more time for any trailing data
- await asyncio.sleep(0.02)
- # Read any remaining data
- if ser.in_waiting > 0:
- extra = await asyncio.to_thread(ser.read, ser.in_waiting)
- if extra:
- for extra_line in extra.decode('utf-8', errors='replace').strip().split('\n'):
- if extra_line.strip():
- responses.append(extra_line.strip())
- break
- else:
- # No data waiting, small delay
- await asyncio.sleep(0.02)
- except Exception as read_error:
- logger.warning(f"Read error: {read_error}")
- break
- # Add any remaining buffer content
- if buffer.strip():
- responses.append(buffer.strip())
- return {
- "success": True,
- "command": request.command.strip(),
- "responses": responses,
- "raw": '\n'.join(responses)
- }
- except Exception as e:
- logger.error(f"Debug serial send error: {e}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/api/debug-serial/status", tags=["debug-serial"])
- async def debug_serial_status():
- """Get status of all debug serial connections."""
- async with get_debug_serial_lock():
- status = {}
- for port, ser in _debug_serial_connections.items():
- try:
- status[port] = {
- "open": ser.is_open,
- "baudrate": ser.baudrate
- }
- except:
- status[port] = {"open": False}
- return {"connections": status}
- @app.get("/list_theta_rho_files")
- async def list_theta_rho_files():
- logger.debug("Listing theta-rho files")
- # Run the blocking file system operation in a thread pool
- files = await asyncio.to_thread(pattern_manager.list_theta_rho_files)
- return sorted(files)
- @app.get("/list_theta_rho_files_with_metadata")
- async def list_theta_rho_files_with_metadata():
- """Get list of theta-rho files with metadata for sorting and filtering.
-
- Optimized to process files asynchronously and support request cancellation.
- """
- from modules.core.cache_manager import get_pattern_metadata
- import asyncio
- from concurrent.futures import ThreadPoolExecutor
-
- # Run the blocking file listing in a thread
- files = await asyncio.to_thread(pattern_manager.list_theta_rho_files)
- files_with_metadata = []
- # Use ThreadPoolExecutor for I/O-bound operations
- executor = ThreadPoolExecutor(max_workers=4)
-
- def process_file(file_path):
- """Process a single file and return its metadata."""
- try:
- full_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path)
-
- # Get file stats
- file_stat = os.stat(full_path)
-
- # Get cached metadata (this should be fast if cached)
- metadata = get_pattern_metadata(file_path)
-
- # Extract full folder path from file path
- path_parts = file_path.split('/')
- if len(path_parts) > 1:
- # Get everything except the filename (join all folder parts)
- category = '/'.join(path_parts[:-1])
- else:
- category = 'root'
-
- # Get file name without extension
- file_name = os.path.splitext(os.path.basename(file_path))[0]
-
- # Use modification time (mtime) for "date modified"
- date_modified = file_stat.st_mtime
-
- return {
- 'path': file_path,
- 'name': file_name,
- 'category': category,
- 'date_modified': date_modified,
- 'coordinates_count': metadata.get('total_coordinates', 0) if metadata else 0
- }
-
- except Exception as e:
- logger.warning(f"Error getting metadata for {file_path}: {str(e)}")
- # Include file with minimal info if metadata fails
- path_parts = file_path.split('/')
- if len(path_parts) > 1:
- category = '/'.join(path_parts[:-1])
- else:
- category = 'root'
- return {
- 'path': file_path,
- 'name': os.path.splitext(os.path.basename(file_path))[0],
- 'category': category,
- 'date_modified': 0,
- 'coordinates_count': 0
- }
-
- # Load the entire metadata cache at once (async)
- # This is much faster than 1000+ individual metadata lookups
- try:
- import json
- metadata_cache_path = "metadata_cache.json"
- # Use async file reading to avoid blocking the event loop
- cache_data = await asyncio.to_thread(lambda: json.load(open(metadata_cache_path, 'r')))
- cache_dict = cache_data.get('data', {})
- logger.debug(f"Loaded metadata cache with {len(cache_dict)} entries")
- # Process all files using cached data only
- for file_path in files:
- try:
- # Extract category from path
- path_parts = file_path.split('/')
- category = '/'.join(path_parts[:-1]) if len(path_parts) > 1 else 'root'
- # Get file name without extension
- file_name = os.path.splitext(os.path.basename(file_path))[0]
- # Get metadata from cache
- cached_entry = cache_dict.get(file_path, {})
- if isinstance(cached_entry, dict) and 'metadata' in cached_entry:
- metadata = cached_entry['metadata']
- coords_count = metadata.get('total_coordinates', 0)
- date_modified = cached_entry.get('mtime', 0)
- else:
- coords_count = 0
- date_modified = 0
- files_with_metadata.append({
- 'path': file_path,
- 'name': file_name,
- 'category': category,
- 'date_modified': date_modified,
- 'coordinates_count': coords_count
- })
- except Exception as e:
- logger.warning(f"Error processing {file_path}: {e}")
- # Include file with minimal info if processing fails
- path_parts = file_path.split('/')
- category = '/'.join(path_parts[:-1]) if len(path_parts) > 1 else 'root'
- files_with_metadata.append({
- 'path': file_path,
- 'name': os.path.splitext(os.path.basename(file_path))[0],
- 'category': category,
- 'date_modified': 0,
- 'coordinates_count': 0
- })
- except Exception as e:
- logger.error(f"Failed to load metadata cache, falling back to slow method: {e}")
- # Fallback to original method if cache loading fails
- # Create tasks only when needed
- loop = asyncio.get_running_loop()
- tasks = [loop.run_in_executor(executor, process_file, file_path) for file_path in files]
- for task in asyncio.as_completed(tasks):
- try:
- result = await task
- files_with_metadata.append(result)
- except Exception as task_error:
- logger.error(f"Error processing file: {str(task_error)}")
- # Clean up executor
- executor.shutdown(wait=False)
- return files_with_metadata
- @app.post("/upload_theta_rho")
- async def upload_theta_rho(file: UploadFile = File(...)):
- """Upload a theta-rho file."""
- try:
- # Save the file
- # Ensure custom_patterns directory exists
- custom_patterns_dir = os.path.join(pattern_manager.THETA_RHO_DIR, "custom_patterns")
- os.makedirs(custom_patterns_dir, exist_ok=True)
-
- # Use forward slashes for internal path representation to maintain consistency
- file_path_in_patterns_dir = f"custom_patterns/{file.filename}"
- full_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_path_in_patterns_dir)
-
- # Save the uploaded file with proper encoding for Windows compatibility
- file_content = await file.read()
- try:
- # First try to decode as UTF-8 and re-encode to ensure proper encoding
- text_content = file_content.decode('utf-8')
- with open(full_file_path, "w", encoding='utf-8') as f:
- f.write(text_content)
- except UnicodeDecodeError:
- # If UTF-8 decoding fails, save as binary (fallback)
- with open(full_file_path, "wb") as f:
- f.write(file_content)
-
- logger.info(f"File {file.filename} saved successfully")
-
- # Generate image preview for the new file with retry logic
- max_retries = 3
- for attempt in range(max_retries):
- try:
- logger.info(f"Generating preview for {file_path_in_patterns_dir} (attempt {attempt + 1}/{max_retries})")
- success = await generate_image_preview(file_path_in_patterns_dir)
- if success:
- logger.info(f"Preview generated successfully for {file_path_in_patterns_dir}")
- break
- else:
- logger.warning(f"Preview generation failed for {file_path_in_patterns_dir} (attempt {attempt + 1})")
- if attempt < max_retries - 1:
- await asyncio.sleep(0.5) # Small delay before retry
- except Exception as e:
- logger.error(f"Error generating preview for {file_path_in_patterns_dir} (attempt {attempt + 1}): {str(e)}")
- if attempt < max_retries - 1:
- await asyncio.sleep(0.5) # Small delay before retry
-
- return {"success": True, "message": f"File {file.filename} uploaded successfully"}
- except Exception as e:
- logger.error(f"Error uploading file: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/get_theta_rho_coordinates")
- async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
- """Get theta-rho coordinates for animated preview."""
- try:
- # Normalize file path for cross-platform compatibility and remove prefixes
- file_name = normalize_file_path(request.file_name)
- file_path = os.path.join(THETA_RHO_DIR, file_name)
- # Check if we can use cached coordinates (already loaded for current playback)
- # This avoids re-parsing large files (2MB+) which can cause issues on Pi Zero 2W
- current_file = state.current_playing_file
- if current_file and state._current_coordinates:
- # Normalize current file path for comparison
- current_normalized = normalize_file_path(current_file)
- if current_normalized == file_name:
- logger.debug(f"Using cached coordinates for {file_name}")
- return {
- "success": True,
- "coordinates": state._current_coordinates,
- "total_points": len(state._current_coordinates)
- }
- # Check file existence asynchronously
- exists = await asyncio.to_thread(os.path.exists, file_path)
- if not exists:
- raise HTTPException(status_code=404, detail=f"File {file_name} not found")
- # Parse the theta-rho file in a thread (not process) to avoid memory pressure
- # on resource-constrained devices like Pi Zero 2W
- coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
- if not coordinates:
- raise HTTPException(status_code=400, detail="No valid coordinates found in file")
- return {
- "success": True,
- "coordinates": coordinates,
- "total_points": len(coordinates)
- }
- except Exception as e:
- logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/run_theta_rho")
- async def run_theta_rho(request: ThetaRhoRequest, background_tasks: BackgroundTasks):
- if not request.file_name:
- logger.warning('Run theta-rho request received without file name')
- raise HTTPException(status_code=400, detail="No file name provided")
-
- file_path = None
- if 'clear' in request.file_name:
- logger.info(f'Clear pattern file: {request.file_name.split(".")[0]}')
- file_path = pattern_manager.get_clear_pattern_file(request.file_name.split('.')[0])
- logger.info(f'Clear pattern file: {file_path}')
- if not file_path:
- # Normalize file path for cross-platform compatibility
- normalized_file_name = normalize_file_path(request.file_name)
- file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
- if not os.path.exists(file_path):
- logger.error(f'Theta-rho file not found: {file_path}')
- raise HTTPException(status_code=404, detail="File not found")
- try:
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to run a pattern without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- check_homing_in_progress()
- if pattern_manager.get_pattern_lock().locked():
- logger.info("Another pattern is running, stopping it first...")
- await pattern_manager.stop_actions()
-
- files_to_run = [file_path]
- logger.info(f'Running theta-rho file: {request.file_name} with pre_execution={request.pre_execution}')
-
- # Only include clear_pattern if it's not "none"
- kwargs = {}
- if request.pre_execution != "none":
- kwargs['clear_pattern'] = request.pre_execution
-
- # Pass arguments properly
- background_tasks.add_task(
- pattern_manager.run_theta_rho_files,
- files_to_run, # First positional argument
- **kwargs # Spread keyword arguments
- )
- return {"success": True}
- except HTTPException as http_exc:
- logger.error(f'Failed to run theta-rho file {request.file_name}: {http_exc.detail}')
- raise http_exc
- except Exception as e:
- logger.error(f'Failed to run theta-rho file {request.file_name}: {str(e)}')
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/stop_execution")
- async def stop_execution():
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to stop without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- success = await pattern_manager.stop_actions()
- if not success:
- raise HTTPException(status_code=500, detail="Stop timed out - use force_stop")
- return {"success": True}
- @app.post("/force_stop")
- async def force_stop():
- """Force stop all pattern execution and clear all state. Use when normal stop doesn't work."""
- logger.info("Force stop requested - clearing all pattern state")
- # Set stop flag first
- state.stop_requested = True
- state.pause_requested = False
- # Clear all pattern-related state
- state.current_playing_file = None
- state.execution_progress = None
- state.is_running = False
- state.is_clearing = False
- state.is_homing = False
- state.current_playlist = None
- state.current_playlist_index = None
- state.playlist_mode = None
- state.pause_time_remaining = 0
- # Wake up any waiting tasks
- try:
- pattern_manager.get_pause_event().set()
- except:
- pass
- # Stop motion controller and clear its queue
- if pattern_manager.motion_controller.running:
- pattern_manager.motion_controller.command_queue.put(
- pattern_manager.MotionCommand('stop')
- )
- # Force release pattern lock by recreating it
- pattern_manager.pattern_lock = None # Will be recreated on next use
- logger.info("Force stop completed - all pattern state cleared")
- return {"success": True, "message": "Force stop completed"}
- @app.post("/soft_reset")
- async def soft_reset():
- """Send $Bye soft reset to FluidNC controller. Resets position counters to 0."""
- if not (state.conn and state.conn.is_connected()):
- logger.warning("Attempted to soft reset without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- try:
- # Stop any running patterns first
- await pattern_manager.stop_actions()
- # Use the shared soft reset function
- await connection_manager.perform_soft_reset()
- return {"success": True, "message": "Soft reset sent. Position reset to 0."}
- except Exception as e:
- logger.error(f"Error sending soft reset: {e}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/controller_restart")
- async def controller_restart():
- """Send $System/Control=RESTART to restart the FluidNC controller."""
- if not (state.conn and state.conn.is_connected()):
- logger.warning("Attempted to restart controller without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- try:
- # Stop any running patterns first
- await pattern_manager.stop_actions()
- # Send the FluidNC restart command
- from modules.connection.connection_manager import SerialConnection
- restart_cmd = "$System/Control=RESTART\n"
- if isinstance(state.conn, SerialConnection) and state.conn.ser:
- state.conn.ser.write(restart_cmd.encode())
- state.conn.ser.flush()
- logger.info(f"Controller restart command sent via serial to {state.port}")
- else:
- state.conn.send(restart_cmd)
- logger.info("Controller restart command sent via connection abstraction")
- # Mark as needing homing since position is now unknown
- state.is_homed = False
- return {"success": True, "message": "Controller restart command sent. Homing required."}
- except Exception as e:
- logger.error(f"Error sending controller restart: {e}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/send_home")
- async def send_home():
- try:
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to move to home without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- if state.is_homing:
- raise HTTPException(status_code=409, detail="Homing already in progress")
- # Set homing flag to block other movement operations
- state.is_homing = True
- logger.info("Homing started - blocking other movement operations")
- try:
- # Run homing with 15 second timeout
- success = await asyncio.to_thread(connection_manager.home)
- if not success:
- logger.error("Homing failed or timed out")
- raise HTTPException(status_code=500, detail="Homing failed or timed out after 15 seconds")
- return {"success": True}
- finally:
- # Always clear homing flag when done (success or failure)
- state.is_homing = False
- logger.info("Homing completed - movement operations unblocked")
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to send home command: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- class SensorHomingRecoveryRequest(BaseModel):
- switch_to_crash_homing: bool = False
- @app.post("/recover_sensor_homing")
- async def recover_sensor_homing(request: SensorHomingRecoveryRequest):
- """
- Recover from sensor homing failure.
- If switch_to_crash_homing is True, changes homing mode to crash homing (mode 0)
- and saves the setting. Then attempts to reconnect and home the device.
- If switch_to_crash_homing is False, just clears the failure flag and retries
- with sensor homing.
- """
- try:
- # Clear the sensor homing failure flag first
- state.sensor_homing_failed = False
- if request.switch_to_crash_homing:
- # Switch to crash homing mode
- logger.info("Switching to crash homing mode per user request")
- state.homing = 0
- state.homing_user_override = True
- state.save()
- # If already connected, just perform homing
- if state.conn and state.conn.is_connected():
- logger.info("Device already connected, performing homing...")
- state.is_homing = True
- try:
- success = await asyncio.to_thread(connection_manager.home)
- if not success:
- # Check if sensor homing failed again
- if state.sensor_homing_failed:
- return {
- "success": False,
- "sensor_homing_failed": True,
- "message": "Sensor homing failed again. Please check sensor position or switch to crash homing."
- }
- return {"success": False, "message": "Homing failed"}
- return {"success": True, "message": "Homing completed successfully"}
- finally:
- state.is_homing = False
- else:
- # Need to reconnect
- logger.info("Reconnecting device and performing homing...")
- state.is_homing = True
- try:
- # connect_device includes homing
- await asyncio.to_thread(connection_manager.connect_device, True)
- # Check if sensor homing failed during connection
- if state.sensor_homing_failed:
- return {
- "success": False,
- "sensor_homing_failed": True,
- "message": "Sensor homing failed. Please check sensor position or switch to crash homing."
- }
- if state.conn and state.conn.is_connected():
- return {"success": True, "message": "Connected and homed successfully"}
- else:
- return {"success": False, "message": "Failed to establish connection"}
- finally:
- state.is_homing = False
- except Exception as e:
- logger.error(f"Error during sensor homing recovery: {e}")
- state.is_homing = False
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/run_theta_rho_file/{file_name}")
- async def run_specific_theta_rho_file(file_name: str):
- file_path = os.path.join(pattern_manager.THETA_RHO_DIR, file_name)
- if not os.path.exists(file_path):
- raise HTTPException(status_code=404, detail="File not found")
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to run a pattern without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- check_homing_in_progress()
- pattern_manager.run_theta_rho_file(file_path)
- return {"success": True}
- class DeleteFileRequest(BaseModel):
- file_name: str
- @app.post("/delete_theta_rho_file")
- async def delete_theta_rho_file(request: DeleteFileRequest):
- if not request.file_name:
- logger.warning("Delete theta-rho file request received without filename")
- raise HTTPException(status_code=400, detail="No file name provided")
- # Normalize file path for cross-platform compatibility
- normalized_file_name = normalize_file_path(request.file_name)
- file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
- # Check file existence asynchronously
- exists = await asyncio.to_thread(os.path.exists, file_path)
- if not exists:
- logger.error(f"Attempted to delete non-existent file: {file_path}")
- raise HTTPException(status_code=404, detail="File not found")
- try:
- # Delete the pattern file asynchronously
- await asyncio.to_thread(os.remove, file_path)
- logger.info(f"Successfully deleted theta-rho file: {request.file_name}")
-
- # Clean up cached preview image and metadata asynchronously
- from modules.core.cache_manager import delete_pattern_cache
- cache_cleanup_success = await asyncio.to_thread(delete_pattern_cache, normalized_file_name)
- if cache_cleanup_success:
- logger.info(f"Successfully cleaned up cache for {request.file_name}")
- else:
- logger.warning(f"Cache cleanup failed for {request.file_name}, but pattern was deleted")
-
- return {"success": True, "cache_cleanup": cache_cleanup_success}
- except Exception as e:
- logger.error(f"Failed to delete theta-rho file {request.file_name}: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/move_to_center")
- async def move_to_center():
- try:
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to move to center without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- check_homing_in_progress()
- # Clear stop_requested to ensure manual move works after pattern stop
- state.stop_requested = False
- logger.info("Moving device to center position")
- await pattern_manager.reset_theta()
- await pattern_manager.move_polar(0, 0)
- # Wait for machine to reach idle before returning
- idle = await connection_manager.check_idle_async(timeout=60)
- if not idle:
- logger.warning("Machine did not reach idle after move to center")
- return {"success": True}
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to move to center: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/move_to_perimeter")
- async def move_to_perimeter():
- try:
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to move to perimeter without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- check_homing_in_progress()
- # Clear stop_requested to ensure manual move works after pattern stop
- state.stop_requested = False
- logger.info("Moving device to perimeter position")
- await pattern_manager.reset_theta()
- await pattern_manager.move_polar(0, 1)
- # Wait for machine to reach idle before returning
- idle = await connection_manager.check_idle_async(timeout=60)
- if not idle:
- logger.warning("Machine did not reach idle after move to perimeter")
- return {"success": True}
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to move to perimeter: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/preview_thr")
- async def preview_thr(request: DeleteFileRequest):
- if not request.file_name:
- logger.warning("Preview theta-rho request received without filename")
- raise HTTPException(status_code=400, detail="No file name provided")
- # Normalize file path for cross-platform compatibility
- normalized_file_name = normalize_file_path(request.file_name)
- # Construct the full path to the pattern file to check existence
- pattern_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
- # Check file existence asynchronously
- exists = await asyncio.to_thread(os.path.exists, pattern_file_path)
- if not exists:
- logger.error(f"Attempted to preview non-existent pattern file: {pattern_file_path}")
- raise HTTPException(status_code=404, detail="Pattern file not found")
- try:
- cache_path = get_cache_path(normalized_file_name)
- # Check cache existence asynchronously
- cache_exists = await asyncio.to_thread(os.path.exists, cache_path)
- if not cache_exists:
- logger.info(f"Cache miss for {request.file_name}. Generating preview...")
- # Attempt to generate the preview if it's missing
- success = await generate_image_preview(normalized_file_name)
- cache_exists_after = await asyncio.to_thread(os.path.exists, cache_path)
- if not success or not cache_exists_after:
- logger.error(f"Failed to generate or find preview for {request.file_name} after attempting generation.")
- raise HTTPException(status_code=500, detail="Failed to generate preview image.")
- # Try to get coordinates from metadata cache first
- metadata = get_pattern_metadata(normalized_file_name)
- if metadata:
- first_coord_obj = metadata.get('first_coordinate')
- last_coord_obj = metadata.get('last_coordinate')
- else:
- # Fallback to parsing file if metadata not cached (shouldn't happen after initial cache)
- logger.debug(f"Metadata cache miss for {request.file_name}, parsing file")
- coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
- first_coord = coordinates[0] if coordinates else None
- last_coord = coordinates[-1] if coordinates else None
-
- # Format coordinates as objects with x and y properties
- first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
- last_coord_obj = {"x": last_coord[0], "y": last_coord[1]} if last_coord else None
- # Return JSON with preview URL and coordinates
- # URL encode the file_name for the preview URL
- # Handle both forward slashes and backslashes for cross-platform compatibility
- encoded_filename = normalized_file_name.replace('\\', '--').replace('/', '--')
- return {
- "preview_url": f"/preview/{encoded_filename}",
- "first_coordinate": first_coord_obj,
- "last_coordinate": last_coord_obj
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Failed to generate or serve preview for {request.file_name}: {str(e)}")
- raise HTTPException(status_code=500, detail=f"Failed to serve preview image: {str(e)}")
- @app.get("/api/pattern_history/{pattern_name:path}")
- async def get_pattern_history(pattern_name: str):
- """Get the most recent execution history for a pattern.
- Returns the last completed execution time and speed for the given pattern.
- """
- from modules.core.pattern_manager import get_pattern_execution_history
- # Get just the filename if a full path was provided
- filename = os.path.basename(pattern_name)
- if not filename.endswith('.thr'):
- filename = f"{filename}.thr"
- history = get_pattern_execution_history(filename)
- if history:
- return history
- return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
- @app.get("/api/pattern_history_all")
- async def get_all_pattern_history():
- """Get execution history for all patterns in a single request.
- Returns a dict mapping pattern names to their most recent execution history.
- """
- from modules.core.pattern_manager import EXECUTION_LOG_FILE
- import json
- if not os.path.exists(EXECUTION_LOG_FILE):
- return {}
- try:
- history_map = {}
- with open(EXECUTION_LOG_FILE, 'r') as f:
- for line in f:
- line = line.strip()
- if not line:
- continue
- try:
- entry = json.loads(line)
- # Only consider fully completed patterns
- if entry.get('completed', False):
- pattern_name = entry.get('pattern_name')
- if pattern_name:
- # Keep the most recent match (last one in file wins)
- history_map[pattern_name] = {
- "actual_time_seconds": entry.get('actual_time_seconds'),
- "actual_time_formatted": entry.get('actual_time_formatted'),
- "speed": entry.get('speed'),
- "timestamp": entry.get('timestamp')
- }
- except json.JSONDecodeError:
- continue
- return history_map
- except Exception as e:
- logger.error(f"Failed to read execution time log: {e}")
- return {}
- @app.get("/preview/{encoded_filename}")
- async def serve_preview(encoded_filename: str):
- """Serve a preview image for a pattern file."""
- # Decode the filename by replacing -- with the original path separators
- # First try forward slash (most common case), then backslash if needed
- file_name = encoded_filename.replace('--', '/')
-
- # Apply normalization to handle any remaining path prefixes
- file_name = normalize_file_path(file_name)
-
- # Check if the decoded path exists, if not try backslash decoding
- cache_path = get_cache_path(file_name)
- if not os.path.exists(cache_path):
- # Try with backslash for Windows paths
- file_name_backslash = encoded_filename.replace('--', '\\')
- file_name_backslash = normalize_file_path(file_name_backslash)
- cache_path_backslash = get_cache_path(file_name_backslash)
- if os.path.exists(cache_path_backslash):
- file_name = file_name_backslash
- cache_path = cache_path_backslash
- # cache_path is already determined above in the decoding logic
- if not os.path.exists(cache_path):
- logger.error(f"Preview image not found for {file_name}")
- raise HTTPException(status_code=404, detail="Preview image not found")
-
- # Add caching headers
- headers = {
- "Cache-Control": "public, max-age=31536000", # Cache for 1 year
- "Content-Type": "image/webp",
- "Accept-Ranges": "bytes"
- }
-
- return FileResponse(
- cache_path,
- media_type="image/webp",
- headers=headers
- )
- @app.post("/send_coordinate")
- async def send_coordinate(request: CoordinateRequest):
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to send coordinate without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- check_homing_in_progress()
- # Clear stop_requested to ensure manual move works after pattern stop
- state.stop_requested = False
- try:
- logger.debug(f"Sending coordinate: theta={request.theta}, rho={request.rho}")
- await pattern_manager.move_polar(request.theta, request.rho)
- # Wait for machine to reach idle before returning
- idle = await connection_manager.check_idle_async(timeout=60)
- if not idle:
- logger.warning("Machine did not reach idle after send_coordinate")
- return {"success": True}
- except Exception as e:
- logger.error(f"Failed to send coordinate: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/download/{filename}")
- async def download_file(filename: str):
- return FileResponse(
- os.path.join(pattern_manager.THETA_RHO_DIR, filename),
- filename=filename
- )
- @app.get("/serial_status")
- async def serial_status():
- connected = state.conn.is_connected() if state.conn else False
- port = state.port
- logger.debug(f"Serial status check - connected: {connected}, port: {port}")
- return {
- "connected": connected,
- "port": port,
- "preferred_port": state.preferred_port
- }
- @app.get("/api/preferred-port", deprecated=True, tags=["settings-deprecated"])
- async def get_preferred_port():
- """Get the currently configured preferred port for auto-connect."""
- return {
- "preferred_port": state.preferred_port
- }
- @app.post("/api/preferred-port", deprecated=True, tags=["settings-deprecated"])
- async def set_preferred_port(request: Request):
- """Set the preferred port for auto-connect."""
- data = await request.json()
- preferred_port = data.get("preferred_port")
- # Allow setting to None to clear the preference
- if preferred_port == "" or preferred_port == "none":
- preferred_port = None
- state.preferred_port = preferred_port
- state.save()
- logger.info(f"Preferred port set to: {preferred_port}")
- return {
- "success": True,
- "preferred_port": state.preferred_port
- }
- @app.post("/pause_execution")
- async def pause_execution():
- if pattern_manager.pause_execution():
- return {"success": True, "message": "Execution paused"}
- raise HTTPException(status_code=500, detail="Failed to pause execution")
- @app.post("/resume_execution")
- async def resume_execution():
- if pattern_manager.resume_execution():
- return {"success": True, "message": "Execution resumed"}
- raise HTTPException(status_code=500, detail="Failed to resume execution")
- # Playlist endpoints
- @app.get("/list_all_playlists")
- async def list_all_playlists():
- playlist_names = playlist_manager.list_all_playlists()
- return playlist_names
- @app.get("/get_playlist")
- async def get_playlist(name: str):
- if not name:
- raise HTTPException(status_code=400, detail="Missing playlist name parameter")
- playlist = playlist_manager.get_playlist(name)
- if not playlist:
- # Auto-create empty playlist if not found
- logger.info(f"Playlist '{name}' not found, creating empty playlist")
- playlist_manager.create_playlist(name, [])
- playlist = {"name": name, "files": []}
- return playlist
- @app.post("/create_playlist")
- async def create_playlist(request: PlaylistRequest):
- success = playlist_manager.create_playlist(request.playlist_name, request.files)
- return {
- "success": success,
- "message": f"Playlist '{request.playlist_name}' created/updated"
- }
- @app.post("/modify_playlist")
- async def modify_playlist(request: PlaylistRequest):
- success = playlist_manager.modify_playlist(request.playlist_name, request.files)
- return {
- "success": success,
- "message": f"Playlist '{request.playlist_name}' updated"
- }
- @app.delete("/delete_playlist")
- async def delete_playlist(request: DeletePlaylistRequest):
- success = playlist_manager.delete_playlist(request.playlist_name)
- if not success:
- raise HTTPException(
- status_code=404,
- detail=f"Playlist '{request.playlist_name}' not found"
- )
- return {
- "success": True,
- "message": f"Playlist '{request.playlist_name}' deleted"
- }
- @app.post("/rename_playlist")
- async def rename_playlist(request: RenamePlaylistRequest):
- """Rename an existing playlist."""
- success, message = playlist_manager.rename_playlist(request.old_name, request.new_name)
- if not success:
- raise HTTPException(
- status_code=400,
- detail=message
- )
- return {
- "success": True,
- "message": message,
- "new_name": request.new_name
- }
- class AddToPlaylistRequest(BaseModel):
- playlist_name: str
- pattern: str
- @app.post("/add_to_playlist")
- async def add_to_playlist(request: AddToPlaylistRequest):
- success = playlist_manager.add_to_playlist(request.playlist_name, request.pattern)
- if not success:
- raise HTTPException(status_code=404, detail="Playlist not found")
- return {"success": True}
- @app.post("/run_playlist")
- async def run_playlist_endpoint(request: PlaylistRequest):
- """Run a playlist with specified parameters."""
- try:
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to run a playlist without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- check_homing_in_progress()
- if not os.path.exists(playlist_manager.PLAYLISTS_FILE):
- raise HTTPException(status_code=404, detail=f"Playlist '{request.playlist_name}' not found")
- # Start the playlist execution
- success, message = await playlist_manager.run_playlist(
- request.playlist_name,
- pause_time=request.pause_time,
- clear_pattern=request.clear_pattern,
- run_mode=request.run_mode,
- shuffle=request.shuffle
- )
- if not success:
- raise HTTPException(status_code=409, detail=message)
- return {"message": f"Started playlist: {request.playlist_name}"}
- except Exception as e:
- logger.error(f"Error running playlist: {e}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/set_speed")
- async def set_speed(request: SpeedRequest):
- try:
- if not (state.conn.is_connected() if state.conn else False):
- logger.warning("Attempted to change speed without a connection")
- raise HTTPException(status_code=400, detail="Connection not established")
- if request.speed <= 0:
- logger.warning(f"Invalid speed value received: {request.speed}")
- raise HTTPException(status_code=400, detail="Invalid speed value")
- state.speed = request.speed
- return {"success": True, "speed": request.speed}
- except HTTPException:
- raise # Re-raise HTTPException as-is
- except Exception as e:
- logger.error(f"Failed to set speed: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/check_software_update")
- async def check_updates():
- update_info = update_manager.check_git_updates()
- return update_info
- @app.post("/update_software")
- async def update_software():
- logger.info("Starting software update process")
- success, error_message, error_log = update_manager.update_software()
-
- if success:
- logger.info("Software update completed successfully")
- return {"success": True}
- else:
- logger.error(f"Software update failed: {error_message}\nDetails: {error_log}")
- raise HTTPException(
- status_code=500,
- detail={
- "error": error_message,
- "details": error_log
- }
- )
- @app.post("/set_wled_ip")
- async def set_wled_ip(request: WLEDRequest):
- """Legacy endpoint for backward compatibility - sets WLED as LED provider"""
- state.wled_ip = request.wled_ip
- state.led_provider = "wled" if request.wled_ip else "none"
- state.led_controller = LEDInterface("wled", request.wled_ip) if request.wled_ip else None
- if state.led_controller:
- state.led_controller.effect_idle()
- _start_idle_led_timeout()
- state.save()
- logger.info(f"WLED IP updated: {request.wled_ip}")
- return {"success": True, "wled_ip": state.wled_ip}
- @app.get("/get_wled_ip")
- async def get_wled_ip():
- """Legacy endpoint for backward compatibility"""
- if not state.wled_ip:
- raise HTTPException(status_code=404, detail="No WLED IP set")
- return {"success": True, "wled_ip": state.wled_ip}
- @app.post("/set_led_config", deprecated=True, tags=["settings-deprecated"])
- async def set_led_config(request: LEDConfigRequest):
- """DEPRECATED: Use PATCH /api/settings instead. Configure LED provider (WLED, DW LEDs, or none)"""
- if request.provider not in ["wled", "dw_leds", "none"]:
- raise HTTPException(status_code=400, detail="Invalid provider. Must be 'wled', 'dw_leds', or 'none'")
- state.led_provider = request.provider
- if request.provider == "wled":
- if not request.ip_address:
- raise HTTPException(status_code=400, detail="IP address required for WLED")
- state.wled_ip = request.ip_address
- state.led_controller = LEDInterface("wled", request.ip_address)
- logger.info(f"LED provider set to WLED at {request.ip_address}")
- elif request.provider == "dw_leds":
- # Check if hardware settings changed (requires restart)
- old_gpio_pin = state.dw_led_gpio_pin
- old_pixel_order = state.dw_led_pixel_order
- hardware_changed = (
- old_gpio_pin != (request.gpio_pin or 18) or
- old_pixel_order != (request.pixel_order or "RGB")
- )
- # Stop existing DW LED controller if hardware settings changed
- if hardware_changed and state.led_controller and state.led_provider == "dw_leds":
- logger.info("Hardware settings changed, stopping existing LED controller...")
- controller = state.led_controller.get_controller()
- if controller and hasattr(controller, 'stop'):
- try:
- controller.stop()
- logger.info("LED controller stopped successfully")
- except Exception as e:
- logger.error(f"Error stopping LED controller: {e}")
- # Clear the reference and give hardware time to release
- state.led_controller = None
- await asyncio.sleep(0.5)
- state.dw_led_num_leds = request.num_leds or 60
- state.dw_led_gpio_pin = request.gpio_pin or 18
- state.dw_led_pixel_order = request.pixel_order or "RGB"
- state.dw_led_brightness = request.brightness or 35
- state.wled_ip = None
- # Create new LED controller with updated settings
- state.led_controller = LEDInterface(
- "dw_leds",
- num_leds=state.dw_led_num_leds,
- gpio_pin=state.dw_led_gpio_pin,
- pixel_order=state.dw_led_pixel_order,
- brightness=state.dw_led_brightness / 100.0,
- speed=state.dw_led_speed,
- intensity=state.dw_led_intensity
- )
- restart_msg = " (restarted)" if hardware_changed else ""
- logger.info(f"DW LEDs configured{restart_msg}: {state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}, pixel order: {state.dw_led_pixel_order}")
- # Check if initialization succeeded by checking status
- status = state.led_controller.check_status()
- if not status.get("connected", False) and status.get("error"):
- error_msg = status["error"]
- logger.warning(f"DW LED initialization failed: {error_msg}, but configuration saved for testing")
- state.led_controller = None
- # Keep the provider setting for testing purposes
- # state.led_provider remains "dw_leds" so settings can be saved/tested
- # Save state even with error
- state.save()
- # Return success with warning instead of error
- return {
- "success": True,
- "warning": error_msg,
- "hardware_available": False,
- "provider": state.led_provider,
- "dw_led_num_leds": state.dw_led_num_leds,
- "dw_led_gpio_pin": state.dw_led_gpio_pin,
- "dw_led_pixel_order": state.dw_led_pixel_order,
- "dw_led_brightness": state.dw_led_brightness
- }
- else: # none
- state.wled_ip = None
- state.led_controller = None
- logger.info("LED provider disabled")
- # Show idle effect if controller is configured
- if state.led_controller:
- state.led_controller.effect_idle()
- _start_idle_led_timeout()
- state.save()
- return {
- "success": True,
- "provider": state.led_provider,
- "wled_ip": state.wled_ip,
- "dw_led_num_leds": state.dw_led_num_leds,
- "dw_led_gpio_pin": state.dw_led_gpio_pin,
- "dw_led_brightness": state.dw_led_brightness
- }
- @app.get("/get_led_config", deprecated=True, tags=["settings-deprecated"])
- async def get_led_config():
- """DEPRECATED: Use GET /api/settings instead. Get current LED provider configuration"""
- # Auto-detect provider for backward compatibility with existing installations
- provider = state.led_provider
- if not provider or provider == "none":
- # If no provider set but we have IPs configured, auto-detect
- if state.wled_ip:
- provider = "wled"
- state.led_provider = "wled"
- state.save()
- logger.info("Auto-detected WLED provider from existing configuration")
- else:
- provider = "none"
- return {
- "success": True,
- "provider": provider,
- "wled_ip": state.wled_ip,
- "dw_led_num_leds": state.dw_led_num_leds,
- "dw_led_gpio_pin": state.dw_led_gpio_pin,
- "dw_led_pixel_order": state.dw_led_pixel_order,
- "dw_led_brightness": state.dw_led_brightness,
- "dw_led_idle_effect": state.dw_led_idle_effect,
- "dw_led_playing_effect": state.dw_led_playing_effect
- }
- @app.post("/skip_pattern")
- async def skip_pattern():
- if not state.current_playlist:
- raise HTTPException(status_code=400, detail="No playlist is currently running")
- state.skip_requested = True
- # If the playlist task isn't running (e.g., cancelled by TestClient),
- # proactively advance state. Otherwise, let the running task handle it
- # to avoid race conditions with the task's index management.
- from modules.core import playlist_manager
- task = playlist_manager._current_playlist_task
- task_not_running = task is None or task.done()
- if task_not_running and state.current_playlist_index is not None:
- next_index = state.current_playlist_index + 1
- if next_index < len(state.current_playlist):
- state.current_playlist_index = next_index
- state.current_playing_file = state.current_playlist[next_index]
- return {"success": True}
- @app.post("/reorder_playlist")
- async def reorder_playlist(request: dict):
- """Reorder a pattern in the current playlist queue.
- Since the playlist now contains only main patterns (clear patterns are executed
- dynamically at runtime), this simply moves the pattern from one position to another.
- """
- if not state.current_playlist:
- raise HTTPException(status_code=400, detail="No playlist is currently running")
- from_index = request.get("from_index")
- to_index = request.get("to_index")
- if from_index is None or to_index is None:
- raise HTTPException(status_code=400, detail="from_index and to_index are required")
- playlist = list(state.current_playlist) # Make a copy to work with
- current_index = state.current_playlist_index
- # Validate indices
- if from_index < 0 or from_index >= len(playlist):
- raise HTTPException(status_code=400, detail="from_index out of range")
- if to_index < 0 or to_index >= len(playlist):
- raise HTTPException(status_code=400, detail="to_index out of range")
- # Can't move patterns that have already played (before current_index)
- # But CAN move the current pattern or swap with it (allows live reordering)
- if from_index < current_index:
- raise HTTPException(status_code=400, detail="Cannot move completed pattern")
- if to_index < current_index:
- raise HTTPException(status_code=400, detail="Cannot move to completed position")
- # Perform the reorder
- item = playlist.pop(from_index)
- # Adjust to_index if moving forward (since we removed an item before it)
- adjusted_to_index = to_index if to_index < from_index else to_index - 1
- playlist.insert(adjusted_to_index, item)
- # Update state (this triggers the property setter)
- state.current_playlist = playlist
- return {"success": True}
- @app.post("/add_to_queue")
- async def add_to_queue(request: dict):
- """Add a pattern to the current playlist queue.
- Args:
- pattern: The pattern file path to add (e.g., 'circle.thr' or 'subdirectory/pattern.thr')
- position: 'next' to play after current pattern, 'end' to add to end of queue
- """
- if not state.current_playlist:
- raise HTTPException(status_code=400, detail="No playlist is currently running")
- pattern = request.get("pattern")
- position = request.get("position", "end") # 'next' or 'end'
- if not pattern:
- raise HTTPException(status_code=400, detail="pattern is required")
- # Verify the pattern file exists
- pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, pattern)
- if not os.path.exists(pattern_path):
- raise HTTPException(status_code=404, detail="Pattern file not found")
- playlist = list(state.current_playlist)
- current_index = state.current_playlist_index
- if position == "next":
- # Insert right after the current pattern
- insert_index = current_index + 1
- else:
- # Add to end
- insert_index = len(playlist)
- playlist.insert(insert_index, pattern)
- state.current_playlist = playlist
- return {"success": True, "position": insert_index}
- @app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
- async def get_custom_clear_patterns():
- """Get the currently configured custom clear patterns."""
- return {
- "success": True,
- "custom_clear_from_in": state.custom_clear_from_in,
- "custom_clear_from_out": state.custom_clear_from_out
- }
- @app.post("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
- async def set_custom_clear_patterns(request: dict):
- """Set custom clear patterns for clear_from_in and clear_from_out."""
- try:
- # Validate that the patterns exist if they're provided
- if "custom_clear_from_in" in request and request["custom_clear_from_in"]:
- pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, request["custom_clear_from_in"])
- if not os.path.exists(pattern_path):
- raise HTTPException(status_code=400, detail=f"Pattern file not found: {request['custom_clear_from_in']}")
- state.custom_clear_from_in = request["custom_clear_from_in"]
- elif "custom_clear_from_in" in request:
- state.custom_clear_from_in = None
-
- if "custom_clear_from_out" in request and request["custom_clear_from_out"]:
- pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, request["custom_clear_from_out"])
- if not os.path.exists(pattern_path):
- raise HTTPException(status_code=400, detail=f"Pattern file not found: {request['custom_clear_from_out']}")
- state.custom_clear_from_out = request["custom_clear_from_out"]
- elif "custom_clear_from_out" in request:
- state.custom_clear_from_out = None
-
- state.save()
- logger.info(f"Custom clear patterns updated - in: {state.custom_clear_from_in}, out: {state.custom_clear_from_out}")
- return {
- "success": True,
- "custom_clear_from_in": state.custom_clear_from_in,
- "custom_clear_from_out": state.custom_clear_from_out
- }
- except Exception as e:
- logger.error(f"Failed to set custom clear patterns: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/api/clear_pattern_speed", deprecated=True, tags=["settings-deprecated"])
- async def get_clear_pattern_speed():
- """Get the current clearing pattern speed setting."""
- return {
- "success": True,
- "clear_pattern_speed": state.clear_pattern_speed,
- "effective_speed": state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
- }
- @app.post("/api/clear_pattern_speed", deprecated=True, tags=["settings-deprecated"])
- async def set_clear_pattern_speed(request: dict):
- """DEPRECATED: Use PATCH /api/settings instead. Set the clearing pattern speed."""
- try:
- # If speed is None or "none", use default behavior (state.speed)
- speed_value = request.get("clear_pattern_speed")
- if speed_value is None or speed_value == "none" or speed_value == "":
- speed = None
- else:
- speed = int(speed_value)
-
- # Validate speed range (same as regular speed limits) only if speed is not None
- if speed is not None and not (50 <= speed <= 2000):
- raise HTTPException(status_code=400, detail="Speed must be between 50 and 2000")
-
- state.clear_pattern_speed = speed
- state.save()
-
- logger.info(f"Clear pattern speed set to {speed if speed is not None else 'default (state.speed)'}")
- return {
- "success": True,
- "clear_pattern_speed": state.clear_pattern_speed,
- "effective_speed": state.clear_pattern_speed if state.clear_pattern_speed is not None else state.speed
- }
- except ValueError:
- raise HTTPException(status_code=400, detail="Invalid speed value")
- except Exception as e:
- logger.error(f"Failed to set clear pattern speed: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/api/app-name", deprecated=True, tags=["settings-deprecated"])
- async def get_app_name():
- """DEPRECATED: Use GET /api/settings instead. Get current application name."""
- return {"app_name": state.app_name}
- @app.post("/api/app-name", deprecated=True, tags=["settings-deprecated"])
- async def set_app_name(request: dict):
- """DEPRECATED: Use PATCH /api/settings instead. Update application name."""
- app_name = request.get("app_name", "").strip()
- if not app_name:
- app_name = "Dune Weaver" # Reset to default if empty
- state.app_name = app_name
- state.save()
- logger.info(f"Application name updated to: {app_name}")
- return {"success": True, "app_name": app_name}
- # ============================================================================
- # Custom Branding Upload Endpoints
- # ============================================================================
- CUSTOM_BRANDING_DIR = os.path.join("static", "custom")
- ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
- MAX_LOGO_SIZE = 10 * 1024 * 1024 # 10MB
- MAX_LOGO_DIMENSION = 512 # Max width/height for optimized logo
- def optimize_logo_image(content: bytes, original_ext: str) -> tuple[bytes, str]:
- """Optimize logo image by resizing and converting to WebP.
- Args:
- content: Original image bytes
- original_ext: Original file extension (e.g., '.png', '.jpg')
- Returns:
- Tuple of (optimized_bytes, new_extension)
- For SVG files, returns the original content unchanged.
- For raster images, resizes to MAX_LOGO_DIMENSION and converts to WebP.
- """
- # SVG files are already lightweight vectors - keep as-is
- if original_ext.lower() == ".svg":
- return content, original_ext
- try:
- from PIL import Image
- import io
- with Image.open(io.BytesIO(content)) as img:
- # Convert to RGBA for transparency support
- if img.mode in ('P', 'LA') or (img.mode == 'RGBA' and 'transparency' in img.info):
- img = img.convert('RGBA')
- elif img.mode != 'RGBA':
- img = img.convert('RGB')
- # Resize if larger than max dimension (maintain aspect ratio)
- width, height = img.size
- if width > MAX_LOGO_DIMENSION or height > MAX_LOGO_DIMENSION:
- ratio = min(MAX_LOGO_DIMENSION / width, MAX_LOGO_DIMENSION / height)
- new_size = (int(width * ratio), int(height * ratio))
- img = img.resize(new_size, Image.Resampling.LANCZOS)
- logger.info(f"Logo resized from {width}x{height} to {new_size[0]}x{new_size[1]}")
- # Save as WebP with good quality/size balance
- output = io.BytesIO()
- img.save(output, format='WEBP', quality=85, method=6)
- optimized_bytes = output.getvalue()
- original_size = len(content)
- new_size = len(optimized_bytes)
- reduction = ((original_size - new_size) / original_size) * 100
- logger.info(f"Logo optimized: {original_size:,} bytes -> {new_size:,} bytes ({reduction:.1f}% reduction)")
- return optimized_bytes, ".webp"
- except Exception as e:
- logger.warning(f"Logo optimization failed, using original: {str(e)}")
- return content, original_ext
- def generate_favicon_from_logo(logo_path: str, output_dir: str) -> bool:
- """Generate circular favicons with transparent background from the uploaded logo.
- Creates:
- - favicon.ico (multi-size: 256, 128, 64, 48, 32, 16)
- - favicon-16x16.png, favicon-32x32.png, favicon-96x96.png, favicon-128x128.png
- Returns True on success, False on failure.
- """
- try:
- from PIL import Image, ImageDraw
- def create_circular_transparent(img, size):
- """Create circular image with transparent background."""
- resized = img.resize((size, size), Image.Resampling.LANCZOS)
- mask = Image.new('L', (size, size), 0)
- draw = ImageDraw.Draw(mask)
- draw.ellipse((0, 0, size - 1, size - 1), fill=255)
- output = Image.new('RGBA', (size, size), (0, 0, 0, 0))
- output.paste(resized, (0, 0), mask)
- return output
- with Image.open(logo_path) as img:
- # Convert to RGBA if needed
- if img.mode != 'RGBA':
- img = img.convert('RGBA')
- # Crop to square (center crop)
- width, height = img.size
- min_dim = min(width, height)
- left = (width - min_dim) // 2
- top = (height - min_dim) // 2
- img = img.crop((left, top, left + min_dim, top + min_dim))
- # Generate circular favicon PNGs with transparent background
- png_sizes = {
- "favicon-16x16.png": 16,
- "favicon-32x32.png": 32,
- "favicon-96x96.png": 96,
- "favicon-128x128.png": 128,
- }
- for filename, size in png_sizes.items():
- icon = create_circular_transparent(img, size)
- icon.save(os.path.join(output_dir, filename), format='PNG')
- # Generate high-resolution favicon.ico
- ico_sizes = [256, 128, 64, 48, 32, 16]
- ico_images = [create_circular_transparent(img, s) for s in ico_sizes]
- ico_images[0].save(
- os.path.join(output_dir, "favicon.ico"),
- format='ICO',
- append_images=ico_images[1:],
- sizes=[(s, s) for s in ico_sizes]
- )
- return True
- except Exception as e:
- logger.error(f"Failed to generate favicon: {str(e)}")
- return False
- def generate_pwa_icons_from_logo(logo_path: str, output_dir: str) -> bool:
- """Generate square PWA app icons from the uploaded logo.
- Creates square icons (no circular crop) - OS will apply its own mask.
- Composites onto a solid background to avoid transparency issues
- (iOS fills transparent areas with white on home screen icons).
- Generates:
- - apple-touch-icon.png (180x180)
- - android-chrome-192x192.png (192x192)
- - android-chrome-512x512.png (512x512)
- Returns True on success, False on failure.
- """
- try:
- from PIL import Image
- with Image.open(logo_path) as img:
- # Convert to RGBA if needed
- if img.mode != 'RGBA':
- img = img.convert('RGBA')
- # Crop to square (center crop)
- width, height = img.size
- min_dim = min(width, height)
- left = (width - min_dim) // 2
- top = (height - min_dim) // 2
- img = img.crop((left, top, left + min_dim, top + min_dim))
- # Generate square icons at each required size
- icon_sizes = {
- "apple-touch-icon.png": 180,
- "android-chrome-192x192.png": 192,
- "android-chrome-512x512.png": 512,
- }
- for filename, size in icon_sizes.items():
- resized = img.resize((size, size), Image.Resampling.LANCZOS)
- # Composite onto solid background to eliminate transparency
- # (iOS shows white behind transparent areas on home screen)
- background = Image.new('RGB', (size, size), (10, 10, 10)) # #0a0a0a theme color
- background.paste(resized, (0, 0), resized) # Use resized as its own alpha mask
- icon_path = os.path.join(output_dir, filename)
- background.save(icon_path, format='PNG')
- logger.info(f"Generated PWA icon: {filename}")
- return True
- except Exception as e:
- logger.error(f"Failed to generate PWA icons: {str(e)}")
- return False
- @app.post("/api/upload-logo", tags=["settings"])
- async def upload_logo(file: UploadFile = File(...)):
- """Upload a custom logo image.
- Supported formats: PNG, JPG, JPEG, GIF, WebP, SVG
- Maximum upload size: 10MB
- Images are automatically optimized:
- - Resized to max 512x512 pixels
- - Converted to WebP format for smaller file size
- - SVG files are kept as-is (already lightweight)
- A favicon and PWA icons will be automatically generated from the logo.
- """
- try:
- # Validate file extension
- file_ext = os.path.splitext(file.filename)[1].lower()
- if file_ext not in ALLOWED_IMAGE_EXTENSIONS:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
- )
- # Read and validate file size
- content = await file.read()
- if len(content) > MAX_LOGO_SIZE:
- raise HTTPException(
- status_code=400,
- detail=f"File too large. Maximum size: {MAX_LOGO_SIZE // (1024*1024)}MB"
- )
- # Ensure custom branding directory exists
- os.makedirs(CUSTOM_BRANDING_DIR, exist_ok=True)
- # Delete old logo and favicon if they exist
- if state.custom_logo:
- old_logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
- if os.path.exists(old_logo_path):
- os.remove(old_logo_path)
- # Also remove old favicon
- old_favicon_path = os.path.join(CUSTOM_BRANDING_DIR, "favicon.ico")
- if os.path.exists(old_favicon_path):
- os.remove(old_favicon_path)
- # Optimize the image (resize + convert to WebP for smaller file size)
- optimized_content, optimized_ext = optimize_logo_image(content, file_ext)
- # Generate a unique filename to prevent caching issues
- import uuid
- filename = f"logo-{uuid.uuid4().hex[:8]}{optimized_ext}"
- file_path = os.path.join(CUSTOM_BRANDING_DIR, filename)
- # Save the optimized logo file
- with open(file_path, "wb") as f:
- f.write(optimized_content)
- # Generate favicon and PWA icons from logo (for non-SVG files)
- favicon_generated = False
- pwa_icons_generated = False
- if optimized_ext != ".svg":
- favicon_generated = generate_favicon_from_logo(file_path, CUSTOM_BRANDING_DIR)
- pwa_icons_generated = generate_pwa_icons_from_logo(file_path, CUSTOM_BRANDING_DIR)
- # Update state
- state.custom_logo = filename
- state.save()
- logger.info(f"Custom logo uploaded: {filename}, favicon generated: {favicon_generated}, PWA icons generated: {pwa_icons_generated}")
- return {
- "success": True,
- "filename": filename,
- "url": f"/static/custom/{filename}",
- "favicon_generated": favicon_generated,
- "pwa_icons_generated": pwa_icons_generated
- }
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Error uploading logo: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.delete("/api/custom-logo", tags=["settings"])
- async def delete_custom_logo():
- """Remove custom logo, favicon, and PWA icons, reverting to defaults."""
- try:
- if state.custom_logo:
- # Remove logo
- logo_path = os.path.join(CUSTOM_BRANDING_DIR, state.custom_logo)
- if os.path.exists(logo_path):
- os.remove(logo_path)
- # Remove generated favicons
- favicon_files = [
- "favicon.ico",
- "favicon-16x16.png",
- "favicon-32x32.png",
- "favicon-96x96.png",
- "favicon-128x128.png",
- ]
- for favicon_name in favicon_files:
- favicon_path = os.path.join(CUSTOM_BRANDING_DIR, favicon_name)
- if os.path.exists(favicon_path):
- os.remove(favicon_path)
- # Remove generated PWA icons
- pwa_icons = [
- "apple-touch-icon.png",
- "android-chrome-192x192.png",
- "android-chrome-512x512.png",
- ]
- for icon_name in pwa_icons:
- icon_path = os.path.join(CUSTOM_BRANDING_DIR, icon_name)
- if os.path.exists(icon_path):
- os.remove(icon_path)
- state.custom_logo = None
- state.save()
- logger.info("Custom logo, favicon, and PWA icons removed")
- return {"success": True}
- except Exception as e:
- logger.error(f"Error removing logo: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
- async def get_mqtt_config():
- """DEPRECATED: Use GET /api/settings instead. Get current MQTT configuration.
- Note: Password is not returned for security reasons.
- """
- from modules.mqtt import get_mqtt_handler
- handler = get_mqtt_handler()
- return {
- "enabled": state.mqtt_enabled,
- "broker": state.mqtt_broker,
- "port": state.mqtt_port,
- "username": state.mqtt_username,
- # Password is intentionally omitted for security
- "has_password": bool(state.mqtt_password),
- "client_id": state.mqtt_client_id,
- "discovery_prefix": state.mqtt_discovery_prefix,
- "device_id": state.mqtt_device_id,
- "device_name": state.mqtt_device_name,
- "connected": handler.is_connected if hasattr(handler, 'is_connected') else False,
- "is_mock": handler.__class__.__name__ == 'MockMQTTHandler'
- }
- @app.post("/api/mqtt-config", deprecated=True, tags=["settings-deprecated"])
- async def set_mqtt_config(request: dict):
- """DEPRECATED: Use PATCH /api/settings instead. Update MQTT configuration. Requires restart to take effect."""
- try:
- # Update state with new values
- state.mqtt_enabled = request.get("enabled", False)
- state.mqtt_broker = (request.get("broker") or "").strip()
- state.mqtt_port = int(request.get("port") or 1883)
- state.mqtt_username = (request.get("username") or "").strip()
- state.mqtt_password = (request.get("password") or "").strip()
- state.mqtt_client_id = (request.get("client_id") or "dune_weaver").strip()
- state.mqtt_discovery_prefix = (request.get("discovery_prefix") or "homeassistant").strip()
- state.mqtt_device_id = (request.get("device_id") or "dune_weaver").strip()
- state.mqtt_device_name = (request.get("device_name") or "Dune Weaver").strip()
- # Validate required fields when enabled
- if state.mqtt_enabled and not state.mqtt_broker:
- return JSONResponse(
- content={"success": False, "message": "Broker address is required when MQTT is enabled"},
- status_code=400
- )
- state.save()
- logger.info(f"MQTT configuration updated. Enabled: {state.mqtt_enabled}, Broker: {state.mqtt_broker}")
- return {
- "success": True,
- "message": "MQTT configuration saved. Restart the application for changes to take effect.",
- "requires_restart": True
- }
- except ValueError as e:
- return JSONResponse(
- content={"success": False, "message": f"Invalid value: {str(e)}"},
- status_code=400
- )
- except Exception as e:
- logger.error(f"Failed to update MQTT config: {str(e)}")
- return JSONResponse(
- content={"success": False, "message": str(e)},
- status_code=500
- )
- @app.post("/api/mqtt-test")
- async def test_mqtt_connection(request: dict):
- """Test MQTT connection with provided settings."""
- import paho.mqtt.client as mqtt_client
- broker = (request.get("broker") or "").strip()
- port = int(request.get("port") or 1883)
- username = (request.get("username") or "").strip()
- password = (request.get("password") or "").strip()
- client_id = (request.get("client_id") or "dune_weaver_test").strip()
- if not broker:
- return JSONResponse(
- content={"success": False, "message": "Broker address is required"},
- status_code=400
- )
- try:
- # Create a test client
- client = mqtt_client.Client(client_id=client_id + "_test")
- if username:
- client.username_pw_set(username, password)
- # Connection result
- connection_result = {"connected": False, "error": None}
- def on_connect(client, userdata, flags, rc):
- if rc == 0:
- connection_result["connected"] = True
- else:
- error_messages = {
- 1: "Incorrect protocol version",
- 2: "Invalid client identifier",
- 3: "Server unavailable",
- 4: "Bad username or password",
- 5: "Not authorized"
- }
- connection_result["error"] = error_messages.get(rc, f"Connection failed with code {rc}")
- client.on_connect = on_connect
- # Try to connect with timeout
- client.connect_async(broker, port, keepalive=10)
- client.loop_start()
- # Wait for connection result (max 5 seconds)
- import time
- start_time = time.time()
- while time.time() - start_time < 5:
- if connection_result["connected"] or connection_result["error"]:
- break
- await asyncio.sleep(0.1)
- client.loop_stop()
- client.disconnect()
- if connection_result["connected"]:
- return {"success": True, "message": "Successfully connected to MQTT broker"}
- elif connection_result["error"]:
- return JSONResponse(
- content={"success": False, "message": connection_result["error"]},
- status_code=400
- )
- else:
- return JSONResponse(
- content={"success": False, "message": "Connection timed out. Check broker address and port."},
- status_code=400
- )
- except Exception as e:
- logger.error(f"MQTT test connection failed: {str(e)}")
- return JSONResponse(
- content={"success": False, "message": str(e)},
- status_code=500
- )
- def _read_and_encode_preview(cache_path: str) -> str:
- """Read preview image from disk and encode as base64.
-
- Combines file I/O and base64 encoding in a single function
- to be run in executor, reducing context switches.
- """
- with open(cache_path, 'rb') as f:
- image_data = f.read()
- return base64.b64encode(image_data).decode('utf-8')
- @app.post("/preview_thr_batch")
- async def preview_thr_batch(request: dict):
- start = time.time()
- if not request.get("file_names"):
- logger.warning("Batch preview request received without filenames")
- raise HTTPException(status_code=400, detail="No file names provided")
- file_names = request["file_names"]
- if not isinstance(file_names, list):
- raise HTTPException(status_code=400, detail="file_names must be a list")
- headers = {
- "Cache-Control": "public, max-age=3600", # Cache for 1 hour
- "Content-Type": "application/json"
- }
- async def process_single_file(file_name):
- """Process a single file and return its preview data."""
- # Check in-memory cache first (for current and next playing patterns)
- normalized_for_cache = normalize_file_path(file_name)
- if state._current_preview and state._current_preview[0] == normalized_for_cache:
- logger.debug(f"Using cached preview for current: {file_name}")
- return file_name, state._current_preview[1]
- if state._next_preview and state._next_preview[0] == normalized_for_cache:
- logger.debug(f"Using cached preview for next: {file_name}")
- return file_name, state._next_preview[1]
- # Acquire semaphore to limit concurrent processing
- async with get_preview_semaphore():
- t1 = time.time()
- try:
- # Normalize file path for cross-platform compatibility
- normalized_file_name = normalize_file_path(file_name)
- pattern_file_path = os.path.join(pattern_manager.THETA_RHO_DIR, normalized_file_name)
- # Check file existence asynchronously
- exists = await asyncio.to_thread(os.path.exists, pattern_file_path)
- if not exists:
- logger.warning(f"Pattern file not found: {pattern_file_path}")
- return file_name, {"error": "Pattern file not found"}
- cache_path = get_cache_path(normalized_file_name)
- # Check cache existence asynchronously
- cache_exists = await asyncio.to_thread(os.path.exists, cache_path)
- if not cache_exists:
- logger.info(f"Cache miss for {file_name}. Generating preview...")
- success = await generate_image_preview(normalized_file_name)
- cache_exists_after = await asyncio.to_thread(os.path.exists, cache_path)
- if not success or not cache_exists_after:
- logger.error(f"Failed to generate or find preview for {file_name}")
- return file_name, {"error": "Failed to generate preview"}
- metadata = get_pattern_metadata(normalized_file_name)
- if metadata:
- first_coord_obj = metadata.get('first_coordinate')
- last_coord_obj = metadata.get('last_coordinate')
- else:
- logger.debug(f"Metadata cache miss for {file_name}, parsing file")
- # Use thread pool to avoid memory pressure on resource-constrained devices
- coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
- first_coord = coordinates[0] if coordinates else None
- last_coord = coordinates[-1] if coordinates else None
- first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
- last_coord_obj = {"x": last_coord[0], "y": last_coord[1]} if last_coord else None
- # Read image file and encode in executor to avoid blocking event loop
- loop = asyncio.get_running_loop()
- image_b64 = await loop.run_in_executor(None, _read_and_encode_preview, cache_path)
- result = {
- "image_data": f"data:image/webp;base64,{image_b64}",
- "first_coordinate": first_coord_obj,
- "last_coordinate": last_coord_obj
- }
- # Cache preview for current/next pattern to speed up subsequent requests
- current_file = state.current_playing_file
- if current_file:
- current_normalized = normalize_file_path(current_file)
- if normalized_file_name == current_normalized:
- state._current_preview = (normalized_file_name, result)
- logger.debug(f"Cached preview for current: {file_name}")
- elif state.current_playlist:
- # Check if this is the next pattern in playlist
- playlist = state.current_playlist
- idx = state.current_playlist_index
- if idx is not None and idx + 1 < len(playlist):
- next_file = normalize_file_path(playlist[idx + 1])
- if normalized_file_name == next_file:
- state._next_preview = (normalized_file_name, result)
- logger.debug(f"Cached preview for next: {file_name}")
- logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
- return file_name, result
- except Exception as e:
- logger.error(f"Error processing {file_name}: {str(e)}")
- return file_name, {"error": str(e)}
- # Process all files concurrently
- tasks = [process_single_file(file_name) for file_name in file_names]
- file_results = await asyncio.gather(*tasks)
- # Convert results to dictionary
- results = dict(file_results)
- logger.debug(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
- return JSONResponse(content=results, headers=headers)
- @app.get("/playlists")
- async def playlists_page(request: Request):
- return get_redirect_response(request)
- @app.get("/image2sand")
- async def image2sand_page(request: Request):
- return get_redirect_response(request)
- @app.get("/led")
- async def led_control_page(request: Request):
- return get_redirect_response(request)
- # DW LED control endpoints
- @app.get("/api/dw_leds/status")
- async def dw_leds_status():
- """Get DW LED controller status"""
- if not state.led_controller or state.led_provider != "dw_leds":
- return {"connected": False, "message": "DW LEDs not configured"}
- try:
- return state.led_controller.check_status()
- except Exception as e:
- logger.error(f"Failed to check DW LED status: {str(e)}")
- return {"connected": False, "message": str(e)}
- @app.post("/api/dw_leds/power")
- async def dw_leds_power(request: dict):
- """Control DW LED power (0=off, 1=on, 2=toggle)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- state_value = request.get("state", 1)
- if state_value not in [0, 1, 2]:
- raise HTTPException(status_code=400, detail="State must be 0 (off), 1 (on), or 2 (toggle)")
- try:
- result = state.led_controller.set_power(state_value)
- # Reset idle timeout when LEDs are manually powered on (only if idle timeout is enabled)
- # This prevents idle timeout from immediately turning them back off
- if state_value in [1, 2] and state.dw_led_idle_timeout_enabled: # Power on or toggle
- state.dw_led_last_activity_time = time.time()
- logger.debug(f"LED activity time reset due to manual power on")
- return result
- except Exception as e:
- logger.error(f"Failed to set DW LED power: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/brightness")
- async def dw_leds_brightness(request: dict):
- """Set DW LED brightness (0-100)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- value = request.get("value", 50)
- if not 0 <= value <= 100:
- raise HTTPException(status_code=400, detail="Brightness must be between 0 and 100")
- try:
- controller = state.led_controller.get_controller()
- result = controller.set_brightness(value)
- # Update state if successful
- if result.get("connected"):
- state.dw_led_brightness = value
- state.save()
- return result
- except Exception as e:
- logger.error(f"Failed to set DW LED brightness: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/color")
- async def dw_leds_color(request: dict):
- """Set solid color (manual UI control - always powers on LEDs)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- # Accept both formats: {"r": 255, "g": 0, "b": 0} or {"color": [255, 0, 0]}
- if "color" in request:
- color = request["color"]
- if not isinstance(color, list) or len(color) != 3:
- raise HTTPException(status_code=400, detail="Color must be [R, G, B] array")
- r, g, b = color[0], color[1], color[2]
- elif "r" in request and "g" in request and "b" in request:
- r = request["r"]
- g = request["g"]
- b = request["b"]
- else:
- raise HTTPException(status_code=400, detail="Color must include r, g, b fields or color array")
- try:
- controller = state.led_controller.get_controller()
- # Power on LEDs when user manually sets color via UI
- controller.set_power(1)
- # Reset idle timeout for manual interaction (only if idle timeout is enabled)
- if state.dw_led_idle_timeout_enabled:
- state.dw_led_last_activity_time = time.time()
- return controller.set_color(r, g, b)
- except Exception as e:
- logger.error(f"Failed to set DW LED color: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/colors")
- async def dw_leds_colors(request: dict):
- """Set effect colors (color1, color2, color3) - manual UI control - always powers on LEDs"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- # Parse colors from request
- color1 = None
- color2 = None
- color3 = None
- if "color1" in request:
- c = request["color1"]
- if isinstance(c, list) and len(c) == 3:
- color1 = tuple(c)
- else:
- raise HTTPException(status_code=400, detail="color1 must be [R, G, B] array")
- if "color2" in request:
- c = request["color2"]
- if isinstance(c, list) and len(c) == 3:
- color2 = tuple(c)
- else:
- raise HTTPException(status_code=400, detail="color2 must be [R, G, B] array")
- if "color3" in request:
- c = request["color3"]
- if isinstance(c, list) and len(c) == 3:
- color3 = tuple(c)
- else:
- raise HTTPException(status_code=400, detail="color3 must be [R, G, B] array")
- if not any([color1, color2, color3]):
- raise HTTPException(status_code=400, detail="Must provide at least one color")
- try:
- controller = state.led_controller.get_controller()
- # Power on LEDs when user manually sets colors via UI
- controller.set_power(1)
- # Reset idle timeout for manual interaction (only if idle timeout is enabled)
- if state.dw_led_idle_timeout_enabled:
- state.dw_led_last_activity_time = time.time()
- return controller.set_colors(color1=color1, color2=color2, color3=color3)
- except Exception as e:
- logger.error(f"Failed to set DW LED colors: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/api/dw_leds/effects")
- async def dw_leds_effects():
- """Get list of available effects"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- try:
- controller = state.led_controller.get_controller()
- effects = controller.get_effects()
- # Convert tuples to lists for JSON serialization
- effects_list = [[eid, name] for eid, name in effects]
- return {
- "success": True,
- "effects": effects_list
- }
- except Exception as e:
- logger.error(f"Failed to get DW LED effects: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.get("/api/dw_leds/palettes")
- async def dw_leds_palettes():
- """Get list of available palettes"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- try:
- controller = state.led_controller.get_controller()
- palettes = controller.get_palettes()
- # Convert tuples to lists for JSON serialization
- palettes_list = [[pid, name] for pid, name in palettes]
- return {
- "success": True,
- "palettes": palettes_list
- }
- except Exception as e:
- logger.error(f"Failed to get DW LED palettes: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/effect")
- async def dw_leds_effect(request: dict):
- """Set effect by ID (manual UI control - always powers on LEDs)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- effect_id = request.get("effect_id", 0)
- speed = request.get("speed")
- intensity = request.get("intensity")
- try:
- controller = state.led_controller.get_controller()
- # Power on LEDs when user manually sets effect via UI
- controller.set_power(1)
- # Reset idle timeout for manual interaction (only if idle timeout is enabled)
- if state.dw_led_idle_timeout_enabled:
- state.dw_led_last_activity_time = time.time()
- return controller.set_effect(effect_id, speed=speed, intensity=intensity)
- except Exception as e:
- logger.error(f"Failed to set DW LED effect: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/palette")
- async def dw_leds_palette(request: dict):
- """Set palette by ID (manual UI control - always powers on LEDs)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- palette_id = request.get("palette_id", 0)
- try:
- controller = state.led_controller.get_controller()
- # Power on LEDs when user manually sets palette via UI
- controller.set_power(1)
- # Reset idle timeout for manual interaction (only if idle timeout is enabled)
- if state.dw_led_idle_timeout_enabled:
- state.dw_led_last_activity_time = time.time()
- return controller.set_palette(palette_id)
- except Exception as e:
- logger.error(f"Failed to set DW LED palette: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/speed")
- async def dw_leds_speed(request: dict):
- """Set effect speed (0-255)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- value = request.get("speed", 128)
- if not 0 <= value <= 255:
- raise HTTPException(status_code=400, detail="Speed must be between 0 and 255")
- try:
- controller = state.led_controller.get_controller()
- result = controller.set_speed(value)
- # Save speed to state
- state.dw_led_speed = value
- state.save()
- return result
- except Exception as e:
- logger.error(f"Failed to set DW LED speed: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/intensity")
- async def dw_leds_intensity(request: dict):
- """Set effect intensity (0-255)"""
- if not state.led_controller or state.led_provider != "dw_leds":
- raise HTTPException(status_code=400, detail="DW LEDs not configured")
- value = request.get("intensity", 128)
- if not 0 <= value <= 255:
- raise HTTPException(status_code=400, detail="Intensity must be between 0 and 255")
- try:
- controller = state.led_controller.get_controller()
- result = controller.set_intensity(value)
- # Save intensity to state
- state.dw_led_intensity = value
- state.save()
- return result
- except Exception as e:
- logger.error(f"Failed to set DW LED intensity: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- @app.post("/api/dw_leds/save_effect_settings")
- async def dw_leds_save_effect_settings(request: dict):
- """Save current LED settings as idle or playing effect"""
- effect_type = request.get("type") # 'idle' or 'playing'
- settings = {
- "effect_id": request.get("effect_id"),
- "palette_id": request.get("palette_id"),
- "speed": request.get("speed"),
- "intensity": request.get("intensity"),
- "color1": request.get("color1"),
- "color2": request.get("color2"),
- "color3": request.get("color3")
- }
- if effect_type == "idle":
- state.dw_led_idle_effect = settings
- elif effect_type == "playing":
- state.dw_led_playing_effect = settings
- else:
- raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
- state.save()
- logger.info(f"DW LED {effect_type} effect settings saved: {settings}")
- return {"success": True, "type": effect_type, "settings": settings}
- @app.post("/api/dw_leds/clear_effect_settings")
- async def dw_leds_clear_effect_settings(request: dict):
- """Clear idle or playing effect settings"""
- effect_type = request.get("type") # 'idle' or 'playing'
- if effect_type == "idle":
- state.dw_led_idle_effect = None
- elif effect_type == "playing":
- state.dw_led_playing_effect = None
- else:
- raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
- state.save()
- logger.info(f"DW LED {effect_type} effect settings cleared")
- return {"success": True, "type": effect_type}
- @app.get("/api/dw_leds/get_effect_settings")
- async def dw_leds_get_effect_settings():
- """Get saved idle and playing effect settings"""
- return {
- "idle_effect": state.dw_led_idle_effect,
- "playing_effect": state.dw_led_playing_effect
- }
- @app.post("/api/dw_leds/idle_timeout")
- async def dw_leds_set_idle_timeout(request: dict):
- """Configure LED idle timeout settings"""
- enabled = request.get("enabled", False)
- minutes = request.get("minutes", 30)
- # Validate minutes (between 1 and 1440 - 24 hours)
- if minutes < 1 or minutes > 1440:
- raise HTTPException(status_code=400, detail="Timeout must be between 1 and 1440 minutes")
- state.dw_led_idle_timeout_enabled = enabled
- state.dw_led_idle_timeout_minutes = minutes
- # Reset activity time when settings change
- import time
- state.dw_led_last_activity_time = time.time()
- state.save()
- logger.info(f"DW LED idle timeout configured: enabled={enabled}, minutes={minutes}")
- return {
- "success": True,
- "enabled": enabled,
- "minutes": minutes
- }
- @app.get("/api/dw_leds/idle_timeout")
- async def dw_leds_get_idle_timeout():
- """Get LED idle timeout settings"""
- import time
- # Calculate remaining time if timeout is active
- remaining_minutes = None
- if state.dw_led_idle_timeout_enabled and state.dw_led_last_activity_time:
- elapsed_seconds = time.time() - state.dw_led_last_activity_time
- timeout_seconds = state.dw_led_idle_timeout_minutes * 60
- remaining_seconds = max(0, timeout_seconds - elapsed_seconds)
- remaining_minutes = round(remaining_seconds / 60, 1)
- return {
- "enabled": state.dw_led_idle_timeout_enabled,
- "minutes": state.dw_led_idle_timeout_minutes,
- "remaining_minutes": remaining_minutes
- }
- @app.get("/table_control")
- async def table_control_page(request: Request):
- return get_redirect_response(request)
- @app.get("/cache-progress")
- async def get_cache_progress_endpoint():
- """Get the current cache generation progress."""
- from modules.core.cache_manager import get_cache_progress
- return get_cache_progress()
- @app.post("/rebuild_cache")
- async def rebuild_cache_endpoint():
- """Trigger a rebuild of the pattern cache."""
- try:
- from modules.core.cache_manager import rebuild_cache
- await rebuild_cache()
- return {"success": True, "message": "Cache rebuild completed successfully"}
- except Exception as e:
- logger.error(f"Failed to rebuild cache: {str(e)}")
- raise HTTPException(status_code=500, detail=str(e))
- def signal_handler(signum, frame):
- """Handle shutdown signals gracefully."""
- logger.info("Received shutdown signal, cleaning up...")
- try:
- # Turn off all LEDs on shutdown
- if state.led_controller:
- state.led_controller.set_power(0)
- # Stop pattern manager motion controller
- pattern_manager.motion_controller.stop()
- # Set stop flags to halt any running patterns
- state.stop_requested = True
- state.pause_requested = False
- state.save()
- logger.info("Cleanup completed")
- except Exception as e:
- logger.error(f"Error during cleanup: {str(e)}")
- finally:
- logger.info("Exiting application...")
- # Use os._exit after cleanup is complete to avoid async stack tracebacks
- # This is safe because we've already: shut down process pool, stopped motion controller, saved state
- os._exit(0)
- @app.get("/api/version")
- async def get_version_info(force_refresh: bool = False):
- """Get current and latest version information
- Args:
- force_refresh: If true, bypass cache and fetch fresh data from GitHub
- """
- try:
- version_info = await version_manager.get_version_info(force_refresh=force_refresh)
- return JSONResponse(content=version_info)
- except Exception as e:
- logger.error(f"Error getting version info: {e}")
- return JSONResponse(
- content={
- "current": await version_manager.get_current_version(),
- "latest": await version_manager.get_current_version(),
- "update_available": False,
- "error": "Unable to check for updates"
- },
- status_code=200
- )
- @app.post("/api/update")
- async def trigger_update():
- """Trigger software update by pulling latest Docker images and recreating containers."""
- try:
- logger.info("Update triggered via API")
- success, error_message, error_log = update_manager.update_software()
- if success:
- return JSONResponse(content={
- "success": True,
- "message": "Update started. Containers are being recreated with the latest images. The page will reload shortly."
- })
- else:
- return JSONResponse(content={
- "success": False,
- "message": error_message or "Update failed",
- "errors": error_log
- })
- except Exception as e:
- logger.error(f"Error triggering update: {e}")
- return JSONResponse(
- content={"success": False, "message": f"Failed to trigger update: {str(e)}"},
- status_code=500
- )
- @app.post("/api/system/shutdown")
- async def shutdown_system():
- """Shutdown the system"""
- try:
- logger.warning("Shutdown initiated via API")
- # Schedule shutdown command after a short delay to allow response to be sent
- def delayed_shutdown():
- time.sleep(2) # Give time for response to be sent
- try:
- # Use systemctl to shutdown the host (via mounted systemd socket)
- subprocess.run(["systemctl", "poweroff"], check=True)
- logger.info("Host shutdown command executed successfully via systemctl")
- except FileNotFoundError:
- logger.error("systemctl command not found - ensure systemd volumes are mounted")
- except Exception as e:
- logger.error(f"Error executing host shutdown command: {e}")
- import threading
- shutdown_thread = threading.Thread(target=delayed_shutdown)
- shutdown_thread.start()
- return {"success": True, "message": "System shutdown initiated"}
- except Exception as e:
- logger.error(f"Error initiating shutdown: {e}")
- return JSONResponse(
- content={"success": False, "message": str(e)},
- status_code=500
- )
- @app.post("/api/system/restart")
- async def restart_system():
- """Restart the Docker containers using docker compose"""
- try:
- logger.warning("Restart initiated via API")
- # Schedule restart command after a short delay to allow response to be sent
- def delayed_restart():
- time.sleep(2) # Give time for response to be sent
- try:
- # Use docker restart directly with container name
- # This is simpler and doesn't require the compose file path
- subprocess.run(["docker", "restart", "dune-weaver-backend"], check=True)
- logger.info("Docker restart command executed successfully")
- except FileNotFoundError:
- logger.error("docker command not found")
- except Exception as e:
- logger.error(f"Error executing docker restart: {e}")
- import threading
- restart_thread = threading.Thread(target=delayed_restart)
- restart_thread.start()
- return {"success": True, "message": "System restart initiated"}
- except Exception as e:
- logger.error(f"Error initiating restart: {e}")
- return JSONResponse(
- content={"success": False, "message": str(e)},
- status_code=500
- )
- def entrypoint():
- import uvicorn
- logger.info("Starting FastAPI server on port 8080...")
- uvicorn.run(app, host="0.0.0.0", port=8080, workers=1) # Set workers to 1 to avoid multiple signal handlers
- if __name__ == "__main__":
- entrypoint()
|