Просмотр исходного кода

Merge branch 'dev' into feature/2656-restore-from-github

MartinNYHC 1 месяц назад
Родитель
Сommit
6cd81fcd85

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 19 - 0
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
 

+ 93 - 0
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."""
 

+ 97 - 0
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<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
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<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 - 0
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<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';
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-joRUZURS.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-xxgQH7Sb.js"></script>
+    <script type="module" crossorigin src="/assets/index-joRUZURS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Db2rfQf-.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов