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
12 changes: 9 additions & 3 deletions compatibility/scalar-i3-341g/aml/elements-list-authed.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
}
}
58 changes: 58 additions & 0 deletions docs/reference/i3-contract-notes.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 22 additions & 4 deletions openblade/api/routes_aml_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Comment on lines +76 to +78
state: str
barcode: str | None = None

Expand Down Expand Up @@ -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(),
Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
17 changes: 12 additions & 5 deletions tests/integration/test_aml_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading