diff --git a/compatibility/scalar-i3-341g/aml/elements-list-authed.json b/compatibility/scalar-i3-341g/aml/elements-list-authed.json index 830f815..fcc6f5b 100644 --- a/compatibility/scalar-i3-341g/aml/elements-list-authed.json +++ b/compatibility/scalar-i3-341g/aml/elements-list-authed.json @@ -5,7 +5,13 @@ "firmware": "341G", "auth": "admin", "manual_ref": "Web Services Guide Rev D, Table 212 (physicalLibrary/elements)", - "fidelity_notes": "SUSPECT COORDINATE SHAPE: with a session the emulator returns element coordinates as a reduced 3-tuple STRING (e.g. '1,1,1'), even though aml_state holds full {frame,rack,section,column,row,type} dicts. The real i3 exposes the full physical coordinate. Modeled now as ScalarCoordinate (openblade/domain/scalar_coordinate.py); applying it to this response is a wire change that must be certified against a capture + re-verified vs the i3 parity tests.", - "request": { "method": "GET", "path": "/aml/physicalLibrary/elements" }, - "expected": { "status": 200, "json_contains": { } } + "fidelity_notes": "Element coordinates now return the FULL physical object {frame,rack,section,column,row,type} per the Web Services manual (Figure 23), matching ScalarCoordinate \u2014 the reduced 3-tuple string is gone. Values (section=bay/module mapping) are the emulator's geometry, not appliance-certified; the STRUCTURE is doc-faithful (see docs/reference/i3-contract-notes.md).", + "request": { + "method": "GET", + "path": "/aml/physicalLibrary/elements" + }, + "expected": { + "status": 200, + "json_contains": {} + } } diff --git a/docs/reference/i3-contract-notes.md b/docs/reference/i3-contract-notes.md new file mode 100644 index 0000000..3e9d00e --- /dev/null +++ b/docs/reference/i3-contract-notes.md @@ -0,0 +1,58 @@ +# Certified i3 contract values (from the Quantum Web Services manual) + +These are the real documented values the OpenBlade emulator + client target, read +from the vendor manual (**Scalar i6k/i3/i6 RESTful Web Services API, 6-68185-01 +Rev D**). The manual itself is copyrighted and gitignored (see +`docs/reference/quantum/`); this file records only the specific values we +implement, with citations — never the vendor text. + +Source it locally with `docs/reference/quantum/fetch.sh` to re-verify. + +## Element / segment coordinate (Figure 23: coordinate) + +A coordinate is an **object**, not a reduced string: + +``` +{ "frame": 1, "rack": 1, "section": 2, "column": 1, "row": 1, "type": 4 } +``` + +- `section` — the module. +- `column` — magazine position (per the manual's magazine-eject description, column + 1 = left magazine … column 10 = right magazine). +- `row` — slot within the addressed unit. +- `type` — element type code (e.g. 2 = storage). + +OpenBlade's `ScalarCoordinate` (`openblade/domain/scalar_coordinate.py`) already +matches this shape. The emulator historically reduced element coordinates to a +`"frame,bay,slot"` string; the faithful form is the full object. + +## moveClass (moveMedium) — bit field + +The current `moveClass` is a **bit field** (the older single-integer form with +`3 = Unload` is explicitly **deprecated** in the manual): + +| Value | Meaning | +|---|---| +| 0 | Normal move (source + destination coordinates required) | +| 2 | Import (source only; IE slot in a partition) — *not supported on i3/i6* | +| 4 | Export (source only; storage slot in a partition) — *not supported on i3/i6* | +| 8 | Unload drive (source coordinate of the drive only; move to home/empty slot) | +| 16 | No-Eject (flag; combine with a move/unload, e.g. `24 = 8+16`) | +| 32 | Move to closest slot (combine with unload `8`) | +| ~~3~~ | **Deprecated** single-integer "Unload" — the emulator's legacy value | + +So `OpenBlade` should send/accept `8` for unload (bit field), keeping `3` accepted +for backward compatibility with the emulator's legacy behavior. + +## Timing / async + +- Robotic operations are **asynchronous**: `202 Accepted` + poll for completion. +- `estimatedCompletionTime` is reported **in minutes** (e.g. for scan sessions). +- Session timeout is configurable (`sessionTimeout`; `localAccessTimeout` / + `remoteAccessTimeout` per interface). + +**Not in this manual:** tape data-transfer rates and per-move robotics durations. +The Web Services guide is a *control-plane* API spec; drive/robotics performance +figures live in hardware datasheets, not here. The emulator's latency profiles +therefore remain OpenBlade-chosen approximations, not doc-certified numbers — do +not present them as vendor-specified. diff --git a/openblade/api/routes_aml_library.py b/openblade/api/routes_aml_library.py index 58e6269..1e2df56 100644 --- a/openblade/api/routes_aml_library.py +++ b/openblade/api/routes_aml_library.py @@ -14,6 +14,7 @@ from openblade.bootstrap import AppContext, get_context from openblade.catalog.models import AmlUser from openblade.domain.models import CartridgeState, ChangerState, DriveState +from openblade.domain.scalar_coordinate import ScalarCoordinate router = APIRouter() @@ -72,7 +73,9 @@ class PhysicalLibraryUpdateRequest(BaseModel): class ElementResource(BaseModel): type: str address: int - coordinate: str | None = None + # Full physical coordinate object {frame,rack,section,column,row,type} per the + # Web Services manual (Figure 23) — no longer a reduced string. + coordinate: dict[str, int] | None = None state: str barcode: str | None = None @@ -392,7 +395,17 @@ def _ws_result(summary: str) -> WSResultCode: return WSResultCode(summary=summary) -def _slot_coordinate(slot_id: int) -> str: +_STORAGE_ELEMENT_TYPE = 2 # Web Services manual: coordinate type 2 = storage + + +def _slot_coordinate(slot_id: int) -> dict[str, int]: + """Full physical element coordinate for a storage slot. + + The real i3 returns a coordinate OBJECT (frame/rack/section/column/row/type), + not a reduced string (Web Services Guide Rev D, Figure 23). The emulator maps + its tower/bay geometry onto that shape: section = bay/module, row = slot within + the module. See docs/reference/i3-contract-notes.md. + """ remaining = slot_id towers = sorted( aml_state.get_aml_towers().values(), @@ -402,9 +415,14 @@ def _slot_coordinate(slot_id: int) -> str: bay = int(tower.get("bay", 1)) slots = int(tower.get("slots", 0)) if remaining <= slots: - return f"1,{bay},{remaining}" + return ScalarCoordinate( + frame=1, rack=1, section=bay, column=1, row=remaining, + element_type=_STORAGE_ELEMENT_TYPE, + ).to_dict() remaining -= slots - return f"1,1,{slot_id}" + return ScalarCoordinate( + frame=1, rack=1, section=1, column=1, row=slot_id, element_type=_STORAGE_ELEMENT_TYPE + ).to_dict() def _serialize_task(item: dict[str, Any]) -> Task: diff --git a/pyproject.toml b/pyproject.toml index 18c7aa7..fd1abe9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,10 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "B", "UP", "A", "SIM"] -ignore = ["A004", "B008", "B017", "E402", "E501", "UP017", "UP042", "UP047"] +# A002 (argument shadows a builtin) is ignored: FastAPI path/query params like +# `id`, `type`, `filter` ARE the wire contract — renaming them would break the +# /aml endpoints (see docs/tech-debt.md). +ignore = ["A002", "A004", "B008", "B017", "E402", "E501", "UP017", "UP042", "UP047"] [tool.ruff.lint.per-file-ignores] "tests/hardware/*" = ["SIM105"] diff --git a/tests/integration/test_aml_diagnostics.py b/tests/integration/test_aml_diagnostics.py index 3114011..14360e3 100644 --- a/tests/integration/test_aml_diagnostics.py +++ b/tests/integration/test_aml_diagnostics.py @@ -178,11 +178,18 @@ def test_physical_elements_use_bay_coordinates_without_duplicates(authed: TestCl body = response.json() elements = body["elementList"]["element"] - slot_coordinates = [str(item["coordinate"]) for item in elements if item.get("type") == "slot"] - assert len(slot_coordinates) == 50 - assert len(slot_coordinates) == len(set(slot_coordinates)) - assert sum(1 for coordinate in slot_coordinates if coordinate.startswith("1,1,")) == 25 - assert sum(1 for coordinate in slot_coordinates if coordinate.startswith("1,2,")) == 25 + slot_coords = [item["coordinate"] for item in elements if item.get("type") == "slot"] + assert len(slot_coords) == 50 + # Full physical coordinate object (Web Services Figure 23), not a reduced string. + assert all( + isinstance(c, dict) and {"frame", "rack", "section", "column", "row", "type"} <= set(c) + for c in slot_coords + ) + # Unique per (section, row); section = bay/module (25 slots per bay across 2 bays). + keys = [(c["section"], c["row"]) for c in slot_coords] + assert len(keys) == len(set(keys)) + assert sum(1 for c in slot_coords if c["section"] == 1) == 25 + assert sum(1 for c in slot_coords if c["section"] == 2) == 25 def test_magazine_slots_expose_library_coordinates(authed: TestClient) -> None: