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.
This commit is contained in:
maziggy
2026-08-15 14:36:57 +02:00
5 changed files with 263 additions and 6 deletions
+19
View File
@@ -1364,6 +1364,25 @@ class BambuMQTTClient:
# Intercept request-topic messages (print commands from slicer/Bambuddy)
if msg.topic == self.topic_publish:
# Record it before returning. This topic carries every command
# travelling *to* the printer, including the ones Bambu Studio
# sends, and it used to be the one thing an MQTT capture could
# never show -- which is why "what does Studio put in the drying
# command?" had no answer from a user's log (#2774). Filed as
# "out" so the direction filter groups it with our own commands
# rather than with printer telemetry; anything sent through
# send_command lands twice, once on publish and once on the
# broker's echo, and the pair is itself evidence the command
# reached the broker.
if self._logging_enabled:
self._message_log.append(
MQTTLogEntry(
timestamp=datetime.now(timezone.utc).isoformat(),
topic=msg.topic,
direction="out",
payload=payload,
)
)
self._handle_request_message(payload)
return
@@ -1987,6 +1987,99 @@ class TestRequestTopicFailSafe:
assert BambuMQTTClient._request_topic_cache["TEST_REJECT"] is False
class TestRequestTopicIsCaptured:
"""The MQTT debug log has to show commands going *to* the printer.
Bambuddy subscribes to the request topic as well as the report topic, so
every command the printer is given crosses this client -- ours echoed back
by the broker, and whatever Bambu Studio sends. Those messages used to
return from _on_message before the logging block, which left a capture able
to prove only what the printer said and never what it was told. Answering
"what does Studio put in this field?" from a user's log depends on it
(#2774).
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
client.enable_logging(True)
return client
@staticmethod
def _deliver(client, topic, payload):
class _Msg:
pass
msg = _Msg()
msg.topic = topic
msg.payload = json.dumps(payload).encode()
client._on_message(None, None, msg)
def test_a_command_on_the_request_topic_is_logged(self, mqtt_client):
payload = {
"print": {
"command": "ams_filament_drying",
"ams_id": 128,
"temp": 45,
"duration": 12,
"filament": "PLA",
}
}
self._deliver(mqtt_client, mqtt_client.topic_publish, payload)
logs = mqtt_client.get_logs()
assert len(logs) == 1
assert logs[0].topic == mqtt_client.topic_publish
# Filed with the commands rather than with telemetry: the direction
# filter is how someone finds what was sent to the printer.
assert logs[0].direction == "out"
assert logs[0].payload == payload
def test_the_payload_is_kept_whole(self, mqtt_client):
"""The point of the capture is the fields we don't parse."""
payload = {"print": {"command": "ams_filament_drying", "unparsed_field": "keep me"}}
self._deliver(mqtt_client, mqtt_client.topic_publish, payload)
assert mqtt_client.get_logs()[0].payload["print"]["unparsed_field"] == "keep me"
def test_request_topic_messages_are_still_parsed(self, mqtt_client):
"""Logging is additive -- the ams_mapping capture must survive it."""
self._deliver(
mqtt_client,
mqtt_client.topic_publish,
{"print": {"command": "project_file", "ams_mapping": [0, 4, -1, -1]}},
)
assert mqtt_client._captured_ams_mapping == [0, 4, -1, -1]
assert len(mqtt_client.get_logs()) == 1
def test_nothing_is_logged_while_logging_is_off(self, mqtt_client):
mqtt_client.enable_logging(False)
self._deliver(
mqtt_client,
mqtt_client.topic_publish,
{"print": {"command": "project_file", "ams_mapping": [0, -1, -1, -1]}},
)
assert mqtt_client.get_logs() == []
assert mqtt_client._captured_ams_mapping == [0, -1, -1, -1]
def test_the_report_topic_is_still_logged_as_incoming(self, mqtt_client):
"""Telemetry keeps its direction -- the two must stay distinguishable."""
self._deliver(mqtt_client, mqtt_client.topic_subscribe, {"print": {"gcode_state": "IDLE"}})
logs = mqtt_client.get_logs()
assert len(logs) == 1
assert logs[0].direction == "in"
class TestRequestTopicAmsMapping:
"""Tests for capturing ams_mapping from the MQTT request topic."""
@@ -0,0 +1,97 @@
/**
* The drying popover has to name a material the printer will recognise.
*
* It seeds two things from one lookup: the temperature it prefills, and the
* filament name the start command carries. The dropdown rendering that name
* falls back to its first option when the value isn't in its list -- silently
* -- so a lookup that can return something outside the table shows one material
* and sends another. An AMS-HT holding Support for PLA/PETG displayed PLA and
* told the printer PLA-S (#2774). These tests pin the invariant that closes
* that: whatever comes back is a key the table has.
*/
import { describe, it, expect } from 'vitest';
import { resolveDryingPresetKey, type DryingPreset } from '../../utils/dryingPresets';
// The shipped table, as PrintersPage.tsx defines it.
const PRESETS: Record<string, DryingPreset> = {
'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
'ABS': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
'ASA': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
'PA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
'PC': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
'PVA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
};
describe('resolveDryingPresetKey', () => {
it('always answers with a key the table has', () => {
// The invariant behind #2774. Anything outside the table reaches the
// dropdown as a value it will not display and the printer as a material
// the user never chose.
const trayTypes = [
'PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA',
'PLA-S', 'PLA-CF', 'PETG-CF', 'PET-CF', 'ABS-GF', 'ASA-CF', 'PAHT-CF',
'PA6-CF', 'PPS-CF', 'PPA-CF', 'HIPS', 'PP', 'PE', 'EVA', 'PHA', 'PCTG',
'Nylon', 'TPU for AMS', '', ' ', 'wildly unknown',
];
for (const trayType of trayTypes) {
expect(PRESETS).toHaveProperty(resolveDryingPresetKey(trayType, PRESETS));
}
});
it('passes a listed material straight through', () => {
expect(resolveDryingPresetKey('PETG', PRESETS)).toBe('PETG');
expect(resolveDryingPresetKey('PVA', PRESETS)).toBe('PVA');
});
it('is case-insensitive and ignores a trailing qualifier', () => {
expect(resolveDryingPresetKey('petg', PRESETS)).toBe('PETG');
expect(resolveDryingPresetKey('TPU for AMS', PRESETS)).toBe('TPU');
});
it('dries a support material as its base', () => {
// Support for PLA/PETG reports as PLA-S -- the case from the report.
expect(resolveDryingPresetKey('PLA-S', PRESETS)).toBe('PLA');
});
it('dries a composite as its base rather than defaulting to PLA', () => {
// The reason the suffix is stripped instead of just falling back: PETG-CF
// wants PETG's 65 degrees, and landing on PLA's 45 would quietly waste the
// cycle.
expect(resolveDryingPresetKey('PETG-CF', PRESETS)).toBe('PETG');
expect(resolveDryingPresetKey('PLA-CF', PRESETS)).toBe('PLA');
expect(resolveDryingPresetKey('ABS-GF', PRESETS)).toBe('ABS');
expect(resolveDryingPresetKey('ASA-CF', PRESETS)).toBe('ASA');
});
it('recognises the polyamide family under its own spellings', () => {
expect(resolveDryingPresetKey('PA6-CF', PRESETS)).toBe('PA');
expect(resolveDryingPresetKey('PAHT-CF', PRESETS)).toBe('PA');
expect(resolveDryingPresetKey('Nylon', PRESETS)).toBe('PA');
});
it('falls back to the coolest row for an unknown material', () => {
// Under-drying an exotic filament wastes a cycle; PA's 85 degrees would
// deform a PLA spool. So an unrecognised material must never inherit a
// hotter row than PLA's.
for (const unknown of ['PPS-CF', 'PEEK', 'wildly unknown']) {
expect(resolveDryingPresetKey(unknown, PRESETS)).toBe('PLA');
}
});
it('handles an empty tray', () => {
// No spool loaded -- the popover still has to open on something.
expect(resolveDryingPresetKey(undefined, PRESETS)).toBe('PLA');
expect(resolveDryingPresetKey(null, PRESETS)).toBe('PLA');
expect(resolveDryingPresetKey('', PRESETS)).toBe('PLA');
});
it('respects a custom preset table', () => {
// Users can override the table from settings, so the lookup answers about
// the table it was handed, not the shipped one.
const custom = { ...PRESETS, 'PLA-S': { n3f: 55, n3s: 55, n3f_hours: 8, n3s_hours: 8 } };
expect(resolveDryingPresetKey('PLA-S', custom)).toBe('PLA-S');
});
});
+9 -6
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { compareFwVersions } from '../utils/firmwareVersion';
import { formatPrintName } from '../utils/printName';
import { computePopoverPosition } from '../utils/popoverPosition';
import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets';
import {
isExternalSpoolHidden,
setExternalSpoolHidden as persistExternalSpoolHidden,
@@ -1781,7 +1782,7 @@ export function AmsNameHoverCard({
// AMS drying presets from BambuStudio filament profiles (idle mode temps)
// Format: { n3f temp, n3s temp, n3f hours, n3s hours }
const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
const DRYING_PRESETS: Record<string, DryingPreset> = {
'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
@@ -1882,7 +1883,7 @@ function PrinterCard({
cameraViewMode?: 'window' | 'embedded';
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
checkPrinterFirmware?: boolean;
dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
dryingPresets?: Record<string, DryingPreset>;
requirePlateClear?: boolean;
selectionMode?: boolean;
isSelected?: boolean;
@@ -4879,8 +4880,9 @@ function PrinterCard({
setDryingPopoverAmsId(null);
} else {
const firstTray = ams.tray.find(t => t.tray_type);
const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
const preset = dryingPresets[filType] || dryingPresets['PLA'];
const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
// Only reachable if a custom preset set dropped PLA itself.
const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
const moduleType = ams.module_type as 'n3f' | 'n3s';
setDryingFilament(filType);
setDryingTemp(preset[moduleType] || preset.n3f);
@@ -5429,8 +5431,9 @@ function PrinterCard({
setDryingPopoverAmsId(null);
} else {
const firstTray = ams.tray.find(t => t.tray_type);
const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
const preset = dryingPresets[filType] || dryingPresets['PLA'];
const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
// Only reachable if a custom preset set dropped PLA itself.
const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
const moduleType = ams.module_type as 'n3f' | 'n3s';
setDryingFilament(filType);
setDryingTemp(preset[moduleType] || preset.n3f);
+45
View File
@@ -0,0 +1,45 @@
// Resolving an AMS tray's material to a row in the drying preset table.
//
// The table itself lives with the UI that renders it; only the lookup is here,
// so it can be exercised without dragging a page module into the test.
export type DryingPreset = { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number };
// Materials whose AMS spelling differs from the preset table's key. Bambu
// labels nylon "PA" while its own composites spell the family out, so PA6 and
// PAHT would otherwise miss a table that has a perfectly good PA row.
const DRYING_MATERIAL_ALIASES: Record<string, string> = {
'NYLON': 'PA',
'PA6': 'PA',
'PAHT': 'PA',
};
/**
* Pick the preset key for a tray's material.
*
* The answer is always a key the table actually has, which is the whole point:
* the drying popover seeds both the temperature and the filament name the start
* command carries from this, and the dropdown silently falls back to its first
* option when handed a value that isn't in its list. Seeding it with a raw
* `tray_type` therefore displayed "PLA" while sending the raw string -- an
* AMS-HT holding Support for PLA/PETG (`tray_type` "PLA-S") showed PLA in the
* dropdown and told the printer PLA-S (#2774).
*
* `tray_type` carries plenty of spellings the table doesn't list: support
* materials (PLA-S) and composites (PETG-CF, PLA-CF, ABS-GF, PAHT-CF) all dry
* as their base material, so the suffix is dropped before giving up. Anything
* still unrecognised lands on PLA, deliberately the coolest row -- under-drying
* an exotic filament wastes a cycle, where defaulting to PA's 85 degrees would
* deform a PLA spool.
*/
export function resolveDryingPresetKey(
trayType: string | null | undefined,
presets: Record<string, DryingPreset>,
): string {
const raw = (trayType || '').split(' ')[0].toUpperCase();
for (const candidate of [raw, raw.split('-')[0]]) {
const key = DRYING_MATERIAL_ALIASES[candidate] ?? candidate;
if (presets[key]) return key;
}
return 'PLA';
}