mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user