Rack: Flex Mini on the NVR, coloured Sliger fronts, UniFi brush panel #32

Merged
jared merged 7 commits from loose-gear-on-top into main 2026-11-11 02:09:39 -05:00
9 changed files with 206 additions and 34 deletions
+8 -1
View File
@@ -154,7 +154,14 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
enp2s0 (left) and enp3s0 (right), and the status glow follows the host. The "ZimaBoard 832 +
ConnectX-3" drawing adds the single-port SFP+ card lying flat in the side PCIe slot, as in the rack,
with a DAC attached; its round dot is Gandalf's link mark for enp1s0 (the slot is PCI bus 1), not an
LED on the card. Anything else is a labelled box.
LED on the card. UniFi desk switches (Flex Mini, Flex) on a shelf are drawn with their live faceplate
at their real width. Anything else is a labelled box.
- **More drawn fronts**: the Sliger CX4712 with its red, white or blue perforated front fitted (as in
the rack photo, drive cages faint behind the mesh), and the UniFi brush panel
(UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images).
- **Gear resting on other gear**: a shelf item with Front drawing "No shelf: gear resting on what is
below" draws no tray, so a switch lying on top of the NVR is a "shelf" in the unit above the NVR whose
contents sit directly on it.
- **Racks**: admins name a rack and set its height with **Rack settings** on the page, and add more with
**+ Rack** (a tab per rack). Renaming a rack moves its items; an empty rack can be deleted. Each item
has a rack (empty means the first). Until a rack is saved, racks come from config:
+2 -1
View File
@@ -1210,7 +1210,8 @@ def api_rack():
'status': _device_status(dev(c), snapshot) if dev(c) in known else '',
'model': next((u.get('model') for u in snapshot.get('unifi') or [] if u.get('name') == dev(c)), '')
or '', 'notes': c.get('notes') or '', 'chassis': c.get('chassis') or '',
'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot)}
'slot': c.get('slot'), 'interfaces': _physical_interfaces(dev(c), snapshot),
'device': dev(c), 'ports': (switches.get(dev(c)) or {}).get('ports') or {}}
for c in sorted(on_shelf.get(name, []), key=lambda c: (c.get('slot') or 999, c['name'].lower()))
]
out.append(entry)
+6 -1
View File
@@ -37,10 +37,15 @@ CHASSIS = {
'uacc-patch-24': 'UniFi Patch Panel Blank 24 (UACC-Rack-Panel-Patch-Blank-24)',
'usw-pro-max-24-poe': 'UniFi Switch Pro Max 24 PoE (until UniFi reports it)',
'usw-pro-max-48-poe': 'UniFi Switch Pro Max 48 PoE (until UniFi reports it)',
'sliger-cx4712': 'Sliger CX4712 4U (10-bay)',
'sliger-cx4712': 'Sliger CX4712 4U (10-bay, faceplate off)',
'sliger-cx4712-red': 'Sliger CX4712 4U, red front',
'sliger-cx4712-white': 'Sliger CX4712 4U, white front',
'sliger-cx4712-blue': 'Sliger CX4712 4U, blue front',
'uacc-brush-1u': 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)',
'rosewill-rsv-l4500u': 'Rosewill RSV-L4500U 4U (15-bay)',
'zimaboard-832': 'IceWhale ZimaBoard 832 (shelf gear)',
'zimaboard-832-cx3': 'IceWhale ZimaBoard 832 + ConnectX-3 SFP+ in PCIe (shelf gear)',
'resting': 'No shelf: gear resting on what is below (for a shelf item)',
'server-1u': 'Generic 1U server',
'server-2u': 'Generic 2U server',
'server-4u': 'Generic 4U server',
+12 -6
View File
@@ -595,6 +595,15 @@ function faceplateLegendHtml(spec, marks) {
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text},
// rack? (draw only the front, spec.rackView, as mounted in a rack) }
function renderFaceplate(model, ctx) {
const { svg, marks } = faceplateSvg(model, ctx);
return `<div class="fp-wrap">
<div class="fp-frame ${FACEPLATES[model].frameClass}">${svg}</div>
</div>${faceplateLegendHtml(FACEPLATES[model], marks)}`;
}
// The faceplate's SVG on its own, for drawing inside another picture (a shelf). `attrs` adds
// attributes to the <svg> (x, y, width, height to place it). Returns { svg, marks }.
function faceplateSvg(model, ctx, attrs = '') {
const spec = FACEPLATES[model];
const anyPort = Object.values(ctx.portMap)[0];
const marks = new Set();
@@ -612,9 +621,7 @@ function renderFaceplate(model, ctx) {
const outlets = (spec.outlets || []).map(o => faceplateOutletSvg(o, outletData[o.idx])).join('');
const body = spec.noBody ? ''
: `<rect class="fp-body" width="${spec.width}" height="${spec.height}" rx="3"/>`;
return `<div class="fp-wrap">
<div class="fp-frame ${spec.frameClass}">
<svg class="fp-svg" viewBox="${((ctx.rack && spec.rackView) || [0, 0, spec.width, spec.height]).join(' ')}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
const svg = `<svg class="fp-svg" ${attrs} viewBox="${((ctx.rack && spec.rackView) || [0, 0, spec.width, spec.height]).join(' ')}" role="group" aria-label="${lt.escHtml(spec.label)} front panel">
<defs>
<linearGradient id="fp-metal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e4e4e6"/><stop offset=".08" stop-color="#d6d6d8"/>
@@ -628,9 +635,8 @@ function renderFaceplate(model, ctx) {
${spec.decor({ online: ctx.online })}
${outlets}
${ports}
</svg>
</div>
</div>${faceplateLegendHtml(spec, marks)}`;
</svg>`;
return { svg, marks };
}
// Where an fp-scroll panel should start: its port area
+145 -22
View File
@@ -1,5 +1,5 @@
'use strict';
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR */
/* exported RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg, SHELF_GEAR, DESK_FACEPLATES */
// Drawn fronts for rack gear Gandalf can't read a faceplate from: custom-built servers. Chosen per cutsheet item ("Front drawing"). Each one
// shows the device's live status on its power light when the item is linked to a device.
//
@@ -106,12 +106,107 @@ const RACK_FRONTS = {
},
},
// Sliger CX4712 with its coloured front fitted, as in the rack photo (wiki: rack-2026.jpg):
// a hex-perforated panel over most of the face, the drive cages faintly visible through it,
// black rack ears, and a power button over two USB ports at the top right.
'sliger-cx4712-red': sligerFront('#c8161d', '#e9e5e0'),
'sliger-cx4712-white': sligerFront('#eceae6', '#9aa0a6'),
'sliger-cx4712-blue': sligerFront('#1f5fae', '#e9e5e0'),
// Ubiquiti UACC-Rack-Panel-Brush-1U, from Ubiquiti's product images (store.ui.com, UACC-Rack-Panel
// variant Brush 1U): a light silver face with separate ear plates (two rounded slots each), a
// recessed chamfered frame around the opening (shadowed along the top, bright bevel along the
// bottom), and two rows of black bristles meeting at a seam along the middle.
'uacc-brush-1u': {
label: 'UniFi Rack Panel Brush 1U (UACC-Rack-Panel-Brush-1U)', units: 1, width: 1100, height: 105,
draw() {
// Bristles: each row is shaded root to tip (dark where it leaves the frame, lighter where the
// two rows meet), with a faint strand texture on top. Strong individual strands alias into hard
// stripes at rack size, so the texture stays low-contrast and the shading carries the look.
// The opening runs almost the full width between the ear plates, as in Ubiquiti's image
const strands = (y0, y1, seed) => Array.from({ length: 172 }, (_, i) => {
const n = (i * 7919 + seed * 104729) % 97;
return `<rect x="${60 + i * 5.7 + (n % 5) * 0.4}" y="${y0 + (n % 4)}" width="${1.4 + (n % 3) * 0.5}"
height="${y1 - y0 - (n % 4)}" class="ub-strand" style="opacity:${0.18 + (n % 5) * 0.05}"/>`;
}).join('');
const slot = x => `<rect class="ua-ear-slot" x="${x - 7}" y="22" width="14" height="10" rx="5"/>
<rect class="ua-ear-slot" x="${x - 7}" y="73" width="14" height="10" rx="5"/>`;
return `<defs><linearGradient id="fp-metal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e4e4e6"/><stop offset=".08" stop-color="#d6d6d8"/>
<stop offset=".92" stop-color="#cfcfd2"/><stop offset="1" stop-color="#a9a9ad"/>
</linearGradient>
<linearGradient id="ub-row-top" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#08090a"/><stop offset=".7" stop-color="#1f2125"/><stop offset="1" stop-color="#34373c"/>
</linearGradient>
<linearGradient id="ub-row-bottom" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#34373c"/><stop offset=".3" stop-color="#1f2125"/><stop offset="1" stop-color="#08090a"/>
</linearGradient></defs>
<rect class="fp-body" width="1100" height="105" rx="3"/>
<rect class="ua-ear-plate" x="0" y="1" width="32" height="103" rx="3"/>
<rect class="ua-ear-plate" x="1068" y="1" width="32" height="103" rx="3"/>
<rect class="ua-ear-seam" x="31" y="1" width="1.5" height="103"/>
<rect class="ua-ear-seam" x="1067.5" y="1" width="1.5" height="103"/>
${slot(16)}${slot(1084)}
<rect class="ub-frame" x="44" y="20" width="1012" height="66" rx="7"/>
<rect class="ub-frame-shadow" x="48" y="24" width="1004" height="7" rx="3"/>
<rect class="ub-frame-bevel" x="48" y="77" width="1004" height="5" rx="2.5"/>
<rect class="ub-opening" x="56" y="30" width="988" height="47" rx="2"/>
<rect x="56" y="30" width="988" height="23.5" fill="url(#ub-row-top)"/>
<rect x="56" y="53.5" width="988" height="23.5" fill="url(#ub-row-bottom)"/>
${strands(31, 53, 1)}${strands(54, 76, 2)}
<rect class="ub-seam" x="56" y="53" width="988" height="1"/>`;
},
},
// Generic servers, for gear without a specific drawing
'server-1u': genericServer(1, 4),
'server-2u': genericServer(2, 12),
'server-4u': genericServer(4, 24),
};
// A rack handle on a Sliger ear: a vertical bar standing off the ear on two mounts
function sligerHandle(x) {
const top = 70, bottom = 350;
return `<rect class="rf-sl-mount" x="${x - 2}" y="${top - 14}" width="30" height="22" rx="4"/>
<rect class="rf-sl-mount" x="${x - 2}" y="${bottom - 8}" width="30" height="22" rx="4"/>
<rect class="rf-sl-handle" x="${x}" y="${top - 6}" width="26" height="${bottom - top + 12}" rx="13"/>
<rect class="rf-sl-handle-shine" x="${x + 5}" y="${top + 6}" width="5" height="${bottom - top - 12}" rx="2.5"/>`;
}
function sligerFront(colour, hole) {
const W = 1100, H = 420;
// Hex perforations: a pattern of hexagons cut out of the panel through a mask
const hex = (cx, cy, r) => Array.from({ length: 6 }, (_, i) => {
const a = Math.PI / 6 + i * Math.PI / 3;
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
}).join(' ');
// Hole size and pitch from the rack photo: about 20 rows of holes over the panel's height
const r = 6.9, dx = 18, dy = 15.6;
const holes = [];
for (let row = 0, y = 46; y < H - 34; row++, y += dy) {
for (let x = 82 + (row % 2 ? dx / 2 : 0); x < 925; x += dx) holes.push(`<polygon points="${hex(x, y, r)}"/>`);
}
const bays = Array.from({ length: 10 }, (_, i) => `<rect class="rf-sl-bay" x="${140 + i * 74}" y="120" width="44" height="250" rx="3" style="fill:${hole}"/>`).join('');
// The cages show only faintly through the mesh
return {
label: 'Sliger CX4712 4U (10-bay)', units: 4, width: W, height: H,
draw({ status }) {
const id = `sl-${colour.slice(1)}`;
return `<defs><mask id="${id}"><rect width="${W}" height="${H}" fill="#fff"/><g fill="#000">${holes.join('')}</g></mask></defs>
<rect class="rf-sl-inside" x="70" y="30" width="870" height="${H - 60}"/>${bays}
<rect class="rf-sl-ear" x="0" y="0" width="70" height="${H}" rx="4"/>
<rect class="rf-sl-ear" x="${W - 70}" y="0" width="70" height="${H}" rx="4"/>
${sligerHandle(14)}${sligerHandle(W - 40)}
<rect x="52" y="10" width="${W - 104}" height="${H - 20}" rx="8" style="fill:${colour}" mask="url(#${id})"/>
<rect class="rf-sl-edge" x="52" y="10" width="${W - 104}" height="${H - 20}" rx="8"/>
<circle class="rf-sl-button" cx="988" cy="88" r="17"/>
<circle class="rf-power-ring" cx="988" cy="88" r="12" style="stroke:${frontLed(status)}"/>
<rect class="rf-sl-io" x="966" y="138" width="44" height="74" rx="4"/>
${usbA(977, 146)}${usbA(977, 168)}${usbC(980, 192)}`;
},
};
}
function genericServer(units, bays) {
const H = 105 * units;
return {
@@ -256,42 +351,70 @@ const SHELF_GEAR = {
},
};
// UniFi desk gear drawn from its live faceplate when it sits on a shelf: the real width of the
// face (Ubiquiti tech specs) and the span of that face in the faceplate drawing, to scale it.
const DESK_FACEPLATES = {
USMINI: { mm: 107, body: [2, 661], label: 'UniFi Switch Flex Mini' }, // 107 x 70 x 21 mm
USF5P: { mm: 122.5, body: [66, 488], label: 'UniFi Switch Flex' }, // 122.5 x 107.1 x 28 mm
};
// A shelf at true scale (1100 units = 482.6 mm, a 19" panel) with what sits on it, left to right.
// Gear with a SHELF_GEAR drawing is drawn to its real width; anything else is a labelled box.
function shelfSvg(item, esc) {
// Gear with a SHELF_GEAR drawing, or UniFi desk gear with a live faceplate, is drawn to its real
// width; anything else is a labelled box. A shelf with Front drawing "resting" has no tray: its gear
// lies directly on whatever is below it (a switch on top of the NVR).
function shelfSvg(item, esc, faceplate) {
const units = item.rack_height || 1;
const W = 1100, H = 105 * units, perMm = W / 482.6;
const floor = H - 20;
const resting = item.chassis === 'resting';
const floor = resting ? H - 3 : H - 20;
let x = 64;
const things = (item.contents || []).map(c => {
const gear = SHELF_GEAR[c.chassis];
const w = gear ? gear.mm * perMm : 150;
const k = gear ? w / gear.width : 1;
const h = gear ? gear.height * k : 58;
const desk = !gear && faceplate && DESK_FACEPLATES[c.model];
let w, h, body;
if (gear) {
w = gear.mm * perMm;
const k = w / gear.width;
h = gear.height * k;
body = `<g transform="translate(${x} ${floor - h}) scale(${k})">${gear.draw(c)}</g>`;
} else if (desk) {
// Scale the faceplate so its body spans the switch's real width, then place the whole drawing
const spec = faceplate.spec(c.model);
const k = (desk.mm * perMm) / (desk.body[1] - desk.body[0]);
w = desk.mm * perMm;
h = spec.height * k;
body = faceplate.svg(c, `x="${x - desk.body[0] * k}" y="${floor - h}" width="${spec.width * k}" height="${h}"`);
} else {
w = 150;
h = 58;
body = `<rect class="sh-box" x="${x}" y="${floor - h}" width="${w}" height="${h}" rx="4"/>
<text class="sh-box-name" x="${x + w / 2}" y="${floor - h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
}
const top = floor - h;
const nics = (c.interfaces || []).map(n => `${n.name} ${n.up ? 'up' : 'down'}`).join(', ');
const tip = [c.name, gear ? gear.label : c.kind, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
const body = gear
? `<g transform="translate(${x} ${top}) scale(${k})">${gear.draw(c)}</g>`
: `<rect class="sh-box" x="${x}" y="${top}" width="${w}" height="${h}" rx="4"/>
<text class="sh-box-name" x="${x + w / 2}" y="${top + h / 2 + 5}" text-anchor="middle">${esc(c.name)}</text>`;
const out = `<a class="sh-thing" href="${esc(c.url)}"><title>${esc(tip)}</title>
const label = gear ? gear.label : (desk ? desk.label : c.kind);
const tip = [c.name, label, c.status, nics, gear && gear.specs].filter(Boolean).join(' · ');
// A live faceplate's ports are links of their own, so it isn't wrapped in one
const open = desk ? '<g class="sh-thing">' : `<a class="sh-thing" href="${esc(c.url)}">`;
const tagY = resting ? top - 16 : floor + 5;
const out = `${open}<title>${esc(tip)}</title>
${body}
<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${floor + 5}" width="92" height="12" rx="2"/>
<text class="sh-tag-text" x="${x + w / 2}" y="${floor + 14}" text-anchor="middle">${esc(c.name)}</text>
</a>`;
${desk ? '' : `<rect class="sh-hit" x="${x - 4}" y="${top - 4}" width="${w + 8}" height="${h + 8}" rx="4"/>`}
<rect class="sh-tag" x="${x + w / 2 - 46}" y="${tagY}" width="92" height="12" rx="2"/>
<text class="sh-tag-text" x="${x + w / 2}" y="${tagY + 9}" text-anchor="middle">${esc(c.name)}</text>
${desk ? '</g>' : '</a>'}`;
x += w + 26;
return out;
}).join('');
const tray = resting ? '' : `${ears(W, H, 'rf-ear-dark')}
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>`;
return `<svg class="rack-svg rack-shelf-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="group"
aria-label="${esc(item.name)}">
<title>${esc(item.name)}</title>
${ears(W, H, 'rf-ear-dark')}
<rect class="sh-back" x="36" y="6" width="${W - 72}" height="${floor - 6}"/>
<rect class="sh-floor" x="30" y="${floor}" width="${W - 60}" height="${H - floor - 2}" rx="2"/>
<rect class="sh-lip" x="30" y="${floor}" width="${W - 60}" height="3"/>
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">empty shelf</text>`}
${tray}
${things || `<text class="sh-empty" x="${W / 2}" y="${floor - 12}" text-anchor="middle">${resting ? 'nothing here' : 'empty shelf'}</text>`}
<text class="sh-name" x="46" y="18">${esc(item.name)}</text>
</svg>`;
}
+11 -2
View File
@@ -1,5 +1,5 @@
'use strict';
/* global FACEPLATES, renderFaceplate, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
/* global FACEPLATES, renderFaceplate, faceplateSvg, RACK_CAN_EDIT, RACK_FRONTS, CHASSIS_FACEPLATES, rackFrontSvg, shelfSvg */
// Rack page: draws the rack front from /api/rack. Switches use their real faceplates,
// patch panels a generic panel; hovering a port lights up every port on the same cable run.
@@ -145,6 +145,15 @@ function plateHtml(item) {
</div>`;
}
// Live faceplates for UniFi desk gear sitting on a shelf (a Flex Mini on top of the NVR)
const shelfFaceplates = {
spec: model => FACEPLATES[model],
svg: (c, attrs) => faceplateSvg(c.model, {
swName: c.device || c.name, portMap: portMapOf(c.ports), outlets: [], online: c.status === 'up',
titleFor: (port, idx) => portTitle(port, idx),
}, attrs).svg,
};
function backHtml(item) {
// A full-depth item seen from the rear: its back, not its faceplate
return `<div class="rack-plate rack-back">
@@ -161,7 +170,7 @@ function itemHtml(item) {
else if (item.chassis && RACK_FRONTS[item.chassis]) body = rackFrontSvg(item.chassis, item);
else if (item.kind === 'patch_panel' && item.chassis === 'uacc-patch-24') body = uaccPanelSvg(item);
else if (item.kind === 'patch_panel') body = panelSvg(item);
else if (item.kind === 'shelf') body = shelfSvg(item, esc);
else if (item.kind === 'shelf') body = shelfSvg(item, esc, shelfFaceplates);
else body = plateHtml(item);
return `<div class="rack-item rack-kind-${esc(item.kind)}" data-item="${esc(item.id)}">${body}</div>`;
}
+18
View File
@@ -1630,3 +1630,21 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
.cx-tab { fill: none; stroke: #c9ccd0; stroke-width: 9; stroke-linejoin: round; }
.cx-link { stroke: #101418; stroke-width: 3; }
.fp-speedbar { fill: #b3b5ba; }
.rf-sl-inside { fill: #141619; }
.rf-sl-ear { fill: #17181a; }
.rf-sl-edge { fill: none; stroke: rgba(0,0,0,.35); stroke-width: 2; }
.rf-sl-button { fill: #c9ccd0; stroke: #8a8e93; stroke-width: 2; }
.rf-sl-bay { opacity: .45; }
.rf-sl-io { fill: #0e0f11; }
.rf-sl-mount { fill: #0c0d0f; }
.rf-sl-handle { fill: #3a3e44; stroke: #0a0b0c; stroke-width: 2; }
.rf-sl-handle-shine { fill: #6b717a; }
.ua-ear-plate { fill: #d3d5d8; stroke: #b2b4b8; stroke-width: 1; }
.ua-ear-seam { fill: #9fa1a6; }
.ua-ear-slot { fill: #2a2c30; }
.ub-frame { fill: #bfc1c5; stroke: #a5a7ac; stroke-width: 1; }
.ub-frame-shadow { fill: #8c8e93; }
.ub-frame-bevel { fill: #f1f2f3; }
.ub-opening { fill: #0d0e10; }
.ub-seam { fill: #050506; opacity: .8; }
.ub-strand { fill: #000; }
+1
View File
@@ -552,6 +552,7 @@ class TestRackFacesAndSettings:
assert [(c['name'], c['slot'], c['chassis']) for c in things] == [('b-box', 1, 'zimaboard-832'),
('large1', 2, '')]
assert things[1]['interfaces'] == [{'name': 'eth0', 'up': True}]
assert things[0]['device'] == 'b-box' and things[0]['ports'] == {}
def test_saved_racks_replace_config(self, client):
self._items()
+3 -1
View File
@@ -369,7 +369,9 @@ class TestChassis:
def test_every_drawing_has_a_label(self):
assert all(cs.CHASSIS.values())
assert {'sliger-cx4712', 'rosewill-rsv-l4500u', 'unvr-pro', 'uacc-patch-24',
'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3'} <= set(cs.CHASSIS)
'usw-pro-max-24-poe', 'usw-pro-max-48-poe', 'zimaboard-832', 'zimaboard-832-cx3',
'resting', 'uacc-brush-1u', 'sliger-cx4712-red', 'sliger-cx4712-white',
'sliger-cx4712-blue'} <= set(cs.CHASSIS)
class TestFacingAndSlots: