Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion openblade/api/routes_aml_move_medium.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +97 to +101
elif destination is None:
raise HTTPException(status_code=422, detail="destinationCoordinate is required")
else:
Expand Down
54 changes: 30 additions & 24 deletions openblade/domain/scalar_coordinate.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from __future__ import annotations

from dataclasses import dataclass
from enum import IntFlag, auto
from enum import IntFlag


@dataclass(frozen=True)
Expand Down Expand Up @@ -64,40 +64,46 @@ 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:
return bool(self & MoveClass.UNLOAD)

@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
Comment on lines +98 to +105

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)
8 changes: 7 additions & 1 deletion openblade/hardware/scalar_http/library_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
23 changes: 15 additions & 8 deletions tests/unit/test_scalar_coordinate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +52 to +54
Loading