settings.py 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101
  1. import json
  2. import re
  3. from typing import Literal
  4. from urllib.parse import urlparse
  5. from pydantic import BaseModel, Field, ValidationInfo, field_validator
  6. from backend.app.schemas.print_queue import TriState
  7. from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
  8. # Outbound service URLs validated on save, so a bad value is rejected at
  9. # configuration time with a clear message rather than failing opaquely at
  10. # request time. Every one of these services is commonly self-hosted on the same
  11. # host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
  12. # RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
  13. # IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
  14. # ``_url_safety.assert_safe_lan_service_url``.
  15. #
  16. # Module-level rather than a class attribute so the CI backstop in
  17. # tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
  18. # cannot drift from it. Any new outbound-URL setting belongs here (or, if it
  19. # must be reachable on the public internet, on the stricter OIDC guard).
  20. LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
  21. # ``docker_compose_dir`` is unusual among the string settings: it is not
  22. # consumed by Bambuddy at all, it is interpolated into a shell command that
  23. # the Settings page invites the user to copy and paste into a root-capable
  24. # terminal (#2664). A value like ``/opt/bambuddy; rm -rf /`` would render as a
  25. # perfectly plausible-looking update command, so anyone with settings:update
  26. # could hand every admin a destructive one-liner to run. Restricting the field
  27. # to characters that occur in real paths removes that entirely; the frontend
  28. # double-quotes the value when it contains a space, which is safe precisely
  29. # because quotes, ``$`` and backticks cannot survive this pattern.
  30. _COMPOSE_DIR_ALLOWED = re.compile(r"^[\w \-./\\:~]+$", re.UNICODE)
  31. _COMPOSE_DIR_MAX_LEN = 512
  32. class AppSettings(BaseModel):
  33. """Application settings schema."""
  34. auto_archive: bool = Field(default=True, description="Automatically archive prints when completed")
  35. save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files")
  36. capture_finish_photo: bool = Field(
  37. default=True,
  38. description=(
  39. "Capture photo from printer camera when print completes. Bambuddy records a "
  40. "brief timelapse during the print so the photo can be sourced from the moment "
  41. "before the bed drops; the timelapse file is kept if you enabled timelapse for "
  42. "this print, otherwise it is deleted automatically after the photo is captured."
  43. ),
  44. )
  45. finish_photo_restore_plate: bool = Field(
  46. default=True,
  47. description=(
  48. "Raise the build plate back into camera framing before taking the finish photo. "
  49. "Bambu's end G-code drops the plate ~100mm as the last thing it does, leaving the "
  50. "finished print far below the camera's natural framing. Bambuddy moves it back to "
  51. "just above the last printed layer, takes the photo, then lowers it again. Skipped "
  52. "when the print height is unknown or another job is queued for the printer."
  53. ),
  54. )
  55. default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
  56. currency: str = Field(default="USD", description="Currency for cost tracking")
  57. energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
  58. energy_tracking_mode: str = Field(
  59. default="total",
  60. description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption",
  61. )
  62. # A plain string on the way out, like energy_tracking_mode: a stray value
  63. # (a restored backup) reads as "fixed" everywhere instead of failing the
  64. # whole settings response. Writes are checked in AppSettingsUpdate.
  65. energy_price_source: str = Field(
  66. default="fixed",
  67. description=(
  68. "Where the electricity price comes from: 'fixed' uses energy_cost_per_kwh, "
  69. "'homeassistant' reads energy_price_ha_entity hourly and at print start and end (#1251)"
  70. ),
  71. )
  72. energy_price_ha_entity: str = Field(
  73. default="", description="Home Assistant sensor holding the electricity price per kWh"
  74. )
  75. # Spoolman integration
  76. spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
  77. spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
  78. spoolman_sync_mode: str = Field(
  79. default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press"
  80. )
  81. spoolman_disable_weight_sync: bool = Field(
  82. default=False,
  83. description="Disable remaining_weight sync. When enabled, only location is updated for existing spools.",
  84. )
  85. spoolman_report_partial_usage: bool = Field(
  86. default=True,
  87. description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
  88. )
  89. auto_add_unknown_rfid: bool = Field(
  90. default=True,
  91. description="Automatically add spools with unknown RFID tags to inventory. Disable if you pre-create inventory entries manually to avoid duplicates.",
  92. )
  93. disable_filament_warnings: bool = Field(
  94. default=False,
  95. description="Disable insufficient filament warnings when printing or queueing prints",
  96. )
  97. prefer_lowest_filament: bool = Field(
  98. default=False,
  99. description="When multiple AMS spools match, prefer the one with lowest remaining filament",
  100. )
  101. # Updates
  102. check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
  103. check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
  104. include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
  105. announcements_enabled: bool = Field(
  106. default=True, description="Fetch announcements from the Bambuddy maintainers (a signed file on GitHub)"
  107. )
  108. announcements_all_users: bool = Field(
  109. default=False, description="Show announcements to every signed-in user, not only administrators"
  110. )
  111. # Language
  112. language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
  113. notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
  114. # Bed cooled notification threshold
  115. bed_cooled_threshold: float = Field(
  116. default=35.0, description="Bed temperature threshold for cooled notification (°C)"
  117. )
  118. # AMS threshold settings for humidity and temperature coloring
  119. ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
  120. ams_humidity_fair: int = Field(
  121. default=60, description="Humidity threshold for fair (orange): <= this value, > is red"
  122. )
  123. ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
  124. ams_temp_fair: float = Field(
  125. default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
  126. )
  127. # Separate from ams_temp_fair on purpose (#2905). The fair threshold decides
  128. # when the AMS card turns amber; this decides when a notification is sent.
  129. # 35 C is a sensible place to change a colour and not a sensible place to
  130. # page someone -- a room above 35 C makes the alarm fire once an hour for as
  131. # long as the weather lasts, and the only way to silence it was to raise the
  132. # display band and lose the colour that says the unit is warm. None means
  133. # "not set", which resolves to ams_temp_fair so every existing install keeps
  134. # behaving exactly as it does now.
  135. ams_temp_alarm: float | None = Field(
  136. default=None,
  137. description="Temperature threshold (°C) for sending an alarm. Unset falls back to ams_temp_fair.",
  138. )
  139. ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
  140. printer_sensor_history_retention_days: int = Field(
  141. default=30, description="Number of days to keep printer heater history data (nozzle / bed / chamber)"
  142. )
  143. # Queue auto-drying settings
  144. queue_drying_enabled: bool = Field(
  145. default=False, description="Automatically dry AMS filament between queued prints"
  146. )
  147. queue_drying_block: bool = Field(
  148. default=False,
  149. description="Block queue until drying completes (when disabled, prints take priority over drying)",
  150. )
  151. ambient_drying_enabled: bool = Field(
  152. default=False,
  153. description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
  154. )
  155. ambient_drying_sustained_minutes: int = Field(
  156. default=0,
  157. ge=0,
  158. le=240,
  159. description=(
  160. "Minutes the humidity must stay above the threshold before an ambient "
  161. "auto-dry starts (0 = start immediately). Rides out the reading spike "
  162. "from opening the AMS lid instead of buying a dry cycle for it."
  163. ),
  164. )
  165. print_drying_enabled: bool = Field(
  166. default=False,
  167. description=(
  168. "Allow auto-drying to also fire on a printer that is currently printing, "
  169. "when its model+firmware supports concurrent drying (H2D 01.03.00.00+, "
  170. "H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L 01.01.00.00+, X1C 01.11.02.00+). "
  171. "Drying temperature is automatically capped 5 degC below the idle preset "
  172. "(floor 40 degC) to protect spools during print."
  173. ),
  174. )
  175. drying_presets: str = Field(
  176. default="",
  177. description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
  178. )
  179. ams_humidity_thresholds: str = Field(
  180. default="",
  181. description=(
  182. "JSON blob of per-filament-type humidity trigger thresholds for auto-drying and alarms. "
  183. 'Shape: {"default": int, "PLA": int, "ASA": int, ...}. '
  184. "Empty = fall back to ams_humidity_fair for all types."
  185. ),
  186. )
  187. # Auto-print G-code injection (#422)
  188. gcode_snippets: str = Field(
  189. default="",
  190. description="JSON: per-model G-code injection snippets {model: {start_gcode, end_gcode}}",
  191. )
  192. # Scheduled local backup (#884)
  193. local_backup_enabled: bool = Field(default=False, description="Enable scheduled local backups")
  194. local_backup_schedule: str = Field(default="daily", description="Backup frequency: hourly, daily, weekly")
  195. local_backup_time: str = Field(default="03:00", description="Time of day for daily/weekly backups (HH:MM, 24h)")
  196. local_backup_retention: int = Field(default=5, description="Number of backup files to keep (1-100)")
  197. local_backup_path: str = Field(default="", description="Backup output directory (empty = DATA_DIR/backups)")
  198. # Print modal settings
  199. per_printer_mapping_expanded: bool = Field(
  200. default=False, description="Expand custom filament mapping by default in print modal"
  201. )
  202. # Date/time display format
  203. date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
  204. time_format: str = Field(default="system", description="Time format: system, 12h, 24h")
  205. # Default printer for operations
  206. default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
  207. # Slicer Pipelines (#1425 PR C). Cap on the ``copies`` field in the
  208. # Run-with-pipeline modal — keeps a misclick from queueing 5000 prints.
  209. pipeline_max_copies: int = Field(
  210. default=50,
  211. ge=1,
  212. le=1000,
  213. description="Upper bound on the copies an operator can request when running a Slicer Pipeline. Larger fleets / production rigs can raise this; the hard ceiling at 1000 is a sanity guard against fat-fingered input.",
  214. )
  215. # Virtual Printer
  216. virtual_printer_enabled: bool = Field(default=False, description="Enable virtual printer for slicer uploads")
  217. virtual_printer_access_code: str = Field(default="", description="Access code for virtual printer authentication")
  218. virtual_printer_mode: str = Field(
  219. default="archive",
  220. description="Mode: 'archive' (archive now), 'review' (pending review), 'queue' (add to print queue), or 'proxy' (relay to real printer)",
  221. )
  222. virtual_printer_archive_name_source: str = Field(
  223. default="metadata",
  224. description="Source for the archive's display name on virtual-printer uploads: 'metadata' uses the 3MF's embedded print_name (default, matches Bambu's behavior), 'filename' uses the filename Bambu Studio sent over FTP (lets users rename via the slicer's 'send to printer' dialog).",
  225. )
  226. # Dark mode theme settings
  227. dark_style: str = Field(default="vibrant", description="Dark mode style: classic, glow, vibrant")
  228. dark_background: str = Field(
  229. default="cool", description="Dark mode background: neutral, warm, cool, oled, slate, forest"
  230. )
  231. dark_accent: str = Field(default="green", description="Dark mode accent: green, teal, blue, orange, purple, red")
  232. # Light mode theme settings
  233. light_style: str = Field(default="classic", description="Light mode style: classic, glow, vibrant")
  234. light_background: str = Field(default="neutral", description="Light mode background: neutral, warm, cool")
  235. light_accent: str = Field(default="green", description="Light mode accent: green, teal, blue, orange, purple, red")
  236. # FTP retry settings for unreliable WiFi connections
  237. ftp_retry_enabled: bool = Field(default=True, description="Enable automatic retry for FTP operations")
  238. ftp_retry_count: int = Field(default=3, description="Number of retry attempts for FTP operations (1-10)")
  239. ftp_retry_delay: int = Field(default=2, description="Seconds to wait between FTP retry attempts (1-30)")
  240. ftp_timeout: int = Field(default=30, description="FTP connection timeout in seconds (10-300)")
  241. # MQTT Relay settings for publishing events to external broker
  242. mqtt_enabled: bool = Field(default=False, description="Enable MQTT event publishing to external broker")
  243. mqtt_broker: str = Field(default="", description="MQTT broker hostname or IP address")
  244. mqtt_port: int = Field(default=1883, description="MQTT broker port (default 1883, TLS typically 8883)")
  245. mqtt_username: str = Field(default="", description="MQTT username for authentication (optional)")
  246. mqtt_password: str = Field(default="", description="MQTT password for authentication (optional)")
  247. mqtt_topic_prefix: str = Field(default="bambuddy", description="Topic prefix for all published messages")
  248. mqtt_use_tls: bool = Field(default=False, description="Use TLS/SSL encryption for MQTT connection")
  249. # External URL for notifications
  250. external_url: str = Field(
  251. default="", description="External URL where Bambuddy is accessible (for notification images)"
  252. )
  253. # Directory holding the user's docker-compose.yml, shown in the update
  254. # instructions so the printed command can be pasted from anywhere (#2664).
  255. # Empty means "omit the cd" — which is also the correct rendering when
  256. # nothing could be detected, rather than guessing a path that fails.
  257. docker_compose_dir: str = Field(
  258. default="", description="Host directory containing docker-compose.yml, used in the update instructions"
  259. )
  260. # Home Assistant integration for smart plug control
  261. ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
  262. ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
  263. ha_token: str = Field(default="", description="Home Assistant Long-Lived Access Token")
  264. ha_url_from_env: bool = Field(default=False, description="Whether HA URL is set via HA_URL environment variable")
  265. ha_token_from_env: bool = Field(
  266. default=False, description="Whether HA token is set via HA_TOKEN environment variable"
  267. )
  268. ha_env_managed: bool = Field(
  269. default=False, description="Whether HA integration is fully managed by environment variables"
  270. )
  271. # File Manager / Library settings
  272. library_archive_mode: str = Field(
  273. default="ask",
  274. description="When printing from File Manager, create archive entry: 'always', 'never', or 'ask'",
  275. )
  276. library_disk_warning_gb: float = Field(
  277. default=5.0,
  278. description="Show warning when free disk space falls below this threshold (GB)",
  279. )
  280. # Chamber light while the camera is in use (#1655): "off", "all" printers,
  281. # or the "selected" printers (Printer.camera_light_auto). A stored value
  282. # outside these reads as off in the service, so it is a plain str here.
  283. camera_light_mode: str = Field(
  284. default="off",
  285. description="Turn the chamber light on while the camera is in use: 'off', 'all' or 'selected' printers",
  286. )
  287. camera_light_delay: float = Field(
  288. default=2.0,
  289. description="Seconds an automatic snapshot waits after the chamber light was turned on",
  290. )
  291. # Camera view settings
  292. camera_view_mode: str = Field(
  293. default="window",
  294. description="Camera view mode: 'window' opens in new browser window, 'embedded' shows overlay on main screen",
  295. )
  296. # Preferred slicer application (server-side / API sidecar slicer)
  297. preferred_slicer: str = Field(
  298. default="bambu_studio",
  299. description="Slicer used for the server-side API / sidecar: 'bambu_studio' or 'orcaslicer'",
  300. )
  301. # "Open in Slicer" desktop URI handler — independent of the API slicer so
  302. # a user can slice via the Bambu Studio sidecar but open files locally in
  303. # OrcaSlicer, or vice versa (#1329). None falls back to ``preferred_slicer``
  304. # so existing installs behave identically until someone changes it.
  305. open_in_slicer: str | None = Field(
  306. default=None,
  307. description=(
  308. "Desktop slicer for the 'Open in Slicer' button: 'bambu_studio' or "
  309. "'orcaslicer'. None inherits from preferred_slicer."
  310. ),
  311. )
  312. # Where slicing runs. Orthogonal to ``preferred_slicer``, which only says
  313. # *which slicer binary* the sidecar drives: a browser engine is a different
  314. # execution site, not a different binary choice. Kept as its own key so the
  315. # two never have to encode impossible combinations.
  316. #
  317. # Only "sidecar" is implemented today; the slice modal offers a per-job
  318. # choice when more than one engine is available, and hides the control
  319. # entirely while there is only one.
  320. slice_engine: str = Field(
  321. default="sidecar",
  322. description="Default execution site for slicing: 'sidecar' (server-side API) or 'browser'",
  323. )
  324. # Slicer dispatch mode: when True, "Slice" actions open the in-app
  325. # SliceModal and call the slicer-API sidecar. When False (default), they
  326. # hand off to the user's local desktop slicer via URI scheme — preserving
  327. # the original Bambuddy behavior for users who don't run a sidecar.
  328. use_slicer_api: bool = Field(
  329. default=False,
  330. description="Use the slicer-API sidecar for slicing instead of the desktop slicer URI scheme",
  331. )
  332. # Slicer-API sidecar base URLs. Per-installation, configured via the
  333. # Settings UI (the "Slicer" card). Empty string means "fall back to the
  334. # SLICER_API_URL / BAMBU_STUDIO_API_URL env vars" — which themselves
  335. # default to the docker-compose ports in core/config.py.
  336. orcaslicer_api_url: str = Field(
  337. default="",
  338. description="OrcaSlicer sidecar URL (e.g. http://localhost:3003). Empty falls back to the SLICER_API_URL env var.",
  339. )
  340. bambu_studio_api_url: str = Field(
  341. default="",
  342. description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
  343. )
  344. # How long to keep waiting on a slice that isn't finishing. Measured against
  345. # the sidecar's progress channel, not total elapsed time — a heavy model can
  346. # legitimately slice for half an hour, and a wall-clock ceiling cannot tell
  347. # that apart from a stalled one (#2730). Sidecars too old to report progress
  348. # fall back to using this as a total-elapsed ceiling, which is the pre-#2730
  349. # behaviour with a configurable number.
  350. slicer_stall_timeout_minutes: int = Field(
  351. default=15,
  352. ge=1,
  353. le=240,
  354. description=(
  355. "Give up on a slice after this many minutes with no progress from the sidecar. "
  356. "On sidecars that do not report progress, applies to total slicing time instead."
  357. ),
  358. )
  359. # Prometheus metrics endpoint
  360. prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
  361. prometheus_token: str = Field(
  362. default="", description="Bearer token for Prometheus metrics authentication (optional)"
  363. )
  364. # Inventory low stock threshold
  365. low_stock_threshold: float = Field(
  366. default=20.0,
  367. ge=0.1,
  368. le=99.9,
  369. description="Low stock threshold percentage (%) for inventory filtering and display",
  370. )
  371. # Session policy (#1706) — admin-set ceiling for user session lifetime.
  372. # Default 24h preserves the M-2 audit reduction from 7 days. Max 720h
  373. # (30 days) bounds blast radius if an admin chooses a long session.
  374. session_max_hours: int = Field(
  375. default=24,
  376. ge=1,
  377. le=720,
  378. description=(
  379. "Maximum session lifetime in hours for user logins (default 24, max 720). "
  380. "Applies to new logins only; already-issued tokens keep their original expiry. "
  381. "Longer sessions reduce automatic logout protection."
  382. ),
  383. )
  384. # User email notifications (requires Advanced Authentication)
  385. user_notifications_enabled: bool = Field(
  386. default=True,
  387. description="Enable user email notifications for print job events (requires Advanced Authentication)",
  388. )
  389. # Default print options. bed_levelling / flow_cali / nozzle_offset_cali are
  390. # tri-state (off/on/auto), defaulting to "auto" per BambuStudio.
  391. default_bed_levelling: TriState = Field(default="auto", description="Default bed levelling option for new prints")
  392. default_flow_cali: TriState = Field(default="auto", description="Default flow calibration option for new prints")
  393. default_vibration_cali: bool = Field(
  394. default=True, description="Default vibration calibration option for new prints"
  395. )
  396. default_layer_inspect: bool = Field(
  397. default=False, description="Default first layer inspection option for new prints"
  398. )
  399. default_timelapse: bool = Field(default=False, description="Default timelapse option for new prints")
  400. default_nozzle_offset_cali: TriState = Field(
  401. default="auto",
  402. description="Default nozzle offset calibration option for new prints (dual-nozzle printers only)",
  403. )
  404. default_confirm_outcome: bool = Field(
  405. default=False,
  406. description="Default for asking for a post-print outcome verdict on new prints (#1898)",
  407. )
  408. confirm_outcome_external_prints: bool = Field(
  409. default=False,
  410. description=(
  411. "Also ask for the outcome of prints Bambuddy archived but did not dispatch — started at "
  412. "the printer, in Bambu Studio or in the Handy app (#1898)"
  413. ),
  414. )
  415. confirm_default_good_on_plate_clear: bool = Field(
  416. default=False,
  417. description=(
  418. "When the build plate is released (manual acknowledgment or next dispatch) with the "
  419. "outcome prompt still unanswered, record the print as a good part (#1898)"
  420. ),
  421. )
  422. # Staggered batch start for multi-printer jobs
  423. stagger_group_size: int = Field(
  424. default=2, ge=1, le=50, description="Number of printers to start simultaneously in staggered mode"
  425. )
  426. stagger_interval_minutes: int = Field(
  427. default=5, ge=1, le=60, description="Minutes between staggered printer groups"
  428. )
  429. # Finance budget window settings
  430. billing_enabled: bool = Field(
  431. default=False,
  432. description="Enable cost-center billing enforcement for print and queue operations",
  433. )
  434. printer_kill_switch_enabled: bool = Field(
  435. default=False,
  436. description="Immediately stop printer jobs that start without Bambuddy authorization",
  437. )
  438. finance_budget_reset_day: int = Field(
  439. default=1,
  440. ge=1,
  441. le=31,
  442. description="Day of month when monthly finance budget window resets (1-31, clamped for short months)",
  443. )
  444. finance_budget_reset_timezone: str = Field(
  445. default="UTC",
  446. description="IANA timezone for finance monthly budget reset calculation (e.g., Europe/Berlin)",
  447. )
  448. # Plate-clear confirmation for queue scheduling
  449. require_plate_clear: bool = Field(
  450. default=False,
  451. description="Require per-printer plate-clear confirmation before starting queued prints on finished printers",
  452. )
  453. queue_shortest_first: bool = Field(
  454. default=False,
  455. description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
  456. )
  457. queue_max_concurrent_uploads: int = Field(
  458. default=4,
  459. ge=1,
  460. le=16,
  461. description=(
  462. "How many printers the queue may upload to at the same time. Printers are independent "
  463. "machines, so raising this starts a multi-printer batch proportionally sooner; each "
  464. "concurrent upload costs one connection and one thread on the Bambuddy host."
  465. ),
  466. )
  467. # Preheat / heat-soak before queued prints (#1468). The scheduler stage runs
  468. # BEFORE FTP upload. Three hardware tiers behave differently:
  469. # - Chamber heater (H2C/H2D/H2DPro/H2S/X2D/X1E): M141 → wait for chamber
  470. # sensor to reach target → soak
  471. # - Chamber sensor only (X1C/P2S): M140 only → wait for radiant chamber
  472. # warm-up to reach target OR max-wait timeout → soak
  473. # - No chamber sensor (P1S/P1P/A1/A1 Mini): M140 only → fixed soak timer
  474. # (no way to verify chamber temp; relies entirely on max_wait + soak)
  475. # Chamber target derives per-print from the loaded AMS filament types via
  476. # preheat_filament_targets (max across loaded slots). A target of 0 skips
  477. # the chamber phase but keeps the bed phase + soak. Per-queue-item
  478. # `preheat_chamber_target_override` (nullable) bypasses the derivation.
  479. preheat_enabled: bool = Field(
  480. default=False,
  481. description="Master toggle / default for new queue items. Per-item preheat_override can flip the decision per print.",
  482. )
  483. preheat_filament_targets: str = Field(
  484. default="",
  485. description=(
  486. "JSON map of normalized filament type → chamber target °C. Empty = bundled defaults "
  487. "(PLA/PETG/TPU/PVA: 0, PETG-CF: 40, ABS/ASA: 45, PA/PC/PC-FR: 50, PA-CF: 55, default: 0). "
  488. "Scheduler picks max across loaded AMS slots; 0 disables chamber phase for that print."
  489. ),
  490. )
  491. preheat_max_wait_seconds: int = Field(
  492. default=900,
  493. ge=60,
  494. le=3600,
  495. description="Maximum time to wait for the chamber to reach the target before falling through to the soak phase (radiant heating on X1C/P2S can take 15-30 min).",
  496. )
  497. preheat_soak_seconds: int = Field(
  498. default=300,
  499. ge=0,
  500. le=1800,
  501. description="Additional hold time at temperature after the chamber reaches the target (or after max_wait_seconds elapses). 0 = no soak.",
  502. )
  503. queue_keep_bed_warm: bool = Field(
  504. default=False,
  505. description=(
  506. "While a printer is in FINISH state awaiting plate-clear and the next queued item requires "
  507. "chamber heating, hold the bed hot so the chamber stays warm during the bed-clearing "
  508. "window. The bed is the chamber's heating element here: the hold target is "
  509. "queue_keep_warm_bed_temp, or the item's own bed_temperature when the slicer metadata "
  510. "reports a higher one. Only fires for filaments with a non-zero chamber target "
  511. "(ASA, ABS, PA, PC etc.); PLA/PETG prints are skipped automatically."
  512. ),
  513. )
  514. queue_keep_warm_bed_temp: int = Field(
  515. default=90,
  516. ge=40,
  517. le=110,
  518. description=(
  519. "Bed temperature (°C) used when the bed's job is to heat the chamber. 90 sustains "
  520. "chamber warmth on enclosed printers and satisfies bed-threshold-linked aftermarket "
  521. "chamber heaters (which typically activate at bed ≥ 80). Applies in two places: the "
  522. "keep-warm hold between chamber-heated prints, and preheat when a chamber-heated "
  523. "item's slicer metadata carries no bed temperature at all. A parsed bed temperature "
  524. "higher than this always wins, so the bed is never driven cooler than the print needs."
  525. ),
  526. )
  527. queue_keep_warm_max_minutes: int = Field(
  528. default=120,
  529. ge=5,
  530. le=480,
  531. description=(
  532. "How long keep-warm may hold the bed on a printer waiting for its plate to be cleared. "
  533. "When this elapses the bed is switched off, and the hold does not re-arm until the "
  534. "printer next becomes a keep-warm candidate — so a plate nobody clears cannot leave the "
  535. "bed hot indefinitely. Set it to how long you realistically take to reach the printer; "
  536. "the only cost of it being too short is that the next print re-soaks from cold."
  537. ),
  538. )
  539. # User-configurable presets for the printer-card temperature / fan-speed
  540. # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
  541. # rendered separately and is not configurable). Empty string = use built-in
  542. # defaults. Validators on AppSettingsUpdate enforce the shape on writes.
  543. nozzle_temp_presets: str = Field(
  544. default="",
  545. description="JSON array of 3 nozzle-temperature preset values in C (0-320). Empty = use defaults [120, 220, 260]",
  546. )
  547. bed_temp_presets: str = Field(
  548. default="",
  549. description="JSON array of 3 bed-temperature preset values in C (0-140). Empty = use defaults [55, 75, 90]",
  550. )
  551. chamber_temp_presets: str = Field(
  552. default="",
  553. description="JSON array of 3 chamber-temperature preset values in C (0-65). Empty = use defaults [35, 45, 60]",
  554. )
  555. fan_speed_presets: str = Field(
  556. default="",
  557. description="JSON array of 3 fan-speed preset values in % (0-100). Empty = use defaults [50, 75, 100]",
  558. )
  559. # Local login (#1589) — when False, /auth/login rejects username+password
  560. # credentials with HTTP 403 and the login page hides the credentials form,
  561. # leaving only the OIDC SSO provider buttons. LDAP is governed by its own
  562. # `ldap_enabled` toggle and is not affected. The env-var
  563. # ``BAMBUDDY_LOCAL_LOGIN=true`` bypasses this gate at the route level so a
  564. # server admin can recover an install whose SSO provider is unreachable
  565. # without editing the DB.
  566. local_login_enabled: bool = Field(
  567. default=True,
  568. description=(
  569. "Allow username + password login on /auth/login. Disable when only SSO should be usable. "
  570. "BAMBUDDY_LOCAL_LOGIN=true on the server overrides this to keep a recovery path open."
  571. ),
  572. )
  573. # LDAP authentication (#794)
  574. ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
  575. ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
  576. ldap_bind_dn: str = Field(default="", description="Bind DN for LDAP searches (e.g., cn=admin,dc=example,dc=com)")
  577. ldap_bind_password: str = Field(default="", description="Bind password for LDAP searches")
  578. ldap_search_base: str = Field(default="", description="Search base DN (e.g., ou=users,dc=example,dc=com)")
  579. ldap_user_filter: str = Field(
  580. default="(sAMAccountName={username})",
  581. description="LDAP user search filter. {username} is replaced with the login username",
  582. )
  583. ldap_security: str = Field(default="starttls", description="LDAP security: 'starttls' or 'ldaps'")
  584. ldap_group_mapping: str = Field(
  585. default="",
  586. description="JSON: LDAP group to BamBuddy group mapping {ldap_group_dn: bambuddy_group_name}",
  587. )
  588. ldap_auto_provision: bool = Field(
  589. default=False,
  590. description="Auto-create BamBuddy user on first successful LDAP login",
  591. )
  592. ldap_default_group: str = Field(
  593. default="",
  594. description="Fallback BamBuddy group name assigned when an LDAP user authenticates but has no mapped groups. Empty = no fallback.",
  595. )
  596. # Obico AI failure detection (#172)
  597. obico_enabled: bool = Field(default=False, description="Enable Obico AI print failure detection")
  598. obico_ml_url: str = Field(
  599. default="",
  600. description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
  601. )
  602. bambuddy_internal_url: str = Field(
  603. default="", description="Bambuddy Internal URL for Obico; empty uses External URL"
  604. )
  605. obico_ml_token: str = Field(
  606. default="",
  607. description=(
  608. "Bearer token for the Obico ML API, matching the server's ML_API_TOKEN "
  609. "environment variable. Empty when the server runs without one."
  610. ),
  611. )
  612. obico_sensitivity: str = Field(
  613. default="medium",
  614. description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
  615. )
  616. obico_action: str = Field(
  617. default="notify",
  618. description="Action on detected failure: 'notify', 'pause', or 'pause_and_off'",
  619. )
  620. obico_poll_interval: int = Field(
  621. default=10,
  622. ge=5,
  623. le=120,
  624. description="Seconds between detection checks while a print is running",
  625. )
  626. obico_enabled_printers: str = Field(
  627. default="",
  628. description="JSON array of printer IDs to monitor (empty = all connected printers)",
  629. )
  630. # Inventory forecasting
  631. forecast_global_lead_time_days: int = Field(
  632. default=0,
  633. ge=0,
  634. description="Global lead time floor (days) used in reorder point calculation for all SKUs",
  635. )
  636. location_sensor_poll_interval: int = Field(
  637. default=120,
  638. ge=60,
  639. le=3600,
  640. description="Seconds between Home Assistant polls/UI refreshes for storage-location sensors",
  641. )
  642. # Server-backed rather than per-browser: these seed the alert rule written
  643. # onto each sensor row when one is bound, so two admins binding sensors
  644. # from different browsers must not seed different rules — and a restore
  645. # has to bring them back. The "show on card" default stays local, because
  646. # show_on_card is decided per sensor and this is only its form
  647. # pre-selection. Same JSON-in-a-string shape as preheat_filament_targets.
  648. location_sensor_alert_defaults: str = Field(
  649. default="",
  650. description=(
  651. "JSON map of sensor category (temperature/humidity/battery) → "
  652. '{"alertAbove": str, "alertBelow": str, "notifyOnAlert": bool}, seeding new '
  653. "storage-location sensor bindings. Empty = built-in defaults."
  654. ),
  655. )
  656. # Default sidebar order (admin-set for all users)
  657. default_sidebar_order: str = Field(
  658. default="",
  659. description="JSON object with 'order' key containing array of sidebar item IDs (empty = no default)",
  660. )
  661. # The settings page sends every field back on each save, and the update
  662. # schema accepts only these values. A stored value outside them would make
  663. # every later save fail, so it is read back as what the service treats it as.
  664. @field_validator("camera_light_mode", mode="before")
  665. @classmethod
  666. def _known_camera_light_mode(cls, value):
  667. return value if value in ("off", "all", "selected") else "off"
  668. @field_validator("camera_light_delay", mode="before")
  669. @classmethod
  670. def _camera_light_delay_in_range(cls, value):
  671. try:
  672. seconds = float(value)
  673. except (TypeError, ValueError):
  674. return 2.0
  675. return min(max(0.0, seconds), 5.0)
  676. class AppSettingsUpdate(BaseModel):
  677. """Schema for updating settings (all fields optional)."""
  678. auto_archive: bool | None = None
  679. save_thumbnails: bool | None = None
  680. capture_finish_photo: bool | None = None
  681. finish_photo_restore_plate: bool | None = None
  682. default_filament_cost: float | None = None
  683. currency: str | None = None
  684. energy_cost_per_kwh: float | None = None
  685. energy_tracking_mode: str | None = None
  686. energy_price_source: Literal["fixed", "homeassistant"] | None = None
  687. energy_price_ha_entity: str | None = Field(default=None, max_length=255)
  688. spoolman_enabled: bool | None = None
  689. spoolman_url: str | None = None
  690. spoolman_sync_mode: str | None = None
  691. spoolman_disable_weight_sync: bool | None = None
  692. spoolman_report_partial_usage: bool | None = None
  693. auto_add_unknown_rfid: bool | None = None
  694. disable_filament_warnings: bool | None = None
  695. prefer_lowest_filament: bool | None = None
  696. check_updates: bool | None = None
  697. check_printer_firmware: bool | None = None
  698. include_beta_updates: bool | None = None
  699. announcements_enabled: bool | None = None
  700. announcements_all_users: bool | None = None
  701. local_login_enabled: bool | None = None
  702. language: str | None = None
  703. notification_language: str | None = None
  704. bed_cooled_threshold: float | None = None
  705. ams_humidity_good: int | None = None
  706. ams_humidity_fair: int | None = None
  707. ams_temp_good: float | None = None
  708. ams_temp_fair: float | None = None
  709. ams_temp_alarm: float | None = None
  710. ams_history_retention_days: int | None = None
  711. printer_sensor_history_retention_days: int | None = None
  712. queue_drying_enabled: bool | None = None
  713. queue_drying_block: bool | None = None
  714. ambient_drying_enabled: bool | None = None
  715. ambient_drying_sustained_minutes: int | None = Field(default=None, ge=0, le=240)
  716. print_drying_enabled: bool | None = None
  717. drying_presets: str | None = None
  718. ams_humidity_thresholds: str | None = None
  719. per_printer_mapping_expanded: bool | None = None
  720. date_format: str | None = None
  721. time_format: str | None = None
  722. default_printer_id: int | None = None
  723. pipeline_max_copies: int | None = None
  724. virtual_printer_enabled: bool | None = None
  725. virtual_printer_access_code: str | None = None
  726. virtual_printer_mode: str | None = None
  727. virtual_printer_archive_name_source: str | None = None
  728. dark_style: str | None = None
  729. dark_background: str | None = None
  730. dark_accent: str | None = None
  731. light_style: str | None = None
  732. light_background: str | None = None
  733. light_accent: str | None = None
  734. ftp_retry_enabled: bool | None = None
  735. ftp_retry_count: int | None = None
  736. ftp_retry_delay: int | None = None
  737. ftp_timeout: int | None = None
  738. mqtt_enabled: bool | None = None
  739. mqtt_broker: str | None = None
  740. mqtt_port: int | None = None
  741. mqtt_username: str | None = None
  742. mqtt_password: str | None = None
  743. mqtt_topic_prefix: str | None = None
  744. mqtt_use_tls: bool | None = None
  745. external_url: str | None = None
  746. docker_compose_dir: str | None = None
  747. ha_enabled: bool | None = None
  748. ha_url: str | None = None
  749. ha_token: str | None = None
  750. library_archive_mode: str | None = None
  751. library_disk_warning_gb: float | None = None
  752. camera_view_mode: str | None = None
  753. camera_light_mode: Literal["off", "all", "selected"] | None = None
  754. # Capped by camera_light.MAX_DELAY_SECONDS: the finish photo's budget
  755. # has no more to spare.
  756. camera_light_delay: float | None = Field(default=None, ge=0, le=5)
  757. preferred_slicer: str | None = None
  758. open_in_slicer: str | None = None
  759. slice_engine: str | None = None
  760. use_slicer_api: bool | None = None
  761. orcaslicer_api_url: str | None = None
  762. bambu_studio_api_url: str | None = None
  763. slicer_stall_timeout_minutes: int | None = Field(default=None, ge=1, le=240)
  764. prometheus_enabled: bool | None = None
  765. prometheus_token: str | None = None
  766. low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
  767. session_max_hours: int | None = Field(default=None, ge=1, le=720)
  768. user_notifications_enabled: bool | None = None
  769. default_bed_levelling: TriState | None = None
  770. default_flow_cali: TriState | None = None
  771. default_vibration_cali: bool | None = None
  772. default_layer_inspect: bool | None = None
  773. default_timelapse: bool | None = None
  774. default_nozzle_offset_cali: TriState | None = None
  775. default_confirm_outcome: bool | None = None
  776. confirm_outcome_external_prints: bool | None = None
  777. confirm_default_good_on_plate_clear: bool | None = None
  778. stagger_group_size: int | None = Field(default=None, ge=1, le=50)
  779. stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
  780. billing_enabled: bool | None = None
  781. printer_kill_switch_enabled: bool | None = None
  782. finance_budget_reset_day: int | None = Field(default=None, ge=1, le=31)
  783. finance_budget_reset_timezone: str | None = None
  784. require_plate_clear: bool | None = None
  785. queue_shortest_first: bool | None = None
  786. queue_max_concurrent_uploads: int | None = Field(default=None, ge=1, le=16)
  787. preheat_enabled: bool | None = None
  788. preheat_filament_targets: str | None = None
  789. preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)
  790. preheat_soak_seconds: int | None = Field(default=None, ge=0, le=1800)
  791. queue_keep_bed_warm: bool | None = None
  792. queue_keep_warm_bed_temp: int | None = Field(default=None, ge=40, le=110)
  793. queue_keep_warm_max_minutes: int | None = Field(default=None, ge=5, le=480)
  794. nozzle_temp_presets: str | None = None
  795. bed_temp_presets: str | None = None
  796. chamber_temp_presets: str | None = None
  797. fan_speed_presets: str | None = None
  798. gcode_snippets: str | None = None
  799. local_backup_enabled: bool | None = None
  800. local_backup_schedule: str | None = None
  801. local_backup_time: str | None = None
  802. local_backup_retention: int | None = None
  803. local_backup_path: str | None = None
  804. ldap_enabled: bool | None = None
  805. ldap_server_url: str | None = None
  806. ldap_bind_dn: str | None = None
  807. ldap_bind_password: str | None = None
  808. ldap_search_base: str | None = None
  809. ldap_user_filter: str | None = None
  810. ldap_security: str | None = None
  811. ldap_group_mapping: str | None = None
  812. ldap_auto_provision: bool | None = None
  813. ldap_default_group: str | None = None
  814. obico_enabled: bool | None = None
  815. obico_ml_url: str | None = None
  816. bambuddy_internal_url: str | None = None
  817. obico_ml_token: str | None = None
  818. obico_sensitivity: str | None = None
  819. obico_action: str | None = None
  820. obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
  821. obico_enabled_printers: str | None = None
  822. default_sidebar_order: str | None = None
  823. forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
  824. location_sensor_poll_interval: int | None = Field(default=None, ge=60, le=3600)
  825. # Three categories × three short fields is well under 300 characters of
  826. # JSON, so 2000 is pure headroom — the cap only stops a stray client from
  827. # parking megabytes in the settings table. Write path only: the AppSettings
  828. # read model must keep accepting whatever an older install already stored.
  829. location_sensor_alert_defaults: str | None = Field(default=None, max_length=2000)
  830. @field_validator(*LAN_SERVICE_URL_SETTINGS)
  831. @classmethod
  832. def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
  833. """Reject SSRF-unsafe outbound service URLs on save.
  834. Empty (and whitespace-only) is the documented "not configured / fall
  835. back to the env var" value for all four fields and must keep passing.
  836. Values that are not absolute URLs at all ("192.168.1.10:3333",
  837. "localhost:3333") are left alone rather than rejected. Two reasons:
  838. - They are inert. Every consumer of these four settings goes through
  839. httpx, which raises UnsupportedProtocol for a URL with no scheme, so
  840. no request is ever issued and there is nothing to guard against.
  841. - They were storable before this validator existed, and the settings
  842. UI is a plain text input with no scheme enforcement. Newly rejecting
  843. them would break saves that have nothing to do with the URL: the
  844. Obico panel, for one, sends obico_ml_url with every change and
  845. auto-saves, so one legacy value would block toggling detection on or
  846. off. A pre-existing misconfiguration should keep failing where it
  847. already failed (at request time), not spread to unrelated fields.
  848. ``urlparse`` is no help in telling the two apart — it reads
  849. "localhost:3333" as scheme "localhost" — so the test is the literal
  850. "://" that makes a string an absolute URL.
  851. """
  852. if v is None or not v.strip():
  853. return v
  854. candidate = v.strip()
  855. if "://" not in candidate:
  856. return v
  857. # Lazy-imported: schemas avoid top-level imports from api/routes,
  858. # matching the existing pattern in auth.py's _validate_icon_url.
  859. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  860. try:
  861. assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
  862. except ValueError as exc:
  863. raise ValueError(str(exc)) from exc
  864. return v
  865. @field_validator("docker_compose_dir")
  866. @classmethod
  867. def validate_docker_compose_dir(cls, v: str | None) -> str | None:
  868. """Keep the copy-and-paste update command free of shell injection (#2664).
  869. Validated on the write path only. Doing it on ``AppSettings`` as well
  870. would mean a single bad row — however it got there — 500s the entire
  871. settings GET and takes the app down with it, which is a worse outcome
  872. than rendering a string that has to be pasted into a shell by hand to
  873. do anything at all.
  874. """
  875. if v is None or not v.strip():
  876. return v
  877. candidate = v.strip()
  878. if len(candidate) > _COMPOSE_DIR_MAX_LEN:
  879. raise ValueError(f"Compose directory must be at most {_COMPOSE_DIR_MAX_LEN} characters")
  880. if not _COMPOSE_DIR_ALLOWED.match(candidate):
  881. raise ValueError(
  882. "Compose directory may only contain path characters (letters, digits, space, and - _ . / \\ : ~)"
  883. )
  884. # A trailing backslash is the one survivor that would still break the
  885. # frontend's double-quoting: `cd "/opt/bam buddy\"` escapes the closing
  886. # quote and swallows the rest of the line. Harmless (the shell just
  887. # waits for a terminator rather than running anything) but the user
  888. # would be left staring at a continuation prompt, so refuse it here
  889. # instead of shipping a command that cannot work.
  890. if candidate.endswith("\\"):
  891. raise ValueError("Compose directory must not end with a backslash")
  892. return candidate
  893. @field_validator("gcode_snippets")
  894. @classmethod
  895. def validate_gcode_snippets(cls, v: str | None) -> str | None:
  896. if v is None or v == "":
  897. return v
  898. try:
  899. parsed = json.loads(v)
  900. except json.JSONDecodeError:
  901. raise ValueError("gcode_snippets must be valid JSON or empty")
  902. if not isinstance(parsed, dict):
  903. raise ValueError("gcode_snippets must be a JSON object keyed by printer model")
  904. return v
  905. @field_validator("ldap_group_mapping")
  906. @classmethod
  907. def validate_ldap_group_mapping(cls, v: str | None) -> str | None:
  908. if v is None or v == "":
  909. return v
  910. try:
  911. parsed = json.loads(v)
  912. except json.JSONDecodeError:
  913. raise ValueError("ldap_group_mapping must be valid JSON or empty")
  914. if not isinstance(parsed, dict):
  915. raise ValueError("ldap_group_mapping must be a JSON object mapping LDAP group DNs to BamBuddy group names")
  916. return v
  917. @field_validator("obico_enabled_printers")
  918. @classmethod
  919. def validate_obico_enabled_printers(cls, v: str | None) -> str | None:
  920. if v is None or v == "":
  921. return v
  922. try:
  923. parsed = json.loads(v)
  924. except json.JSONDecodeError:
  925. raise ValueError("obico_enabled_printers must be valid JSON or empty")
  926. if not isinstance(parsed, list) or not all(isinstance(item, int) for item in parsed):
  927. raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
  928. return v
  929. @field_validator("bambuddy_internal_url")
  930. @classmethod
  931. def validate_bambuddy_internal_url(cls, v: str | None) -> str:
  932. """Require an absolute http(s) URL, or empty to fall back to External URL.
  933. Obico's ML server fetches snapshots from this address, so a value
  934. without a scheme ("bambuddy:8000") would only fail later, mid-print,
  935. as a snapshot error. Rejecting it here surfaces the mistake on save.
  936. An explicit null clears the field. The settings updater stores None
  937. as the literal "None", which would read back as an address.
  938. """
  939. candidate = (v or "").strip()
  940. if not candidate:
  941. return ""
  942. error = "bambuddy_internal_url must be a full http:// or https:// address"
  943. if any(ch.isspace() for ch in candidate):
  944. raise ValueError(error)
  945. try:
  946. parsed = urlparse(candidate)
  947. hostname = parsed.hostname
  948. _ = parsed.port # raises on a port outside 0-65535
  949. except ValueError:
  950. raise ValueError(error) from None
  951. if parsed.scheme not in ("http", "https") or not hostname:
  952. raise ValueError(error)
  953. return candidate
  954. @staticmethod
  955. def _validate_preset_triple(v: str | None, field_name: str, lo: int, hi: int) -> str | None:
  956. """Validate a JSON array of exactly 3 ints in [lo, hi]. Empty = defaults."""
  957. if v is None or v == "":
  958. return v
  959. try:
  960. parsed = json.loads(v)
  961. except json.JSONDecodeError:
  962. raise ValueError(f"{field_name} must be valid JSON or empty")
  963. if not isinstance(parsed, list) or len(parsed) != 3:
  964. raise ValueError(f"{field_name} must be a JSON array of exactly 3 integers")
  965. if not all(isinstance(item, int) and not isinstance(item, bool) for item in parsed):
  966. raise ValueError(f"{field_name} entries must all be integers")
  967. if not all(lo <= item <= hi for item in parsed):
  968. raise ValueError(f"{field_name} entries must each be in [{lo}, {hi}]")
  969. return v
  970. @field_validator("nozzle_temp_presets")
  971. @classmethod
  972. def validate_nozzle_temp_presets(cls, v: str | None) -> str | None:
  973. return cls._validate_preset_triple(v, "nozzle_temp_presets", 0, 320)
  974. @field_validator("bed_temp_presets")
  975. @classmethod
  976. def validate_bed_temp_presets(cls, v: str | None) -> str | None:
  977. return cls._validate_preset_triple(v, "bed_temp_presets", 0, 140)
  978. @field_validator("chamber_temp_presets")
  979. @classmethod
  980. def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
  981. return cls._validate_preset_triple(v, "chamber_temp_presets", 0, MAX_CHAMBER_TEMP_C)
  982. @field_validator("fan_speed_presets")
  983. @classmethod
  984. def validate_fan_speed_presets(cls, v: str | None) -> str | None:
  985. return cls._validate_preset_triple(v, "fan_speed_presets", 0, 100)
  986. @field_validator("obico_sensitivity")
  987. @classmethod
  988. def validate_obico_sensitivity(cls, v: str | None) -> str | None:
  989. if v is None:
  990. return v
  991. if v not in ("low", "medium", "high"):
  992. raise ValueError("obico_sensitivity must be 'low', 'medium', or 'high'")
  993. return v
  994. @field_validator("obico_action")
  995. @classmethod
  996. def validate_obico_action(cls, v: str | None) -> str | None:
  997. if v is None:
  998. return v
  999. if v not in ("notify", "pause", "pause_and_off"):
  1000. raise ValueError("obico_action must be 'notify', 'pause', or 'pause_and_off'")
  1001. return v
  1002. @field_validator("default_sidebar_order")
  1003. @classmethod
  1004. def validate_default_sidebar_order(cls, v: str | None) -> str | None:
  1005. if v is None or v == "":
  1006. return v
  1007. try:
  1008. parsed = json.loads(v)
  1009. except json.JSONDecodeError:
  1010. raise ValueError("default_sidebar_order must be valid JSON or empty")
  1011. if isinstance(parsed, dict):
  1012. order = parsed.get("order")
  1013. hidden_system_item_ids = parsed.get("hiddenSystemItemIds", [])
  1014. if not isinstance(hidden_system_item_ids, list) or not all(
  1015. isinstance(item, str) for item in hidden_system_item_ids
  1016. ):
  1017. raise ValueError("sidebar hidden system item IDs must be an array of strings")
  1018. elif isinstance(parsed, list):
  1019. order = parsed
  1020. else:
  1021. raise ValueError("default_sidebar_order must be a JSON object with 'order' key or a JSON array")
  1022. if not isinstance(order, list) or not all(isinstance(item, str) for item in order):
  1023. raise ValueError("sidebar order must be an array of strings")
  1024. return v