From bd6ef8abd63a15c4e1e0175d20816c1c5f62d95a Mon Sep 17 00:00:00 2001 From: Amantux Date: Mon, 20 Jul 2026 18:14:50 +0000 Subject: [PATCH] moveClass: use the real documented bit field (8=unload, not deprecated 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounded in the real Web Services manual (Rev D): moveClass is a bit field — 0=Normal, 2=Import, 4=Export, 8=Unload, 16=No-Eject, 32=Closest-slot — and the single-integer 3=Unload is DEPRECATED. The emulator/client used 3. - MoveClass: real bit values (was auto() -> 1,2,4,8). from_wire parses the bit field (8/16/24/32...), still accepts the deprecated 3 as unload, and ignores unknown bits (never silently unload). to_wire emits the documented 8, not 3. - scalar_http client unload() now sends moveClass=8 (the real flag) with the drive source + target-slot hint — faithful to the appliance, verified against the emulator. - moveMedium route unload branch honors a supplied destination slot (else picks an empty/home slot), so the client's target-slot hint is preserved while moveClass=8 is the trigger. - Unit tests updated to the real values + deprecated-3 acceptance + unknown-bit safety. Emulator-as-reference, verified end-to-end: 24 moveClass/scalar_http tests + 56 AML operations/diagnostics tests green. Deprecated 3 stays accepted for back-compat. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_012K5Zjc2bggcyJtFbz2D4Pw --- openblade/api/routes_aml_move_medium.py | 6 ++- openblade/domain/scalar_coordinate.py | 54 ++++++++++--------- .../hardware/scalar_http/library_backend.py | 8 ++- tests/unit/test_scalar_coordinate.py | 23 +++++--- 4 files changed, 57 insertions(+), 34 deletions(-) diff --git a/openblade/api/routes_aml_move_medium.py b/openblade/api/routes_aml_move_medium.py index ce57641..4792452 100644 --- a/openblade/api/routes_aml_move_medium.py +++ b/openblade/api/routes_aml_move_medium.py @@ -94,7 +94,11 @@ async def move_medium( raise HTTPException( status_code=422, detail="Unload move requires a drive source" ) - result = context.library.unload(source_address, _first_empty_slot(context)) + # Real moveClass=8 unload takes the drive source only; if a destination + # slot is supplied (client hint), honor it, else the library picks an + # empty/home slot (Web Services manual). + target_slot = destination[1] if destination is not None else _first_empty_slot(context) + result = context.library.unload(source_address, target_slot) elif destination is None: raise HTTPException(status_code=422, detail="destinationCoordinate is required") else: diff --git a/openblade/domain/scalar_coordinate.py b/openblade/domain/scalar_coordinate.py index dff227c..6eddea6 100644 --- a/openblade/domain/scalar_coordinate.py +++ b/openblade/domain/scalar_coordinate.py @@ -19,7 +19,7 @@ from __future__ import annotations from dataclasses import dataclass -from enum import IntFlag, auto +from enum import IntFlag @dataclass(frozen=True) @@ -64,19 +64,25 @@ def from_dict(cls, data: dict[str, object]) -> ScalarCoordinate: raise ValueError(f"invalid ScalarCoordinate dict: {data!r}") from exc +# Deprecated single-integer "Unload" (Web Services manual marks moveClass=3 as +# deprecated in favour of the bit field). The emulator historically used it; still +# accepted on input for backward compatibility. +_DEPRECATED_UNLOAD_WIRE = 3 + + class MoveClass(IntFlag): - """moveMedium classification, modeled as the documented bit field. + """moveMedium classification — the real documented bit field. - NORMAL is the empty flag. IMPORT/EXPORT/UNLOAD/NO_EJECT are independent bits so - documented combinations compose (e.g. ``UNLOAD | NO_EJECT``). See the module - docstring: bit STRUCTURE is faithful; wire VALUES are certified separately. + Values are from the Quantum Web Services manual (6-68185-01 Rev D); see + docs/reference/i3-contract-notes.md. Flags compose, e.g. ``24 = UNLOAD | NO_EJECT``. """ NORMAL = 0 - IMPORT = auto() - EXPORT = auto() - UNLOAD = auto() - NO_EJECT = auto() + IMPORT = 2 + EXPORT = 4 + UNLOAD = 8 + NO_EJECT = 16 + CLOSEST_SLOT = 32 @property def is_unload(self) -> bool: @@ -84,20 +90,20 @@ def is_unload(self) -> bool: @classmethod def from_wire(cls, value: int) -> MoveClass: - """Map an emulator-native moveClass integer to the structured flag.""" - # Unknown integers fall back to NORMAL; callers needing strictness validate. - return _LEGACY_MOVECLASS_WIRE.get(int(value), cls.NORMAL) + """Parse a moveClass integer into the structured flag. + + Accepts the real bit field (8=unload, 16=no-eject, 24=8+16, 32=closest, …) + and the deprecated single-integer ``3`` (=unload). Unknown bits are ignored. + """ + value = int(value) + if value == _DEPRECATED_UNLOAD_WIRE: + return cls.UNLOAD + result = cls.NORMAL + for flag in (cls.IMPORT, cls.EXPORT, cls.UNLOAD, cls.NO_EJECT, cls.CLOSEST_SLOT): + if value & flag.value: + result |= flag + return result def to_wire(self) -> int: - """Map a structured flag back to the emulator-native integer.""" - for wire, flag in _LEGACY_MOVECLASS_WIRE.items(): - if self == flag: - return wire - return 0 - - -# The single certification point: OpenBlade emulator's CURRENT moveClass integers. -# UNVERIFIED vs a real i3 (the external review indicates the real "unload" value is -# a distinct bit value, not 3). Replace once a captured compatibility case certifies -# the real mapping — no call site changes needed. -_LEGACY_MOVECLASS_WIRE: dict[int, MoveClass] = {0: MoveClass.NORMAL, 3: MoveClass.UNLOAD} + """The documented integer for this flag (bit field). Unload -> 8, not the deprecated 3.""" + return int(self) diff --git a/openblade/hardware/scalar_http/library_backend.py b/openblade/hardware/scalar_http/library_backend.py index 539871b..546fff8 100644 --- a/openblade/hardware/scalar_http/library_backend.py +++ b/openblade/hardware/scalar_http/library_backend.py @@ -22,6 +22,7 @@ OperationResult, SlotState, ) +from openblade.domain.scalar_coordinate import MoveClass from openblade.hardware.scalar_http.errors import ScalarHttpError from openblade.hardware.scalar_http.session import ScalarHttpSession @@ -162,8 +163,13 @@ def load(self, source_slot: int, drive_id: int) -> OperationResult: ) def unload(self, drive_id: int, target_slot: int) -> OperationResult: + # Real i3 unload uses moveClass=8 (bit field) with the drive source; the + # target slot is sent as a hint (Web Services manual). See + # docs/reference/i3-contract-notes.md. return self._move_medium( - self._coordinate("drive", drive_id), self._coordinate("slot", target_slot) + self._coordinate("drive", drive_id), + self._coordinate("slot", target_slot), + move_class=MoveClass.UNLOAD.to_wire(), ) def move(self, source_slot: int, target_slot: int) -> OperationResult: diff --git a/tests/unit/test_scalar_coordinate.py b/tests/unit/test_scalar_coordinate.py index f99fd45..9bcb326 100644 --- a/tests/unit/test_scalar_coordinate.py +++ b/tests/unit/test_scalar_coordinate.py @@ -33,15 +33,22 @@ def test_moveclass_is_a_composable_bit_field() -> None: assert not (MoveClass.IMPORT & MoveClass.EXPORT) -def test_moveclass_wire_mapping_round_trips_current_emulator_values() -> None: - # Emulator-native values (0 normal, 3 unload) — the certification point. +def test_moveclass_real_documented_bit_values() -> None: + # Values from the Web Services manual (Rev D): unload=8, no-eject=16, closest=32. + assert (MoveClass.IMPORT, MoveClass.EXPORT, MoveClass.UNLOAD, MoveClass.NO_EJECT, + MoveClass.CLOSEST_SLOT) == (2, 4, 8, 16, 32) + assert MoveClass.from_wire(8).is_unload + assert MoveClass.from_wire(8).to_wire() == 8 # sends 8, not the deprecated 3 + assert MoveClass.from_wire(24) == (MoveClass.UNLOAD | MoveClass.NO_EJECT) # 8+16 assert MoveClass.from_wire(0) == MoveClass.NORMAL - assert MoveClass.from_wire(3).is_unload - assert MoveClass.from_wire(3).to_wire() == 3 assert MoveClass.NORMAL.to_wire() == 0 -def test_moveclass_unknown_wire_value_falls_back_to_normal() -> None: - # An uncertified/unknown integer must not be silently treated as unload. - assert MoveClass.from_wire(8) == MoveClass.NORMAL - assert not MoveClass.from_wire(8).is_unload +def test_moveclass_accepts_deprecated_unload_and_ignores_unknown_bits() -> None: + # The deprecated single-integer 3 (=unload) is still accepted on input. + assert MoveClass.from_wire(3).is_unload + # But its canonical wire form is the bit-field 8, not 3. + assert MoveClass.from_wire(3).to_wire() == 8 + # An unknown bit (1) that maps to no flag must NOT be treated as unload. + assert MoveClass.from_wire(1) == MoveClass.NORMAL + assert not MoveClass.from_wire(1).is_unload