diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index c3afa6c60..8bad15f2b 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -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 diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index c36acf1bc..b011d1c44 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -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.""" diff --git a/frontend/src/__tests__/utils/dryingPresets.test.ts b/frontend/src/__tests__/utils/dryingPresets.test.ts new file mode 100644 index 000000000..43b008ec9 --- /dev/null +++ b/frontend/src/__tests__/utils/dryingPresets.test.ts @@ -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 = { + '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'); + }); +}); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 2b87a325a..1e0885005 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -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 = { +const DRYING_PRESETS: Record = { '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; + dryingPresets?: Record; 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); diff --git a/frontend/src/utils/dryingPresets.ts b/frontend/src/utils/dryingPresets.ts new file mode 100644 index 000000000..f63cbd0d6 --- /dev/null +++ b/frontend/src/utils/dryingPresets.ts @@ -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 = { + '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 { + 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'; +}