Commit Graph
1081 Commits
Author SHA1 Message Date
maziggy a9b15e727f Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
Two printers went on printing while every archive they produced held nothing
    but a filename. Bambuddy opened port 990, the printer accepted the connection
    and answered with something that was not TLS, and connect() logged a warning
    and returned False -- indistinguishable, to every caller, from "the file is
    not at this path". So the 3MF lookup walked all six filename variants across
    five directories with four retries each, the cover endpoint ran its own
    sixteen-path sweep, and the timelapse scan added four more, all against a
    sixteen-path sweep, and the timelapse scan added four more, all against a
    printer that could not have answered any of them. One reporter's log carried
    1813 identical handshake failures, another's 3511.

    The evidence says this is the printer's own file service getting stuck, not a
    model, firmware or TLS-configuration problem. In #2780's bundle the same two
    printers ran clean from 22 July to 4 August and failed again from the 5th; a
    second bundle shows an X2D serving files for five days, flipping on 19 July,
    then failing every connection for eight days with zero successes. The same
    models and firmware appear in roughly twenty other bundles with no occurrences
    at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
    and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
    this symptom, and the reporter's own debug line proves they do not.

    An ssl.SSLError from connect() now opens a five-minute cool-off for that
    printer. Subsequent connects return False without touching the network, so a
    wedged printer is contacted twice an hour instead of hundreds of times a
    minute, and the single warning that is logged names the remedy. The cool-off
    is dropped on expiry rather than kept, so the map holds one key per currently
    wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
    abandons the remaining paths and skips the directory-walk fallback, the cover
    endpoint returns 503 naming the file service instead of a 404 that reads as
    "this print has no thumbnail", and the timelapse scan separates 503 (cannot
    reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
    both, which is what the reporter hit when reproducing.

    The Connection Diagnostic completed a bare TCP connect to 990, which is why it
    reported the port green throughout: the port is open, it is what is behind it
    that is broken. It now completes a real implicit-TLS handshake using the
    model's own ftp_profiles cap, so a pass means the FTP client would also get
    through. An open port that cannot negotiate reports warn with reason no_tls,
    selecting a new message in all 13 locales that points at a printer restart
    rather than at the firewall. No login is attempted, so this stays valid in the
    pre-save Add Printer flow.

    The cool-off tests run against a real socket that accepts on 990 and replies
    with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
    mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
    map too -- every test here talks to 127.0.0.1, so one left behind would make
    the next test's connect() a no-op.
2026-08-15 14:41:08 +02:00
maziggy b0927b426c implemented pr (minor) feedback 2026-08-15 14:40:37 +02:00
maziggy 40ba1f4b0e implemented pr (worth fixing) feedback
update commit
2026-08-15 14:40:25 +02:00
maziggy 7dcfd0921d Check filament deficit for Library-backed queue items (#2779)
A job needing 20.5 g was dispatched onto a spool holding 9 g and the printer
    started. _resolve_source_3mf returned LibraryFile.file_path verbatim, but that
    column stores a path relative to base_dir -- so it resolved against the process
    working directory, found nothing, and compute_deficit_for_queue_item treated a
    missing source as "nothing to verify" and returned no deficit.

    Every library-backed queue item was affected: Slicer Pipeline jobs, which are
    always library-backed, and everything added through the Library's bulk Add to
    queue. Both callers share the resolver, so the Play button on the queue was as
    blind as the auto-dispatcher. Archive-backed items (print history, VP intake)
    resolved correctly and were never affected, and neither was PrintModal, which
    resolves the file on its own path.

    The library branch now uses the same idiom as the eleven other readers of
    file_path -- absolute stays, relative joins base_dir. The join carries a
    SEC-PATH-OK marker: the value is DB-stored and generated by the Library ingest,
    and it is already what resolves the file for upload, so the check has to
    resolve it identically or it is not checking what gets printed.

    A source that is configured but absent now logs a warning naming the item and
    the resolved path. It still dispatches, because the upload needs the same file
    seconds later and fails there, where blocking would strand a queue on a moved
    file -- but a safety check that skips itself must not do so in silence, which
    is what hid this for every library-backed item.

    Tests cover the relative path (the reporter's 20.5 g against 9 g), the absolute
    path against a base_dir the file is not under, and the missing-source warning.
    The existing cases all used archives with absolute paths, which is the gap the
    bug lived in.
2026-08-15 14:40:02 +02:00
maziggy 34243ce7e4 Accept Forgejo tokens scoped to a single repository (#2775)
ForgejoBackend.test_connection asked GET /user who the token belonged to
    before asking whether the token could reach the repository, and treated a 403
    there as fatal. A Forgejo v15 repository-scoped token may only carry
    read/write on issues and repositories, so it 403s on /user -- and was rejected
    despite reaching its own repository fine, which is all a backup needs: the push
    path uses the Contents API and restore reads commits, trees and blobs, all
    under /repos/{owner}/{repo}. That /user call was the only one in the whole
    provider layer.

    The probe stays, because a 401 from it is genuinely conclusive and names a bad
    token before the repo call has to guess -- Forgejo v15+ hides a private repo
    behind 404 rather than 403, so the repo call cannot always tell those apart.
    Every other status now falls through to the repo check.

    Two additions keep the messages as sharp as before: the repo call's own 401 is
    mapped to "Invalid access token" instead of a generic API error, and the 404
    names write:repository and the scoped-to-another-repository case, mentioning a
    possibly-invalid token only when /user did not confirm the identity.

    The token hint under the field was one shared string reading "fine-grained
    token with Contents read/write" -- GitHub's advice, shown to Gitea, Forgejo and
    GitLab users too. It is now per provider via PROVIDER_TOKEN_HINT_I18N_KEY,
    following the existing repo-URL placeholder map, translated in all 13 locales.

    Tests pin the repository-scoped token connecting, a transient /user status not
    blocking the repo call, both 404 wordings, and the repo-call 401; a frontend
    test switches providers and asserts the hint follows.
2026-08-15 14:39:32 +02:00
maziggy ffcfa70e5b Record who queued a file from the Library and the webhook API
PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
    queue:delete_own permissions filter on, but only three of the paths that create
    queue items were setting it.

    The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
    bound the dependency to `_`, discarding the user, so every item it created was
    ownerless -- and invisible to the person who added it if their permissions are
    scoped to their own work. That is the one path built for adding many files at
    once, which is where it was hardest to notice.

    The webhook queue endpoint has no request user, but APIKey.user_id records the
    key's owner, which is the acting identity everywhere else the key is used, so
    its items are credited to that owner. Keys minted before per-user ownership
    have no user_id and their items stay ownerless.

    The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
    owner, and the obvious substitute is wrong rather than incomplete: one admin
    typically configures the VP while everyone sends prints through it, so
    crediting those to the admin would make the "added by" column lie and put other
    people's jobs in the admin's own queue. Existing NULL rows are not backfilled
    -- there is no record of who created them, and the ownerless case is already
    handled throughout.

    Tests pin both fixed paths and the two cases that must stay ownerless (auth
    disabled, legacy key).
2026-08-15 14:39:11 +02:00
maziggy c38e6566f3 fix(backup): report the categories a failed restore already committed (#2656)
The service reports what landed on a part-way failure -- categories commit as
    they finish, so results names the ones on disk -- and the modal gated the
    whole result panel on success, so it showed the failure message and dropped
    them.

    The cache invalidation was inside that same branch, which is the half that
    mattered: a run that committed the settings category and then failed left the
    app rendering pre-restore settings, with no reload and no re-read, which is
    the failure the modal's own reload-on-close exists to prevent.

    Gate on what was written instead. A refusal that never reached a category
    still carries an empty results and still keeps the form, so the mutex and
    backup-in-flight cases are unchanged. A partial does not read as a success:
    the tick becomes a warning and a line says the listed categories are the ones
    on disk.

    ---

    fix(backup): keep the local owner when the backup names one we cannot resolve (#2656)

    An owner the backup names but this instance has no user for was written as
    NULL, and overwrite is a blanket setattr -- so restoring over a local archive
    that had a perfectly good owner took it away, which is the 404-for-its-own-
    owner failure this column is carried across to fix. Resolving by username
    widened the trigger from a stale id to any user renamed since the backup.

    It is the same state as an absent key: the backup has not told us who owns
    this. So it takes the same action -- the column is not written at all.
    Overwrite keeps the local owner, insert lands ownerless with the note, and an
    explicit null still writes, so overwrite still means "match the backup".

    The notes move to the insert path with it. On overwrite nothing was taken
    away, so there is nothing to warn about, which is the rule the absent-key
    case already follows.
2026-08-15 14:38:18 +02:00
maziggy 0dfb53c5c5 Resolve a spool's material to a known drying preset before starting a cycle (#2774)
The drying popover prefilled its material from the loaded spool without
    checking the preset table had that material. An AMS-HT holding Support for
    PLA/PETG (tray_type PLA-S) fell back to PLA's temperature but kept PLA-S as
    the material, and the dropdown displays its first option when handed a value
    outside its list -- so it read PLA while PLA-S was sent. Same gap for every
    composite: PETG-CF prefilled at PLA's 45C.

    Resolve the tray_type to a key the table has before setting either value.
    Support materials and composites resolve to their base, nylon is aliased
    under its several spellings, and anything unrecognised falls back to PLA --
    the coolest row, so an unknown material under-dries rather than deforming a
    PLA spool.

    Also record request-topic messages in the MQTT debug log. That topic carries
    every command a printer is given, including Bambu Studio's, and returned
    before the logging block -- so a capture could show only what the printer
    said, never what it was told.
2026-08-15 14:36:57 +02:00
maziggy a12a4a8707 fix(backup): refuse the whole LDAP family on restore, not just its password (#2656)
A settings restore could substitute the instance'"'"'s authentication source.
    auth.py reads the LDAP config live from the settings table on every
    login, and none of ldap_server_url, ldap_user_filter, ldap_auto_provision
    or ldap_default_group is credential-shaped, so the secret-key hints never
    saw them and only the four auth-policy keys were protected.

    ldap_enabled was covered by the companion-credential rule instead, and
    that rule asks the wrong question. It judges availability - "will the
    integration still work?" - and an anonymous bind works, so a payload that
    simply OMITS ldap_bind_password skips the refusal and has its toggle
    written. Omitting the credential is exactly what an attacker authoring
    the file would do: they own the directory being pointed at, so they need
    no bind credential from us.

    Left unrefused, a backup repository anyone can write to yields admin:
    point ldap_server_url at your own directory, set ldap_auto_provision and
    ldap_default_group=Administrators, and the next login on a fresh username
    is provisioned into the admin group. Overwrite-off is enough on an
    instance that never configured LDAP - there are no rows to skip.

    Refused by prefix so a key added to the LDAP schema later is refused by
    default, and matched case-insensitively because the key comes from the
    backup JSON rather than from our own writer. ldap_enabled leaves
    _COMPANION_CREDENTIALS rather than sitting there as dead code, since
    _is_protected_setting_key runs first.

    The two tests asserting an anonymous bind was a false positive are
    inverted - they encoded the hole - and the refusal reuses the existing
    settingsAuthSkipped note, which already points at Settings >
    Authentication.
2026-08-15 14:27:48 +02:00
maziggy a0d1c3674b fix(backup): keep both restore tallies equal to the number the preview showed (#2656)
Two ways the K-profile and spool categories broke the
    restored + skipped + failed == item_count invariant the settings count
    holds:

    * The spools preview counted only the spools and put the usage records
      in the detail, but _restore_spool_usage increments the same tally, so
      any backup with usage history reported a total larger than the number
      the user was shown. The preview now counts both and the detail breaks
      the total down instead of adding to it.
    * A K-profile entry that is not a dict was dropped silently on the
      connected path. _kprofile_profile_count includes it, so the offline,
      printer-missing and step-failed paths all account for it; only the one
      path that talks to a printer let it leave the tally. It now counts
      failed.
2026-08-15 14:26:14 +02:00
maziggy 43d268655f fix(backup): commit each database category so SQLite's writer is not held (#2656)
The database phase had the same shape the K-profile phase did: _find_
    archive, _find_spool, the usage dedupe and _restore_settings are all one
    SELECT per row or per key, interleaved with autoflushed INSERTs, inside a
    single open write transaction. A few thousand archives plus a full usage
    history plausibly passes the 15 s busy_timeout, and every concurrent
    writer in the app fails with "database is locked" until it finishes.

    Each category now commits before the next starts. The id maps are plain
    dicts in memory and the session is expire_on_commit=False, so the
    ordering tolerates it.

    The cost is that a later failure no longer rolls back an earlier
    category, so a tally is recorded only after its category commits and
    run_restore reports the categories already on disk instead of an empty
    result - the same correction the K-profile split needed.
2026-08-15 14:26:02 +02:00
maziggy 732b1d7034 fix(backup): gate every restore category on the permission owning its rows (#2656)
settings was gated on settings:update because a restore rewrites rows
    PUT /api/v1/settings/ owns. The same argument applies to the other three
    categories, and gating one but not the rest is the only state that is
    not defensible: a role holding Backup alone could still write spools,
    archives and K-profiles through a restore that it cannot write through
    the endpoints that own them.

    Each category now also requires that endpoint's write permission -
    inventory:update, archives:update_all and kprofiles:update. archives
    takes update_all rather than create because a restore writes rows owned
    by other users, which is exactly what update_all means.

    All missing permissions are reported in one refusal: a restore is a
    multi-select, so naming them one at a time turns picking four categories
    into four round trips.
2026-08-15 14:25:46 +02:00
maziggy 2ef43943d3 docs(backup): say what the secret-key hints actually refuse (#2656)
The comment called the hint list belt-and-braces over keys the collector
    already refuses to write. It is not: _collect_settings filters exactly
    bambu_cloud_token and auth_secret_key, so a current backup really does
    carry mqtt_password, ldap_bind_password, ha_token and prometheus_token,
    and the hints are the only thing that refuses them. The companion-
    credential rule sits downstream of that, so reading the list as redundant
    and shortening it would write a stale credential and make that rule inert
    at the same time.

    Comment and test docstring only - no behaviour change.
2026-08-15 14:25:31 +02:00
maziggy ca6b9f8fe0 fix(backup): resolve a restored archive's owner by username, not by id (#2656)
created_by_id is only meaningful on the instance that wrote it. Restoring
    onto a rebuilt instance - this feature's main use case - renumbers the
    users table, so a live id can land on a different person and hand one
    user's print history to another under archives:read_own. The id path
    cannot even detect that: archivesOwnerCleared fires only for an id that
    is absent, so a valid-but-wrong id produced no note at all.

    The collector now records created_by_username alongside the id, and the
    restore prefers it. username is unique on users, so a match is the same
    person; the one case it cannot resolve - a user renamed since the backup
    - falls through to ownerless with a note rather than guessing from the
    id. The id stays as the fallback for backups taken before this change.
2026-08-15 14:25:16 +02:00
maziggy 09a7413efc implemented pr feedback #2 2026-08-15 14:24:45 +02:00
maziggy 12c6749abe Merge remote-tracking branch 'upstream/dev' into feature/billing 2026-08-15 14:24:32 +02:00
maziggy b1610f70ae Security hardening (maziggy/bambuddy-security #9) 2026-08-15 14:24:04 +02:00
maziggy 5bee8df5cd Broadcast AMS slot changes that keep the same material
Configuring a slot from the printer card left the card showing the old
    filament until a reload or the 30s fallback poll. The command reached the
    printer and the printer applied it; the update just never got broadcast.

    on_printer_status_change deduplicates WebSocket pushes against a status_key
    whose AMS part carried id, tray_type and state. Configure Slot writes none of
    those -- it writes tray_info_idx, tray_color, tray_sub_brands and cali_idx. So
    PLA to another brand or colour of PLA produced an identical key and was
    dropped, while PLA to PETG came through. Reset always worked because it clears
    tray_type.

    Those four fields only move when someone configures a slot or swaps a spool,
    so this costs no broadcasts mid-print. remain stays out of the key for the
    opposite reason.
2026-08-15 14:23:47 +02:00
maziggy f35c184b52 Show Home Assistant sensors on the printer card (#1148, #448)
Binds binary_sensor and reading-carrying sensor entities to a printer and
    renders their state on its card, worded by Home Assistant's device_class.
    Optional per-sensor alert condition drives a notification on the transition
    into the alert state and an opt-in interlock that holds queued prints while
    alerting -- a hold with a readable waiting_reason, never a failure, and only
    ever on a sensor that was read successfully.

    Sensors get their own table rather than a wider entity pattern on SmartPlug:
    get_smart_plug_by_printer would otherwise hand the card's power button a door
    contact to switch.

    The hold is passed to the model matcher directly rather than merged into
    busy_printers: _check_auto_drying reads that set as "is currently printing"
    and would put an idle-but-held printer down the mid-print drying path.

    The notification_providers migration spells its default FALSE, not 0 --
    Postgres rejects an integer default for a boolean and _safe_execute swallows
    the error.
2026-08-15 14:23:00 +02:00
maziggy 372ab3b788 Route external spools to a nozzle when the printer has no AMS (#2771)
Five X2Ds with no AMS, each printing from its external spool holder,
    took a job sent to a named printer and refused the same job sent to
    "Any X2D": the file uploaded, the firmware answered 0700_8012 "Failed
    to get AMS mapping table", and the item failed after three attempts.

    A named-printer job carries a mapping the frontend resolved at queue
    time, so the scheduler's matcher never runs. A model-based job has no
    printer until dispatch, so the matcher does run -- and could not see an
    external spool on a dual-nozzle printer. _build_loaded_filaments derived
    dual-nozzle status from ams_extruder_map, which is built from AMS info
    bits, so a printer with zero AMS units reported an empty map; every
    external spool got extruder_id=None, and the nozzle-aware hard filter in
    _match_filaments_to_slots discarded it because None equals neither 0 nor
    1. The mapping came back all -1, was cleared to None, and the print
    command went out as use_ams:true with no ams_mapping and no
    ams_mapping2 at all.

    This is the backend half of #1257, which fixed the same logic in
    useFilamentMapping.ts and left this copy behind. Mirror its inference:
    a populated nozzles[1].nozzle_diameter, a non-empty ams_extruder_map, or
    more than one vt_tray entry. Replaying the reporter's own push-status
    now yields extruder 1 for Ext-L and 0 for Ext-R, and a nozzle-1
    requirement resolves to [254] -- what their working named-printer
    dispatch sent. Single-nozzle printers keep extruder_id=None; nozzles
    always has two entries, so its length alone must not be the signal.

    Also stop dispatching a job the firmware is certain to reject. When the
    matcher ran, matched nothing, and the printer has no AMS, fail the item
    with the filament and nozzle it wants instead of spending an upload and
    two retries on it -- that path already ended in a failed item, just an
    opaque one. With an AMS attached the firmware error still stands, since
    there the user can load a spool and press Resume. Fail-safe like the
    nozzle-diameter guard (#1899): every branch short of a positive finding
    returns None and dispatches as before.

    _apply_filament_overrides is extracted from _compute_ams_mapping_for_printer
    so the message names the filament the matcher looked for rather than the one
    the 3MF was sliced with.
2026-08-15 14:22:24 +02:00
maziggy ae802e74ca Say why a drying cycle ended when the firmware cuts it short (#2770)
An H2D started a twelve-hour PETG dry at 65 degC and the AMS gave up on it
    twenty minutes in, with 700 of the 720 minutes still on the clock. It cooled,
    humidity climbed back over the threshold, auto-drying started another
    twelve-hour cycle, and that one went the same way; the reporter's AMS
    temperature history shows the loop running all morning.

    The log had one line for it: "AMS 0 drying complete", which is exactly what it
    says for a dry that ran its full twelve hours. Nothing in a support bundle told
    the two apart, and the one number that does -- the time still remaining -- was
    written into that line as the previous value, where it reads like a duration
    rather than a shortfall. The reporter took 700 for seconds and concluded the
    cycle had lasted twelve minutes.

    Bambuddy did not stop that cycle; every stop it sends is logged with the full
    outgoing command and there was none. So ending it was the printer's decision,
    and the account of why lives in three things already received and parsed and
    never written down: the drying phase and sub-phase from the AMS info hex, the
    per-unit dry_sf_reason constraint codes, and the live HMS errors.

    A cycle that ends with most of its countdown left now logs all three alongside
    how much of the requested duration ran. One that reaches its duration keeps the
    single line it has always had. A stop Bambuddy sent is named as ours -- it is
    short of its duration too, and on the telemetry alone is indistinguishable from
    the firmware abandoning the cycle, so without tracking it the print-takes-priority
    stop and the Stop button would both have been blamed on the printer.

    Diagnostics only. Nothing about when drying starts or stops has changed, and the
    restart loop is not addressed: what the firmware objects to has to be established
    before Bambuddy can sensibly decide how long to wait before trying again.
2026-08-15 14:22:04 +02:00
maziggy da1f655c3d Cover nested data structures in the HA notify pass-through (#1441)
The three tests around it use flat scalars, which is also all the field's
    placeholder and the wiki showed, so nothing recorded that the value is
    forwarded verbatim rather than treated as a key/value list. A reporter asked
    whether action buttons work; they always have, and now that is pinned.

    The changelog entry said "nested options work" and left it there. It now names
    actions and the two things that decide whether the buttons do anything - the
    mobile_app_notification_action automation, and iOS needing a registered
    category - since neither is set from Bambuddy and both are what a reader would
    otherwise have to discover the way the reporter did.
2026-08-15 14:20:40 +02:00
maziggy d8d637cbfe Survive a directory that defines no posixGroup class (#2769)
Every LDAP user on an lldap directory was rejected with "Incorrect
    username or password", on an install where Test Connection passed and
    where the same bind DN, filter and group membership all checked out
    under ldapsearch. The directory never saw the request.

    _extract_user_info searches for POSIX groups alongside the memberOf
    ones, and both of those filters name the posixGroup object class. ldap3
    fetches the schema at connect time (get_info=ALL) and validates class
    names in a filter against it while building the request, raising
    LDAPObjectClassError before anything is sent. lldap marks every account
    it creates as posixAccount -- which is what makes us look for POSIX
    groups at all -- but defines no group class beyond groupOfNames. The
    exception escaped authenticate_ldap_user, and the login route reports
    any LDAP failure as bad credentials.

    A directory with no posixGroup class has no posixGroup entries, which is
    exactly the answer those searches would have returned. Catch it, log it
    once, and carry on with the memberOf groups collected above. Both
    searches sit inside the one try: they name the same class, so once one
    is rejected the other cannot succeed, and attempting it would only
    produce a second identical exception to swallow.

    Not a regression from 848f55810. The memberUid filter has named the
    class since b6599dd41 and runs for every user whether or not they have a
    gidNumber, so a directory of this shape has never been able to log in;
    the primary-group lookup only added a second trigger. Test Connection
    was unaffected throughout because (objectClass=*) is a presence filter
    and never reaches the value validator.

    The mock connection gained a hook that raises on a filter substring,
    standing in for that client-side validation. Reset in the fixture and
    default None, so existing tests are unchanged.
2026-08-15 14:20:07 +02:00
maziggy 868cfa2d1e Read the printer's own slot mapping when Spoolman has none (#2768)
A sliced file numbers its filaments 1..4; which AMS tray each came from is
    a separate decision made when the job is sent. store_print_data learns it
    from one of two sources, both of which require the print command to pass
    through us: the mapping Bambuddy chose itself, or the one it intercepted
    on the printer's local request topic. A job dispatched from Bambu Studio
    while the printer is cloud-bound satisfies neither -- the command travels
    through Bambu's broker and never reaches the topic we subscribe to.

    slot_to_tray is then NULL and _resolve_global_tray_id guesses by position:
    filament 1 from the first loaded tray, filament 2 from the second. The
    reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so all four slots
    were charged to the wrong spool. Their log carries the printer's own
    answer, mapping=[1, 3, 0, 32768], sitting unread.

    usage_tracker has consulted that field since it started resolving mappings
    at completion, along with a colour match against the loaded trays for the
    models that never publish it (A1, A1 Mini, P1S, P2S). Only the Spoolman
    writer, which resolves at print start, never learned to -- and main.py
    gates usage_tracker behind Spoolman being off, so enabling Spoolman is
    what costs you the better resolver.

    _resolve_slot_to_tray_fallback gives it both, at completion rather than at
    print start: a printer keeps publishing the last job's mapping while it
    sits idle, so reading it early would risk stamping the previous print's
    mapping onto this one. A mapping we or the slicer actually recorded is
    never second-guessed.

    Applied in _report_partial_usage too. Cancelled and failed prints feed the
    same slot_to_tray to the same resolver and mis-charged just as readily.

    The resolved mapping and its source are now logged at print start and at
    completion. "source: none" at start is the signal that completion will
    have to fall back, and it was the one line that would have turned this
    report into a five-minute triage.

    Not addressed: editing the mapping after the fact, which the reporter also
    asked for. ArchiveUpdate exposes neither filament field and there is no way
    to re-run an attribution, so that is a feature rather than a fix.
2026-08-15 14:19:45 +02:00
maziggy 4335ee3814 Stop the drying badge inventing a temperature on a uniform AMS (#2759)
The follow-up to the same report: a second AMS 2 Pro, no aux power,
    loaded entirely with PLA and drying at the 45C the reporter picked,
    showed 45C and then switched to 55C.

    Bambu never echoes back a cycle's filament or temperature, so both come
    from the target cached when the command went out, and the fallback for
    a missing cache reads the loaded trays. The first pass narrowed that
    fallback to units whose spools agree on a filament, which fixed the
    mixed-unit case in the original report but left the uniform case
    answering with the spools' RFID-recommended drying_temp -- 55C here.
    Agreement across slots is evidence of what is being dried, because the
    dryer heats all of them. It is no evidence of the temperature, which is
    picked freely in the popover, so the recommendation was never more than
    a guess wearing the same confident "PLA @ 55C" as a known target.

    uniform_tray_drying_hint therefore becomes uniform_tray_filament_hint
    and returns the filament alone. The badge names a temperature only when
    we sent it, and otherwise shows the filament and the countdown.

    Both status builders also stopped filling the two fields independently.
    Entering the fallback when either was missing let a cached filament pair
    with a guessed temperature and render as though both were known; the
    temperature now simply has no fallback to reach.

    The badge required both fields before rendering anything, so dropping
    the temperature would have blanked it rather than shortening it -- the
    frontend now renders each on its own terms. No new translation key: the
    filament type is a passthrough.

    This changes what is shown when the cached target is missing, not why
    it goes missing. If the reporter was on the fixed build, the falling-
    edge gate is still letting a zero through on an unpowered unit, which
    needs a log covering the start of the cycle.
2026-08-15 14:19:15 +02:00
maziggy 91acac2b35 Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
Two printers went on printing while every archive they produced held nothing
but a filename. Bambuddy opened port 990, the printer accepted the connection
and answered with something that was not TLS, and connect() logged a warning
and returned False -- indistinguishable, to every caller, from "the file is
not at this path". So the 3MF lookup walked all six filename variants across
five directories with four retries each, the cover endpoint ran its own
sixteen-path sweep, and the timelapse scan added four more, all against a
sixteen-path sweep, and the timelapse scan added four more, all against a
printer that could not have answered any of them. One reporter's log carried
1813 identical handshake failures, another's 3511.

The evidence says this is the printer's own file service getting stuck, not a
model, firmware or TLS-configuration problem. In #2780's bundle the same two
printers ran clean from 22 July to 4 August and failed again from the 5th; a
second bundle shows an X2D serving files for five days, flipping on 19 July,
then failing every connection for eight days with zero successes. The same
models and firmware appear in roughly twenty other bundles with no occurrences
at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
this symptom, and the reporter's own debug line proves they do not.

An ssl.SSLError from connect() now opens a five-minute cool-off for that
printer. Subsequent connects return False without touching the network, so a
wedged printer is contacted twice an hour instead of hundreds of times a
minute, and the single warning that is logged names the remedy. The cool-off
is dropped on expiry rather than kept, so the map holds one key per currently
wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
abandons the remaining paths and skips the directory-walk fallback, the cover
endpoint returns 503 naming the file service instead of a 404 that reads as
"this print has no thumbnail", and the timelapse scan separates 503 (cannot
reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
both, which is what the reporter hit when reproducing.

The Connection Diagnostic completed a bare TCP connect to 990, which is why it
reported the port green throughout: the port is open, it is what is behind it
that is broken. It now completes a real implicit-TLS handshake using the
model's own ftp_profiles cap, so a pass means the FTP client would also get
through. An open port that cannot negotiate reports warn with reason no_tls,
selecting a new message in all 13 locales that points at a printer restart
rather than at the firewall. No login is attempted, so this stays valid in the
pre-save Add Printer flow.

The cool-off tests run against a real socket that accepts on 990 and replies
with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
map too -- every test here talks to 127.0.0.1, so one left behind would make
the next test's connect() a no-op.
2026-08-08 09:00:03 +02:00
behrinml 2e5d36d680 implemented pr (minor) feedback 2026-08-06 20:28:16 +02:00
behrinml dd1d40b0d4 implemented pr (worth fixing) feedback
update commit
2026-08-06 20:27:25 +02:00
maziggy 9c86a05657 Check filament deficit for Library-backed queue items (#2779)
A job needing 20.5 g was dispatched onto a spool holding 9 g and the printer
started. _resolve_source_3mf returned LibraryFile.file_path verbatim, but that
column stores a path relative to base_dir -- so it resolved against the process
working directory, found nothing, and compute_deficit_for_queue_item treated a
missing source as "nothing to verify" and returned no deficit.

Every library-backed queue item was affected: Slicer Pipeline jobs, which are
always library-backed, and everything added through the Library's bulk Add to
queue. Both callers share the resolver, so the Play button on the queue was as
blind as the auto-dispatcher. Archive-backed items (print history, VP intake)
resolved correctly and were never affected, and neither was PrintModal, which
resolves the file on its own path.

The library branch now uses the same idiom as the eleven other readers of
file_path -- absolute stays, relative joins base_dir. The join carries a
SEC-PATH-OK marker: the value is DB-stored and generated by the Library ingest,
and it is already what resolves the file for upload, so the check has to
resolve it identically or it is not checking what gets printed.

A source that is configured but absent now logs a warning naming the item and
the resolved path. It still dispatches, because the upload needs the same file
seconds later and fails there, where blocking would strand a queue on a moved
file -- but a safety check that skips itself must not do so in silence, which
is what hid this for every library-backed item.

Tests cover the relative path (the reporter's 20.5 g against 9 g), the absolute
path against a base_dir the file is not under, and the missing-source warning.
The existing cases all used archives with absolute paths, which is the gap the
bug lived in.
2026-08-06 12:21:50 +02:00
maziggy 306b9ba7fd Accept Forgejo tokens scoped to a single repository (#2775)
ForgejoBackend.test_connection asked GET /user who the token belonged to
before asking whether the token could reach the repository, and treated a 403
there as fatal. A Forgejo v15 repository-scoped token may only carry
read/write on issues and repositories, so it 403s on /user -- and was rejected
despite reaching its own repository fine, which is all a backup needs: the push
path uses the Contents API and restore reads commits, trees and blobs, all
under /repos/{owner}/{repo}. That /user call was the only one in the whole
provider layer.

The probe stays, because a 401 from it is genuinely conclusive and names a bad
token before the repo call has to guess -- Forgejo v15+ hides a private repo
behind 404 rather than 403, so the repo call cannot always tell those apart.
Every other status now falls through to the repo check.

Two additions keep the messages as sharp as before: the repo call's own 401 is
mapped to "Invalid access token" instead of a generic API error, and the 404
names write:repository and the scoped-to-another-repository case, mentioning a
possibly-invalid token only when /user did not confirm the identity.

The token hint under the field was one shared string reading "fine-grained
token with Contents read/write" -- GitHub's advice, shown to Gitea, Forgejo and
GitLab users too. It is now per provider via PROVIDER_TOKEN_HINT_I18N_KEY,
following the existing repo-URL placeholder map, translated in all 13 locales.

Tests pin the repository-scoped token connecting, a transient /user status not
blocking the repo call, both 404 wordings, and the repo-call 401; a frontend
test switches providers and asserts the hint follows.
2026-08-06 12:08:36 +02:00
maziggy 9beb001a17 Record who queued a file from the Library and the webhook API
PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
queue:delete_own permissions filter on, but only three of the paths that create
queue items were setting it.

The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
bound the dependency to `_`, discarding the user, so every item it created was
ownerless -- and invisible to the person who added it if their permissions are
scoped to their own work. That is the one path built for adding many files at
once, which is where it was hardest to notice.

The webhook queue endpoint has no request user, but APIKey.user_id records the
key's owner, which is the acting identity everywhere else the key is used, so
its items are credited to that owner. Keys minted before per-user ownership
have no user_id and their items stay ownerless.

The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
owner, and the obvious substitute is wrong rather than incomplete: one admin
typically configures the VP while everyone sends prints through it, so
crediting those to the admin would make the "added by" column lie and put other
people's jobs in the admin's own queue. Existing NULL rows are not backfilled
-- there is no record of who created them, and the ownerless case is already
handled throughout.

Tests pin both fixed paths and the two cases that must stay ownerless (auth
disabled, legacy key).
2026-08-06 11:06:15 +02:00
maziggy afa0ba0dc0 Nest projects under a master project and roll their figures up (#1264)
Projects were flat. The parent_id column and the sub-project list existed
but nothing could set a parent outside the API, and a master project's
stats only ever covered its own prints.

The project dialog gets a parent picker, and a project with sub-projects
gets a second card covering the whole tree -- jobs, parts, time, filament,
cost, and progress against every target in the tree added together. That
card is separate from the project's own stats, which keep their existing
meaning; widening them would have restated the figures of anyone who had
already nested projects over the API. Each listed sub-project carries its
own branch's roll-up, so the rows add up to the card above them.

On the Projects page a sub-project is drawn inside its parent's group
rather than as another card in the grid -- two cards columns apart cannot
show that they belong together, whatever the caption says. A sub-project
whose parent the status filter has hidden stays put and names its parent
instead.

compute_project_stats now goes through the same grouped aggregation as the
roll-up rather than its own copy of the SQL, since the two must agree.

Three things the interface made reachable:

- PATCH refused only a project as its own direct parent, so A -> B -> A
  was two calls away. A cycle has no root to roll up to, and the walk
  keeps its seen-set for databases that already contain one.
- A sub-project's percentage was completed quantities against the plate
  target, disagreeing with the page it linked to.
- Deleting a mid-tree project orphaned its children at top level; they
  now move up to its own parent.
2026-08-06 10:00:45 +02:00
maziggy b5163b94f8 fix(backup): report the categories a failed restore already committed (#2656)
The service reports what landed on a part-way failure -- categories commit as
they finish, so results names the ones on disk -- and the modal gated the
whole result panel on success, so it showed the failure message and dropped
them.

The cache invalidation was inside that same branch, which is the half that
mattered: a run that committed the settings category and then failed left the
app rendering pre-restore settings, with no reload and no re-read, which is
the failure the modal's own reload-on-close exists to prevent.

Gate on what was written instead. A refusal that never reached a category
still carries an empty results and still keeps the form, so the mutex and
backup-in-flight cases are unchanged. A partial does not read as a success:
the tick becomes a warning and a line says the listed categories are the ones
on disk.

---

fix(backup): keep the local owner when the backup names one we cannot resolve (#2656)

An owner the backup names but this instance has no user for was written as
NULL, and overwrite is a blanket setattr -- so restoring over a local archive
that had a perfectly good owner took it away, which is the 404-for-its-own-
owner failure this column is carried across to fix. Resolving by username
widened the trigger from a stale id to any user renamed since the backup.

It is the same state as an absent key: the backup has not told us who owns
this. So it takes the same action -- the column is not written at all.
Overwrite keeps the local owner, insert lands ownerless with the note, and an
explicit null still writes, so overwrite still means "match the backup".

The notes move to the insert path with it. On overwrite nothing was taken
away, so there is nothing to warn about, which is the rule the absent-key
case already follows.
2026-08-06 08:46:02 +02:00
MartinNYHC 6cd81fcd85 Merge branch 'dev' into feature/2656-restore-from-github 2026-08-06 08:23:37 +02:00
maziggy 1eea194953 Resolve a spool's material to a known drying preset before starting a cycle (#2774)
The drying popover prefilled its material from the loaded spool without
checking the preset table had that material. An AMS-HT holding Support for
PLA/PETG (tray_type PLA-S) fell back to PLA's temperature but kept PLA-S as
the material, and the dropdown displays its first option when handed a value
outside its list -- so it read PLA while PLA-S was sent. Same gap for every
composite: PETG-CF prefilled at PLA's 45C.

Resolve the tray_type to a key the table has before setting either value.
Support materials and composites resolve to their base, nylon is aliased
under its several spellings, and anything unrecognised falls back to PLA --
the coolest row, so an unknown material under-dries rather than deforming a
PLA spool.

Also record request-topic messages in the MQTT debug log. That topic carries
every command a printer is given, including Bambu Studio's, and returned
before the logging block -- so a capture could show only what the printer
said, never what it was told.
2026-08-06 08:19:01 +02:00
jmoore-skild f62e907e9f fix(backup): refuse the whole LDAP family on restore, not just its password (#2656)
A settings restore could substitute the instance'"'"'s authentication source.
auth.py reads the LDAP config live from the settings table on every
login, and none of ldap_server_url, ldap_user_filter, ldap_auto_provision
or ldap_default_group is credential-shaped, so the secret-key hints never
saw them and only the four auth-policy keys were protected.

ldap_enabled was covered by the companion-credential rule instead, and
that rule asks the wrong question. It judges availability - "will the
integration still work?" - and an anonymous bind works, so a payload that
simply OMITS ldap_bind_password skips the refusal and has its toggle
written. Omitting the credential is exactly what an attacker authoring
the file would do: they own the directory being pointed at, so they need
no bind credential from us.

Left unrefused, a backup repository anyone can write to yields admin:
point ldap_server_url at your own directory, set ldap_auto_provision and
ldap_default_group=Administrators, and the next login on a fresh username
is provisioned into the admin group. Overwrite-off is enough on an
instance that never configured LDAP - there are no rows to skip.

Refused by prefix so a key added to the LDAP schema later is refused by
default, and matched case-insensitively because the key comes from the
backup JSON rather than from our own writer. ldap_enabled leaves
_COMPANION_CREDENTIALS rather than sitting there as dead code, since
_is_protected_setting_key runs first.

The two tests asserting an anonymous bind was a false positive are
inverted - they encoded the hole - and the refusal reuses the existing
settingsAuthSkipped note, which already points at Settings >
Authentication.
2026-08-05 20:05:23 -04:00
jmoore-skild bb25e36510 Merge branch 'dev' into feature/2656-restore-from-github 2026-08-05 16:47:49 -04:00
jmoore-skild f6fca4b927 fix(backup): keep both restore tallies equal to the number the preview showed (#2656)
Two ways the K-profile and spool categories broke the
restored + skipped + failed == item_count invariant the settings count
holds:

* The spools preview counted only the spools and put the usage records
  in the detail, but _restore_spool_usage increments the same tally, so
  any backup with usage history reported a total larger than the number
  the user was shown. The preview now counts both and the detail breaks
  the total down instead of adding to it.
* A K-profile entry that is not a dict was dropped silently on the
  connected path. _kprofile_profile_count includes it, so the offline,
  printer-missing and step-failed paths all account for it; only the one
  path that talks to a printer let it leave the tally. It now counts
  failed.
2026-08-05 16:17:42 -04:00
jmoore-skild fc2dcf76b5 fix(backup): commit each database category so SQLite's writer is not held (#2656)
The database phase had the same shape the K-profile phase did: _find_
archive, _find_spool, the usage dedupe and _restore_settings are all one
SELECT per row or per key, interleaved with autoflushed INSERTs, inside a
single open write transaction. A few thousand archives plus a full usage
history plausibly passes the 15 s busy_timeout, and every concurrent
writer in the app fails with "database is locked" until it finishes.

Each category now commits before the next starts. The id maps are plain
dicts in memory and the session is expire_on_commit=False, so the
ordering tolerates it.

The cost is that a later failure no longer rolls back an earlier
category, so a tally is recorded only after its category commits and
run_restore reports the categories already on disk instead of an empty
result - the same correction the K-profile split needed.
2026-08-05 16:14:23 -04:00
jmoore-skild 9284240279 fix(backup): gate every restore category on the permission owning its rows (#2656)
settings was gated on settings:update because a restore rewrites rows
PUT /api/v1/settings/ owns. The same argument applies to the other three
categories, and gating one but not the rest is the only state that is
not defensible: a role holding Backup alone could still write spools,
archives and K-profiles through a restore that it cannot write through
the endpoints that own them.

Each category now also requires that endpoint's write permission -
inventory:update, archives:update_all and kprofiles:update. archives
takes update_all rather than create because a restore writes rows owned
by other users, which is exactly what update_all means.

All missing permissions are reported in one refusal: a restore is a
multi-select, so naming them one at a time turns picking four categories
into four round trips.
2026-08-05 16:10:04 -04:00
jmoore-skild 8602c54c1f docs(backup): say what the secret-key hints actually refuse (#2656)
The comment called the hint list belt-and-braces over keys the collector
already refuses to write. It is not: _collect_settings filters exactly
bambu_cloud_token and auth_secret_key, so a current backup really does
carry mqtt_password, ldap_bind_password, ha_token and prometheus_token,
and the hints are the only thing that refuses them. The companion-
credential rule sits downstream of that, so reading the list as redundant
and shortening it would write a stale credential and make that rule inert
at the same time.

Comment and test docstring only - no behaviour change.
2026-08-05 16:09:50 -04:00
jmoore-skild 4ec0f3f9f2 fix(backup): resolve a restored archive's owner by username, not by id (#2656)
created_by_id is only meaningful on the instance that wrote it. Restoring
onto a rebuilt instance - this feature's main use case - renumbers the
users table, so a live id can land on a different person and hand one
user's print history to another under archives:read_own. The id path
cannot even detect that: archivesOwnerCleared fires only for an id that
is absent, so a valid-but-wrong id produced no note at all.

The collector now records created_by_username alongside the id, and the
restore prefers it. username is unique on users, so a match is the same
person; the one case it cannot resolve - a user renamed since the backup
- falls through to ownerless with a note rather than guessing from the
id. The id stays as the fallback for backups taken before this change.
2026-08-05 16:09:20 -04:00
behrinml 9a397f46e9 implemented pr feedback #2 2026-08-05 21:25:46 +02:00
behrinml 43bf854bd5 Merge remote-tracking branch 'upstream/dev' into feature/billing 2026-08-05 20:19:32 +02:00
maziggy 16c8c6f2ea Security hardening (maziggy/bambuddy-security #9) 2026-08-05 15:21:42 +02:00
maziggy 684a328d6f Broadcast AMS slot changes that keep the same material
Configuring a slot from the printer card left the card showing the old
filament until a reload or the 30s fallback poll. The command reached the
printer and the printer applied it; the update just never got broadcast.

on_printer_status_change deduplicates WebSocket pushes against a status_key
whose AMS part carried id, tray_type and state. Configure Slot writes none of
those -- it writes tray_info_idx, tray_color, tray_sub_brands and cali_idx. So
PLA to another brand or colour of PLA produced an identical key and was
dropped, while PLA to PETG came through. Reset always worked because it clears
tray_type.

Those four fields only move when someone configures a slot or swaps a spool,
so this costs no broadcasts mid-print. remain stays out of the key for the
opposite reason.
2026-08-05 14:53:05 +02:00
maziggy cd004df817 Show Home Assistant sensors on the printer card (#1148, #448)
Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.

Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.

The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.

The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
2026-08-05 14:26:38 +02:00
maziggy 945d4ca6eb Route external spools to a nozzle when the printer has no AMS (#2771)
Five X2Ds with no AMS, each printing from its external spool holder,
took a job sent to a named printer and refused the same job sent to
"Any X2D": the file uploaded, the firmware answered 0700_8012 "Failed
to get AMS mapping table", and the item failed after three attempts.

A named-printer job carries a mapping the frontend resolved at queue
time, so the scheduler's matcher never runs. A model-based job has no
printer until dispatch, so the matcher does run -- and could not see an
external spool on a dual-nozzle printer. _build_loaded_filaments derived
dual-nozzle status from ams_extruder_map, which is built from AMS info
bits, so a printer with zero AMS units reported an empty map; every
external spool got extruder_id=None, and the nozzle-aware hard filter in
_match_filaments_to_slots discarded it because None equals neither 0 nor
1. The mapping came back all -1, was cleared to None, and the print
command went out as use_ams:true with no ams_mapping and no
ams_mapping2 at all.

This is the backend half of #1257, which fixed the same logic in
useFilamentMapping.ts and left this copy behind. Mirror its inference:
a populated nozzles[1].nozzle_diameter, a non-empty ams_extruder_map, or
more than one vt_tray entry. Replaying the reporter's own push-status
now yields extruder 1 for Ext-L and 0 for Ext-R, and a nozzle-1
requirement resolves to [254] -- what their working named-printer
dispatch sent. Single-nozzle printers keep extruder_id=None; nozzles
always has two entries, so its length alone must not be the signal.

Also stop dispatching a job the firmware is certain to reject. When the
matcher ran, matched nothing, and the printer has no AMS, fail the item
with the filament and nozzle it wants instead of spending an upload and
two retries on it -- that path already ended in a failed item, just an
opaque one. With an AMS attached the firmware error still stands, since
there the user can load a spool and press Resume. Fail-safe like the
nozzle-diameter guard (#1899): every branch short of a positive finding
returns None and dispatches as before.

_apply_filament_overrides is extracted from _compute_ams_mapping_for_printer
so the message names the filament the matcher looked for rather than the one
the 3MF was sliced with.
2026-08-05 13:15:04 +02:00
maziggy 0596ff424e Say why a drying cycle ended when the firmware cuts it short (#2770)
An H2D started a twelve-hour PETG dry at 65 degC and the AMS gave up on it
twenty minutes in, with 700 of the 720 minutes still on the clock. It cooled,
humidity climbed back over the threshold, auto-drying started another
twelve-hour cycle, and that one went the same way; the reporter's AMS
temperature history shows the loop running all morning.

The log had one line for it: "AMS 0 drying complete", which is exactly what it
says for a dry that ran its full twelve hours. Nothing in a support bundle told
the two apart, and the one number that does -- the time still remaining -- was
written into that line as the previous value, where it reads like a duration
rather than a shortfall. The reporter took 700 for seconds and concluded the
cycle had lasted twelve minutes.

Bambuddy did not stop that cycle; every stop it sends is logged with the full
outgoing command and there was none. So ending it was the printer's decision,
and the account of why lives in three things already received and parsed and
never written down: the drying phase and sub-phase from the AMS info hex, the
per-unit dry_sf_reason constraint codes, and the live HMS errors.

A cycle that ends with most of its countdown left now logs all three alongside
how much of the requested duration ran. One that reaches its duration keeps the
single line it has always had. A stop Bambuddy sent is named as ours -- it is
short of its duration too, and on the telemetry alone is indistinguishable from
the firmware abandoning the cycle, so without tracking it the print-takes-priority
stop and the Stop button would both have been blamed on the printer.

Diagnostics only. Nothing about when drying starts or stops has changed, and the
restart loop is not addressed: what the firmware objects to has to be established
before Bambuddy can sensibly decide how long to wait before trying again.
2026-08-05 12:38:45 +02:00
maziggy a74dc7932f Cover nested data structures in the HA notify pass-through (#1441)
The three tests around it use flat scalars, which is also all the field's
placeholder and the wiki showed, so nothing recorded that the value is
forwarded verbatim rather than treated as a key/value list. A reporter asked
whether action buttons work; they always have, and now that is pinned.

The changelog entry said "nested options work" and left it there. It now names
actions and the two things that decide whether the buttons do anything - the
mobile_app_notification_action automation, and iOS needing a registered
category - since neither is set from Bambuddy and both are what a reader would
otherwise have to discover the way the reporter did.
2026-08-05 11:51:30 +02:00