- Expired messages stay readable under a collapsed "Earlier" section, as
long as the feed keeps them (12 months, at most 50). They never count as
unread or raise a banner; withdrawn ones are gone everywhere.
- Each message is one row (level, date, title) that opens in place.
Unread ones carry a dot and a New chip, and opening one is what marks
it read; the banner's Read more opens the panel on its message.
- A fetch that brings a newer feed broadcasts an empty
announcements_changed event, so open pages show the new dot and banner
without a reload.
- The sidebar entry is a megaphone icon with an unread badge, in the
footer row left of System. Footer icons are 32px with no gap so seven
fit an expanded sidebar; with authentication on, logout used to wrap
onto a line of its own.
Fetch a signed feed.json from the public bambuddy-notifications repo on
GitHub at startup and every 6 hours. Nothing about the install is sent;
targeting (version, beta channel, install type) is decided locally.
- Ed25519 against a key built into the app; an older serial is refused so a
withdrawn message can't come back. The feed replaces the stored list, and
a failed or rejected fetch keeps the last good one.
- Sidebar entry above System with an unread count, a slide-over list, and a
banner for unread important/critical messages. Read state per user.
- Admins by default; Settings > General > Updates can show them to all
users or switch them off, which also stops the fetch.
- Plain text only; links to github.com and bambuddy.cool only.
Raise the brace-expansion override from ^5.0.9 to ^5.0.12. Earlier
versions can run out of stack on deeply nested brace groups and take
quadratic time on the {a},b} rewrite. brace-expansion is a dev-only
transitive dependency of eslint (through minimatch) and only expands
the fixed globs in eslint.config.js.
Raise the js-yaml override from ^5.2.3 to ^5.4.1. Versions up to 5.4.0
do not count empty mappings towards maxTotalMergeKeys, so a small YAML
file can keep the CPU busy for a long time. js-yaml is a dev-only
transitive dependency of eslint and only parses our own ESLint configs.
C11 is the P1P, C12 the P1S, C13 the X1E and N7 the P2S, as the virtual
printer and a real P1P 3MF already say. The frontend map had them shifted,
so discovery pre-filled a P1S as a P1P and an X1E as a P2S. The backend
map read C11/C12 as X1C/X1 and lacked N7, the firmware check sent C13 to
the P2S line, and the capability lists never matched BL-P001 because
their lookup strips the dash.
-----
Post work PR #3134
POST /notifications/app-message delivers an app's message to every channel
with the new "Messages from connected apps" switch on (off by default),
through quiet hours, the digest and the log. API keys need the new "Send
notifications" permission, and their owner notifications:update; plain text,
http(s) links, 20 messages a minute per key. The electricity-price door and
this one now share one scoped-key check. /queue?batch=<id> opens and
highlights one batch order.
fix(#1898): keep Telegram link previews, and keep the outcome prompt's failures its own
Four follow-ups to the post-print outcome confirmation merged in #3047.
Telegram: link previews were switched off for every message rather than
only for the outcome prompt, so a print_complete template carrying
{finish_photo_url} lost its photo preview whenever the photo was too
large to attach. _send_telegram now takes link_preview, and only the
prompt turns it off, as the Slack unfurl change already did.
Archives: Reset left the new Unconfirmed filter on, so the list stayed
narrowed and the button seemed to do nothing.
Print start: when the external-print check hit a failed statement, it
rolled back the caller's whole transaction, which expired the printer
and the just-created archive; on an async session the next read of
either raises, and the start notification, energy reading and timelapse
baseline were skipped. The check's reads now run in a savepoint, and a
failed flag write reloads the archive and printer after its rollback.
Print complete: a failed outcome prompt left the notification session
needing a rollback, so the per-user print email sent on it next failed
too. The dispatch now rolls back on failure.
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register
an app with one exact callback URL (Settings > API Keys > Connected Apps);
/connect/authorize asks for consent once and returns a single-use, 60 s code
bound to app, callback and challenge; POST /api/v1/connect/token swaps it,
with the client secret, for the user's identity and permissions. Codes and
secrets stored hashed, exchanges rate-limited per client and IP, no redirect
before the callback is validated, API keys cannot authorize, refused while
auth is disabled. i18n for all 15 locales.
-----
fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup
The #2974 failure-reason conversion ran before the #1378 migration that adds
print_log_entries.failure_reason, so older databases stopped with "no such
column: failure_reason". It now skips a table without the column, only runs
where a legacy label exists, and on SQLite rebuilds archive_fts first, since
archives created before that index existed trip "database disk image is
malformed" when updated.
POST /queue/batches accepts external_source + external_ref; both are
returned on every batch and filterable on GET /queue/batches. The pair is
unique (index uq_print_batches_external), so a retried create answers 409
instead of queueing the same order twice. Migration covers SQLite and
PostgreSQL.
Follow-ups after merging PR #3128 (with the #2990 preview work):
- PDF preview failed on every browser without
Map.prototype.getOrInsertComputed (Chrome < 145, Firefox < 144,
older Safari): "This file cannot be previewed" for any PDF. Load
pdf.js's legacy build, which bundles the polyfills for page and
worker, and bundle the worker through Vite (?worker&url) so
build.target lowers its class static block for Safari 16.0-16.3.
The browser-baseline check only scanned .js output and never saw
the copied .mjs worker; it scans .mjs too.
- PDF grid thumbnails only existed after someone opened the preview.
Page one is now rendered server-side with PDFium (pypdfium2, new
dependency, prebuilt wheels for every shipped platform) on upload,
ZIP extraction and external-folder scans; Generate Thumbnails
backfills PDFs as well. Renders are serialised behind a lock
(PDFium is not thread-safe), run off the event loop, and scale
from the page size so a huge MediaBox cannot allocate a huge
bitmap. Unreadable PDFs land without a thumbnail and keep the
browser fallback.
- A large STEP file takes over a minute to mesh in the browser and
showed only a spinner. STEP loads now show "Converting STEP
model... N s" and a note that large files can take a minute or
more, in all 15 locales.
- Drop the two occt-import-js "externalized for browser
compatibility" build warnings (path/crypto are only required in
its Node branch); any other externalization still shows.
The Print / Schedule dialog's filament mapping is where the colour a slice
asked for is compared against the colour actually loaded, and only the
left-hand side of that comparison had a swatch. The slot, and every slot in
its dropdown, was text -- and the text cannot be trusted: a slot's colour name
is resolved from the Color Catalog or, failing that, from hue, so a
third-party beige is announced as "Orange". A "Color mismatch" warning then
gives no way to tell a real mismatch from two names for the same hex without
opening the printer card in another tab, which on a farm swapping twenty or
thirty non-Bambu colours between machines is a check made many times a day.
Each slot now carries its colour and its hex, and the slot whose colour is
exactly the one the slice asked for is ticked. This works for a slot bound to
an inventory spool and for one configured through Configure Slot or on the
printer itself: the second kind has no inventory row behind it, and the
printer's own tray colour is then what draws. A bound spool contributes what a
tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so
a two-tone or glittery spool draws as itself rather than as its base colour.
The same treatment goes to the filament-override picker used for model-based
assignment. It is the same choice on the other dispatch path, and leaving it
text-only would have made one decision read two ways.
Both controls stop being <select>s to do it, because an <option> renders text
and nothing else. SlotPicker keeps what the select gave for free -- arrow,
Home/End, Enter and Escape keys, listbox semantics, and the border colouring
that encodes match, same-type-different-colour and not-loaded -- and is
portaled with position:fixed so it is not clipped by the dialog's own scroll
container, flipping above the row when there is no room below.
print_archives.library_file_id -> library_files.folder_id ->
library_folders.archive_id -> print_archives. Three nullable SET NULL
links, each reasonable alone, that together made a loop
metadata.sorted_tables could not sort: it dropped those edges, warned on
every backup and every restore, and could return an order placing a
child before its parent -- which once imported library_files ahead of
library_folders and killed a restore on a ForeignKeyViolation.
The restore no longer depends on that order (it strips every foreign key
before importing and adds them back after), but the backup export sorts
the same way, and the warning ends with "may raise an error in a future
release" -- which would break backup and restore on one upgrade.
Marking one edge use_alter removes it from the sort graph, not from the
database: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT, as it
already did for every constraint on these three tables, and SQLite
inlines it into CREATE TABLE, so ON DELETE SET NULL holds on both.
Verified against PostgreSQL 16 and SQLite.
Switching an "Any P2S" job to a specific P2S cleared its filament
override: "Specific Printer" empties the target model and the reset
effect counted that as a model change. Printer mode also matched trays
against the 3MF's colours, never sent the override, and left the old one
on the row.
The reset now compares against the last model actually targeted, so the
switch keeps the override while a real model or plate change still clears
it. Printer-mode tray matching (single, per-plate, multi-printer and the
selector's per-printer editor) runs against the requirements with the
overrides applied, mirroring the scheduler's _apply_filament_overrides; an
entry naming the slot's own filament is not a swap and keeps its
tray_info_idx. Printer-mode submits carry the user's overrides, and the
create endpoint stores them for a printer-targeted job, so a dispatch-time
recompute of an unresolved mapping looks for the same filament.
Saving re-attaches the tray_info_idx an unchanged entry already had, so a
virtual printer's force-colour PLA-variant pin (#2650) survives an edit in
either assignment mode. The printer card's compatibility filter skips
printer-targeted jobs: it mirrors the model scheduler, and hiding a job on
filament would hide it from the printer it is going to run on.
Selecting sliced files for two printer models and asking for 25 copies
queued one item. The queue emptied as soon as it dispatched and the
Batches tab stayed empty, because no batch is created at quantity 1.
A multi-plate file moves the run count off the modal's Quantity field
onto a stepper beside each plate (#342), hiding the field. The
cross-model submit (#671) posts that field, which in this combination
nothing can set, so it stayed at its initial 1. The modal read "19 runs
in total" above a button that queued one.
Per-plate steppers do not fit a cross-model job: its plate is chosen per
candidate, in the alternatives list, so there is one number to give.
Exclude cross-model from the per-plate mode and the global field comes
back.
Drop the plate selector in that mode too. Its choice never reached the
request; it only keyed the filament-requirements query, so picking plate
3 for a candidate while plate 1 stayed ticked above produced overrides
computed from a plate the job would not print. That query now follows
the primary file's own dropdown.
Dispatch needed nothing -- it already gives each copy its own candidate
rows -- but naming did. A cross-model job carries neither archive_id nor
library_file_id, because the candidates are the files, so both branches
that name a batch missed and every such order would have read "Batch" in
the tab the reporter went looking in. Name it after the first candidate.
The existing cross-model tests all mock a single-plate file, which is
why the pair was never covered; the multi-plate case is added.
Bambu Studio files a sliced print on the X2D's internal eMMC, which FTPS
does not serve. The bounded probe found a same-named file on the card --
an earlier slice of the same project, plate 4, against a running plate 1
-- and #1204's guard correctly refused it rather than archive another
plate's thumbnail, filament and cost.
It then blanked subtask_name because swap_plate_suffix returned None. But
None also means "this name carries no plate suffix", and such a name holds
no stale plate number to be wrong about. The project name was dropped, the
row fell through to the gcode_file path, and the archive was titled
plate_1.
Keep the name for the title only. subtask_name itself stays disowned,
because it is what every lookup here is built from and it keys
_active_prints, where the cover endpoint's own download of that same name
would find this archive and hand the contradicted file to
_recover_fallback_archive -- which checks a candidate is a readable 3MF
and never which plate it holds. A corrected name is still registered:
that one points at the plate actually running.
Also name the X2D alongside H2-series and P2S in the Archives banner, the
connection diagnostic and the storage-verdict docs -- it stores slicer
sends the same way, and an X2D owner was told the explanation did not
apply.
The two tag-link routes answered the same conflict differently. The
built-in one said "Tag UID already linked to another active spool" and
named nobody -- while holding the conflicting spool row it had just
loaded -- and Spoolman mode named the spool inside a different English
sentence. Neither was machine-readable, so a client had to parse prose
to learn which spool to look at, and could only do it in one mode.
Both now raise one shared constructor: code tag_already_linked, the
holder's id, and which identifier collided. That is the detail shape
insufficient_filament and printer_connection_failed already use, so
ApiError parses it with no frontend change.
Two active spools can carry one tag -- no unique index on either
column, no conflict check on PATCH /spools/{id}, and /spools/bulk
copies one payload including the tag into every row it creates -- and
the lookup read that with scalar_one_or_none(), which raises on two
rows. The exception escaped into the auth middleware's fail-closed
handler, so the caller was told the authentication service was
unavailable. Both lookups are now ordered and take the first row, as
get_spool_by_tag earlier in the same file always has.
Naming the lowest id means the Spoolman scan reads every row where it
used to stop at its first match, so it now reads extra.tag defensively:
that field is edited outside Bambuddy, and a single null further down
the list would otherwise take the request down in place of the 409.
The kiosk reads the new code: a refused link showed a flat "Failed to
assign spool" and now names the spool holding the tag, reusing the
inventory.tagAlreadyLinked key that no code referenced.
The button gated on tag_uid alone. A spool linked by its 32-character
Bambu tray UUID carries none -- Bambuddy splits a stored tag by length,
so a 32-char value becomes tray_uuid and tag_uid stays empty. In
Spoolman mode that is every Bambu Lab spool synced from the AMS; on the
reporter's instance, 35 of 39 tagged spools, none of which could have
its tag cleared from the dialog. The documented workaround was to edit
extra.tag in Spoolman's own interface.
Everything around the button already treated those spools as tagged.
The Tag ID column renders whichever identifier is present, and the
payload the button sends nulls both fields -- which both inventory
modes honour: the built-in PATCH applies them through exclude_unset,
and the Spoolman route keys its tag-removal branch off either field
being explicitly null.
Either identifier now enables it, and clearing still removes both.
The Finance page was the only surface in Bambuddy that read its currency
from a data row rather than the `currency` setting, and it fell back to EUR
where every other page falls back to USD. One variable drives every amount
on that page, so the personal balance, the cost-center budgets and the whole
transaction list were wrong together on any install not set to euros. It now
takes the configured currency from /settings/ui-flags, which is readable by
anyone who can see Finance -- /settings needs SETTINGS_READ, which a
cost_centers:read_own user does not have.
The backend was the other half. Of the four places that settle on a
currency, three wrote a hardcoded "EUR": the wallet the API mints on demand,
the wallet a print charge mints when none exists, and the balance returned
for a user with no wallet row at all. All four now go through one resolver,
which lives beside the rest of the balance logic.
The wallet's currency column is removed outright rather than merely ignored.
An install has one currency and nothing here converts between them, so a
per-wallet copy could only ever drift from the setting -- and a column
nothing reads is a trap for whoever finds it next. A startup migration drops
it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
otherwise re-add it on an install whose finance tables predate the ORM.
SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
since it has a default and no reader.
Saving settings now invalidates the ui-flags query too. Nothing did, so a
changed currency sat behind that query's staleTime before showing up. The
sponsor prompt's own EUR fallback is now USD, matching AppSettings.
macOS attributes Local Network permission to a code signature and judges a
launchd-spawned process on its own, rather than letting it inherit the grant
of the Terminal that started it. Homebrew ships Python unsigned on Intel, so
there is no identity for the grant to attach to: every connection to a LAN
address is dropped with no error the application can log and no permission
prompt. The printer reads as unreachable and nothing says why, and the entry
in Privacy & Security cannot be made to work because it refers to an identity
that no longer resolves.
install.sh signs during a macOS install; update_macos.sh re-checks on every
update, because `brew upgrade python` installs a fresh unsigned binary under
a new versioned path.
Both sign only what is currently unsigned. That gate is load-bearing: on
arm64 the linker ad-hoc signs every binary and the identity is a hash of the
file, so re-signing would rotate it and revoke a working grant on each update.
A python.org build carries a real Developer ID and must not be downgraded for
the same reason.
The interpreter and the framework's Python.app are both signed. The first is
what sys._base_executable resolves to and what the reporter's TCC log names;
the second is what his fix actually targeted. Which one macOS attributes
could not be established from either, and signing both costs nothing.
-----
fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114)
The port checks reported all three ports unreachable while the subnet check
passed, and port_mqtt's fix text sent the reporter after firewalls and IP
addresses. On a macOS native install that pattern has a cause neither of
those covers: no Local Network grant, denied with no error and no prompt.
A new macos_local_network check, appended on macOS only so no permanently
dimmed row appears for anyone else. It passes when the control port answered,
which is proof the permission is in place and means the signature probe never
runs on a healthy diagnostic. Otherwise it probes the interpreter: an
unsigned one gets the repair that fixes it, a signed one gets System Settings
— the arm64 case, where the identity is a hash of the binary, so a Python
upgrade presents macOS with a new application and strands the old grant.
Always warn, never fail, and only once port_mqtt has already failed, so this
can never be why a green diagnostic turns red. A printer that is simply
switched off produces the same all-ports-dead pattern, which is why the
signature, not the pattern, is what earns the specific advice. An
undeterminable signature is reported as the generic case rather than as
unsigned: that advice rewrites a file in the user's Python installation and
must not be offered on a guess.