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

This commit is contained in:
MartinNYHC
2026-08-06 08:23:37 +02:00
committed by GitHub
8 changed files with 9027 additions and 7 deletions
+2
View File
@@ -12,11 +12,13 @@ All notable changes to Bambuddy will be documented in this file.
- **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests.
### Changed
- **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests.
- **The L and XL printer cards now scale their text and icons, not just their width (#1848, reporter @misterff1)** — Switching a card from M to XL made it wider, enlarged the printer name and the thumbnail, and left everything else exactly as it was: the AMS slot labels, temperatures, filament names, status text and every small button stayed pinned between 8 and 11 pixels, well under the smallest size used anywhere else in Bambuddy. The result was a full-width card carrying the same tiny text as the compact one, which is precisely the opposite of what someone reaching for a bigger card is asking for. Browser zoom is not an answer to this, since it enlarges the entire page and so preserves the very disparity being complained about. The card body now scales along with the card: L draws it 20% larger and XL 40% larger, icons included, so the controls grow with the text rather than staying fiddly to hit. The AMS-HT card needed two adjustments of its own, since its temperature and humidity readings sit beside the slot rather than under it. Its single slot was the only thing on that row able to grow, so it swallowed every spare pixel and pushed the readings hard against the card's edge — it is now capped at roughly two ordinary slots, which keeps them clear at any card width. The card itself also gained a ceiling of one full AMS card's width, so a unit that wraps onto a line of its own no longer stretches that single slot across the whole card. S and M are deliberately untouched — S is the dense fleet view where density is the point, and M is the default, so an existing install looks identical until you reach for a size that is already asking for more room. Wiki updated. Covered by frontend tests.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
- **The drying popover no longer starts a cycle under a material you did not pick (#2774)** — An AMS-HT loaded with Support for PLA/PETG offered PLA in the drying dialog's filament list, and the cycle that started was labelled Support for PLA/PETG on the printer's own screen. Opening the dialog prefills it from the spool that is loaded, and Bambu reports that spool's material as `PLA-S` — a name Bambuddy's table of drying temperatures does not carry. The temperature and duration fell back to PLA's 45°C for twelve hours, which is what the dialog showed and what was sent, but the material did not fall back with them: it stayed `PLA-S`, and the dropdown, handed a value that is not one of its options, displays its first option without saying so. So the list read PLA while `PLA-S` was what left for the printer. Anyone who opened the dialog and pressed **Start** without touching the material was affected; picking any entry from the list, even the same one it was already showing, made the two agree again. The same gap covered every composite — a spool of PETG-CF, PLA-CF, ABS-GF or PAHT-CF prefilled the dialog at PLA's 45°C, far short of what those materials want, and sent its full name as the material. Bambuddy now resolves a spool's material to an entry the list actually has before either value is set, so what the dialog shows and what the printer is told can no longer disagree. Support materials and composites resolve to the material they are built on, so PLA-S dries as PLA and PETG-CF as PETG at 65°C, and nylon is recognised under the several spellings Bambu gives it. Anything genuinely unrecognised still falls back to PLA, deliberately the coolest setting in the table — under-drying an exotic filament costs a cycle, where defaulting to the hottest would deform a PLA spool. This does not address the other half of that report: a printer that keeps showing the material set from its own screen even when Bambuddy names a different one, which needs a capture of what Bambu Studio sends before anything can sensibly be changed. Covered by frontend tests.
- **Configuring an AMS slot shows up on the printer card straight away, without a page reload** — Setting a slot to a different filament from the printer card left the card showing the old one. Nothing was lost: the command reached the printer, the printer applied it, and reloading the page or waiting out the thirty-second fallback poll showed the new filament. It simply never arrived on its own. Bambuddy compares each status push from a printer against the last one it broadcast and stays quiet when nothing has changed, which is what keeps a machine mid-print from flooding every open browser tab several times a second. The comparison looked at each AMS tray's slot number, material and load state — and **Configure Slot** writes none of those. It writes the filament id, the colour, the profile name and the calibration profile. So changing PLA to a different brand or colour of PLA produced a push that looked identical to its predecessor and was discarded, while changing PLA to PETG came through immediately because the material had moved. That is also why **Reset** always worked: it clears the material. The comparison now covers the filament identity as well, so every kind of slot change reaches the card. Those fields only move when someone configures a slot or swaps a spool, so this adds no traffic during a print — the amount of filament left, which does tick down continuously, is still deliberately excluded. Covered by backend tests.
- **"Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130)** — A fleet of five X2Ds with no AMS units, each printing PETG from its external spool holder, accepted a job sent to a named printer and refused the same job sent to **Any X2D**: the file uploaded, the printer answered "Failed to get AMS mapping table", and after three attempts the queue item failed. The two paths differ in one thing. A job queued for a named printer carries a filament mapping the browser worked out at the time you queued it; a job queued for a model has no printer yet, so the scheduler has to work the mapping out at dispatch — and its copy of that logic could not see an external spool on a dual-nozzle printer. On an X2D or H2D each filament in the sliced file names the nozzle it feeds, and Bambuddy will only offer a spool to the nozzle it is physically plumbed to. Which nozzle an external spool feeds was being read off a table the printer builds from its AMS units, so a printer with no AMS published an empty table, every external spool came back belonging to no nozzle at all, and the per-nozzle check discarded the only filament on the machine. Nothing matched, and the print went out claiming to use an AMS while carrying no mapping — which is the message the firmware was objecting to. The left and right external feeds identify themselves well enough to be routed without that table, and the printer reports its two nozzles directly, so both are now used. This is the same fault that was corrected in the browser last May for exactly this hardware; the scheduler kept the old logic, which is why the browser-resolved mapping worked and the scheduler-resolved one did not. Single-nozzle printers are untouched — they have no nozzle to route to and never took this branch. Separately, a job whose filament genuinely cannot be matched on a printer with no AMS now fails immediately and says which filament is missing and which nozzle wants it, instead of uploading several megabytes, collecting the firmware's error and failing anyway two retries later; where there *is* an AMS the firmware error still stands, because there the job can be recovered by loading a spool and pressing **Resume**. Covered by backend tests.
- **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests.
+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';
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>