"""Fleet inventory model for the central Blueforce panel (Python stdlib only). Read-only by construction: this module reads one inventory JSON document (or falls back to clearly labelled synthetic demo data), turns it into the device records the panel renders, and does nothing else. It never writes to disk, never opens a network connection and never invents a state. Product rules this module enforces, because they are the fleet's rules (docs/02 §9, docs/18 §9, docs/27 §9): * one identity per dealer number, derived, never guessed: ``BF-`` for the human surface and ``bf-`` for the hostname, ``^[0-9]{8}$``; * the state machine is ``PROVISIONED_OFFLINE -> ENROLLED -> READY`` with ``FAILED`` and ``ABSENT`` outside the sequence, and the panel never promotes a device -- a stage claim the centre has not made stays unmade here; * ``READY`` is refused while any of the four channels is unverified: such a record is surfaced as ``HATA`` with the reason, not as a claim the fleet cannot back (docs/27); * a channel check that failed means the device needs a person, so the device is ``HATA``; * a record that cannot be identified is never dropped silently -- it leaves an ``InvalidRecord`` that the panel shows. Vocabulary, Turkish words and colour tokens are taken from docs/DESIGN.md; tests/test_web_static.py asserts this module still matches that document, so the two operator surfaces cannot drift apart. """ from __future__ import annotations import json import os import re from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, Iterable, Mapping, Sequence # -------------------------------------------------------------------------- # Identity (docs/02) # -------------------------------------------------------------------------- DEALER_PATTERN = re.compile(r"^[0-9]{8}$") DEVICE_ID_PREFIX = "BF-" HOSTNAME_PREFIX = "bf-" # -------------------------------------------------------------------------- # Status vocabulary (docs/DESIGN.md "Status vocabulary") -- one word per state, # Turkish, used identically by the device console and this panel. # -------------------------------------------------------------------------- @dataclass(frozen=True) class StateSpec: """One state of the fleet vocabulary, as docs/DESIGN.md defines it.""" state: str """Machine name used by the state machine: PROVISIONED_OFFLINE, ENROLLED...""" word: str """The Turkish word an operator reads. Both surfaces show this word.""" token: str """Colour token in web/static/tokens.css. Never a literal colour here.""" soft_token: str """Soft (fill) form of the same colour, for badge backgrounds.""" meaning: str """Turkish one-liner: what the state means to the operator.""" @property def token_suffix(self) -> str: """``--state-provisioned`` -> ``provisioned`` (used in CSS class names).""" return self.token[len("--state-"):] STATE_VOCABULARY: tuple[StateSpec, ...] = ( StateSpec( "PROVISIONED_OFFLINE", "KURULDU", "--state-provisioned", "--info-soft", "Çevrimdışı kuruldu; merkeze kayıtlı değil.", ), StateSpec( "ENROLLED", "KAYITLI", "--state-enrolled", "--warn-soft", "Kayıtlı; dört kanalın hepsi doğrulanmadı.", ), StateSpec( "READY", "HAZIR", "--state-ready", "--ok-soft", "Dört kanal da doğrulandı; tek sevk edilebilir durum.", ), StateSpec( "FAILED", "HATA", "--state-failed", "--danger-soft", "Bir denetim başarısız; cihaz bir insan bekliyor.", ), StateSpec( "ABSENT", "YOK", "--state-absent", "--idle-soft", "Erişilemiyor ya da hiç görülmedi.", ), ) STATE_BY_NAME: dict[str, StateSpec] = {spec.state: spec for spec in STATE_VOCABULARY} #: Lookup by the Turkish word, for the surfaces that read a word back. STATE_BY_WORD: dict[str, StateSpec] = {spec.word: spec for spec in STATE_VOCABULARY} # Sorted so that non-ready devices surface first (docs/DESIGN.md: "the fleet # table sorts non-ready first, so the list is readable in greyscale"). FAILED # and ABSENT need a person; READY -- the only shippable state -- is last. STATE_ORDER: tuple[str, ...] = ( "FAILED", "ABSENT", "PROVISIONED_OFFLINE", "ENROLLED", "READY", ) # -------------------------------------------------------------------------- # Rollout waves (docs/18 §9) # -------------------------------------------------------------------------- @dataclass(frozen=True) class Wave: """One rollout wave: ``LAB(2) -> P1(5) -> P2(20) -> W1(50) -> W2(100) -> PROD``.""" code: str label: str target: int order: int aliases: tuple[str, ...] = () @property def full_label(self) -> str: return f"{self.code} — {self.label}" if self.label != self.code else self.code WAVES: tuple[Wave, ...] = ( Wave("LAB", "LAB", 2, 0, ("lab",)), Wave("P1", "PILOT-1", 5, 1, ("pilot_1", "pilot-1", "pilot1")), Wave("P2", "PILOT-2", 20, 2, ("pilot_2", "pilot-2", "pilot2")), Wave("W1", "WAVE-1", 50, 3, ("wave_1", "wave-1", "wave1")), Wave("W2", "WAVE-2", 100, 4, ("wave_2", "wave-2", "wave2")), Wave("PROD", "PROD", 523, 5, ("production", "prod")), ) WAVE_BY_CODE: dict[str, Wave] = {wave.code: wave for wave in WAVES} # -------------------------------------------------------------------------- # Channels (docs/27: READY requires every channel verified) # -------------------------------------------------------------------------- @dataclass(frozen=True) class ChannelSpec: """One of the four remote-access channels every device must verify.""" key: str label: str hint: str CHANNELS: tuple[ChannelSpec, ...] = ( ChannelSpec("ssh_over_wg", "SSH-over-WG", "WireGuard üzerinden SSH (K-13)"), ChannelSpec("xrdp", "xRDP", "GNOME masaüstü oturumu (K-22)"), ChannelSpec("rustdesk", "RustDesk", "RustDesk OSS istemcisi"), ChannelSpec("meshcentral", "MeshCentral", "MeshCentral ajanı"), ) CHANNEL_STATUSES: tuple[str, ...] = ("verified", "pending", "failed", "unknown", "missing") # Turkish word per channel status. The panel never shows a bare colour: the word # is what carries the meaning (docs/DESIGN.md rule 2). CHANNEL_STATUS_WORDS: dict[str, str] = { "verified": "doğrulandı", "pending": "bekliyor", "failed": "başarısız", "unknown": "bilinmiyor", "missing": "yok", } # Severity class for the local stylesheet. Tokens only, no colours in Python. CHANNEL_STATUS_TONES: dict[str, str] = { "verified": "ok", "pending": "warn", "failed": "danger", "unknown": "idle", "missing": "idle", } # -------------------------------------------------------------------------- # Document contract # -------------------------------------------------------------------------- SUPPORTED_SCHEMA_VERSION = 1 DATA_PATH_ENV = "BF_FLEET_DATA" DEFAULT_DATA_PATH = Path(__file__).resolve().parent / "data" / "fleet.json" DEMO_SOURCE_LABEL = "sentetik demo veri (gerçek filo envanteri bağlı değil)" # Synthetic records for a first look at the panel without a live fleet. They go # through the same parse path as a real document, and the UI labels every page # that renders them -- demo data must never be mistaken for fleet state. DEMO_DEVICES: tuple[Mapping[str, Any], ...] = ( { "dealer_id": "12010101", "wave": "LAB", "state": "READY", "channels": { "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-15T09:10:00+03:00"}, "xrdp": {"status": "verified", "checked_at": "2026-09-15T09:12:00+03:00"}, "rustdesk": {"status": "verified", "checked_at": "2026-09-15T09:15:00+03:00"}, "meshcentral": {"status": "verified", "checked_at": "2026-09-15T09:18:00+03:00"}, }, "last_seen": "2026-09-16T08:40:00+03:00", }, { "dealer_id": "12010102", "wave": "LAB", "state": "ENROLLED", "channels": { "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T08:02:00+03:00"}, "xrdp": {"status": "pending"}, "rustdesk": {"status": "verified", "checked_at": "2026-09-16T08:05:00+03:00"}, "meshcentral": {"status": "pending"}, }, "last_seen": "2026-09-16T08:41:00+03:00", }, { "dealer_id": "12010193", "wave": "pilot_1", "state": "READY", "channels": { "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-14T10:00:00+03:00"}, "xrdp": {"status": "verified", "checked_at": "2026-09-14T10:02:00+03:00"}, "rustdesk": {"status": "verified", "checked_at": "2026-09-14T10:04:00+03:00"}, "meshcentral": {"status": "verified", "checked_at": "2026-09-14T10:06:00+03:00"}, }, "last_seen": "2026-09-16T09:05:00+03:00", }, { "dealer_id": "12010194", "wave": "P1", "state": "ENROLLED", "channels": { "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T07:30:00+03:00"}, "xrdp": {"status": "verified", "checked_at": "2026-09-16T07:32:00+03:00"}, "rustdesk": {"status": "pending"}, "meshcentral": {"status": "verified", "checked_at": "2026-09-16T07:35:00+03:00"}, }, "last_seen": "2026-09-16T09:04:00+03:00", }, { "dealer_id": "12010195", "wave": "P1", "state": "PROVISIONED_OFFLINE", "channels": {}, "last_seen": "2026-09-13T16:20:00+03:00", }, { "dealer_id": "12010204", "wave": "P2", "state": "PROVISIONED_OFFLINE", "channels": {}, "last_seen": "2026-09-12T11:00:00+03:00", }, { "dealer_id": "12010205", "wave": "P2", "state": "ENROLLED", "channels": { "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T06:10:00+03:00"}, "xrdp": {"status": "failed", "checked_at": "2026-09-16T06:12:00+03:00"}, "rustdesk": {"status": "verified", "checked_at": "2026-09-16T06:14:00+03:00"}, "meshcentral": {"status": "unknown"}, }, "last_seen": "2026-09-16T06:20:00+03:00", }, { "dealer_id": "12010206", "wave": "P2", "state": "PROVISIONED_OFFLINE", "channels": {}, "last_seen": "2026-09-11T14:45:00+03:00", }, { "dealer_id": "12010207", "wave": "W1", "state": "PROVISIONED_OFFLINE", "channels": {}, "last_seen": "2026-09-10T10:05:00+03:00", }, { "dealer_id": "12010208", "wave": "W1", "state": "ABSENT", "channels": {}, "last_seen": None, "note": "Envanterde var, merkez hiç görmedi.", }, { "dealer_id": "12010209", "wave": "W2", "state": "ABSENT", "channels": {}, "last_seen": None, }, { "dealer_id": "12010210", "wave": "PROD", "state": "PROVISIONED_OFFLINE", "channels": {}, "last_seen": "2026-09-09T09:00:00+03:00", }, { "dealer_id": "12010211", "wave": "W2", "state": "READY", "channels": { "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-15T13:00:00+03:00"}, "xrdp": {"status": "verified", "checked_at": "2026-09-15T13:02:00+03:00"}, "rustdesk": {"status": "verified", "checked_at": "2026-09-15T13:04:00+03:00"}, "meshcentral": {"status": "verified", "checked_at": "2026-09-15T13:06:00+03:00"}, }, "last_seen": "2026-09-16T09:01:00+03:00", }, ) # -------------------------------------------------------------------------- # Records # -------------------------------------------------------------------------- @dataclass(frozen=True) class ChannelCheck: """Verification state of one channel on one device.""" key: str label: str hint: str status: str checked_at: str | None = None @property def status_word(self) -> str: return CHANNEL_STATUS_WORDS[self.status] @property def tone(self) -> str: return CHANNEL_STATUS_TONES[self.status] @property def is_verified(self) -> bool: return self.status == "verified" @property def checked_at_text(self) -> str: """Never a bare ``None`` on screen: an operator reads Turkish words.""" return self.checked_at or "kayıt yok" @property def status_word_title(self) -> str: return f"{self.label}: {self.status_word}" @dataclass(frozen=True) class InvalidRecord: """A record that could not be identified. Shown, never silently dropped.""" record_ref: str reason: str @dataclass(frozen=True) class Device: """One fleet device, as the panel describes it.""" device_id: str hostname: str dealer_id: str wave_code: str state: str channels: tuple[ChannelCheck, ...] = () last_seen: str | None = None note: str | None = None claimed_state: str | None = None reason: str | None = None notes: tuple[str, ...] = field(default=()) @property def spec(self) -> StateSpec: return STATE_BY_NAME[self.state] @property def state_label(self) -> str: return self.spec.word @property def state_meaning(self) -> str: return self.spec.meaning @property def state_token_suffix(self) -> str: return self.spec.token_suffix @property def wave(self) -> Wave: return WAVE_BY_CODE[self.wave_code] @property def wave_label(self) -> str: return self.wave.full_label @property def is_ready(self) -> bool: return self.state == "READY" @property def unverified_channels(self) -> tuple[str, ...]: return tuple(check.label for check in self.channels if not check.is_verified) @property def verified_channel_count(self) -> int: return sum(1 for check in self.channels if check.is_verified) @property def last_seen_text(self) -> str: return self.last_seen or "kayıt yok" @property def ready_gate_note(self) -> str: """The Turkish sentence that explains the READY gate for this device.""" if self.is_ready: return "Bu cihaz HAZIR: dört kanalın dördü de doğrulandı (docs/27)." unverified = self.unverified_channels if not unverified: return ( "Dört kanal da doğrulandı, ancak merkez kaydı HAZIR demiyor. " "Panel durumu yükseltmez (docs/27)." ) return ( "HAZIR için eksik kanallar: " + ", ".join(unverified) + ". READY yalnız dördü de doğrulandığında verilir (docs/27)." ) @dataclass(frozen=True) class FleetSnapshot: """Everything the panel knows: devices, refused records and the source.""" devices: tuple[Device, ...] invalid: tuple[InvalidRecord, ...] source_kind: str # "file" | "demo" | "error" source_path: str | None source_label: str error: str | None loaded_at: str generated_at: str | None = None schema_version: int | None = None @property def is_demo(self) -> bool: return self.source_kind == "demo" @property def is_file(self) -> bool: return self.source_kind == "file" @property def is_error(self) -> bool: return self.source_kind == "error" @property def total(self) -> int: return len(self.devices) @property def counts(self) -> dict[str, int]: """Count of devices per Turkish state word, zero filled, word order kept.""" counted = {spec.word: 0 for spec in STATE_VOCABULARY} for device in self.devices: counted[device.state_label] += 1 return counted @property def ready_count(self) -> int: return self.counts["HAZIR"] @property def not_ready_count(self) -> int: return self.total - self.ready_count @property def wave_counts(self) -> dict[str, int]: counted = {wave.code: 0 for wave in WAVES} for device in self.devices: counted[device.wave_code] += 1 return counted @property def source_kind_word(self) -> str: return { "file": "envanter dosyası", "demo": "sentetik demo veri", "error": "okunamadı", }[self.source_kind] @property def banner_word(self) -> str: """One Turkish word that names what the operator is looking at.""" return {"file": "ENVANTER", "demo": "DEMO VERİ", "error": "VERİ YOK"}[self.source_kind] @property def banner_tone(self) -> str: return {"file": "info", "demo": "warn", "error": "danger"}[self.source_kind] @property def banner_text(self) -> str: """The sentence under the word: what the data is, and what it is not.""" if self.is_demo: return ( f"Gerçek filo envanteri bağlı değil: aşağıdaki {self.total} kayıt " "sentetiktir ve hiçbir cihazı temsil etmez. Gerçek veri için paneli " "«--data » ile başlatın." ) if self.is_file: generated = self.generated_at or "bildirilmemiş" return ( f"Kaynak: {self.source_path} · şema sürümü {self.schema_version} · " f"dosya üretimi: {generated} · yükleme: {self.loaded_at}" ) return f"{self.error} Demo veriye düşülmedi; filo durumu bilinmiyor." @dataclass(frozen=True) class FleetFilter: """What the operator narrowed the list to.""" query: str = "" state: str = "" # machine state name, "" means every state wave: str = "" # wave code, "" means every wave only_not_ready: bool = False @property def is_active(self) -> bool: return bool(self.query or self.state or self.wave or self.only_not_ready) def matches(self, device: Device) -> bool: if self.state and device.state != self.state: return False if self.wave and device.wave_code != self.wave: return False if self.only_not_ready and device.is_ready: return False if self.query: needle = fold(self.query) haystack = " ".join((device.device_id, device.hostname, device.dealer_id)) if needle not in fold(haystack): return False return True @property def description(self) -> str: """Turkish summary of the filter, for the header band and the empty note.""" parts: list[str] = [] if self.query: parts.append(f"metin «{self.query}»") if self.state: parts.append(f"durum {STATE_BY_NAME[self.state].word}") if self.wave: parts.append(f"dalga {self.wave}") if self.only_not_ready: parts.append("yalnız hazır olmayanlar") return " · ".join(parts) if parts else "filtre yok" # -------------------------------------------------------------------------- # Text helpers (Turkish aware: the vocabulary contains ı/İ/ş/ğ/ç/ö/ü) # -------------------------------------------------------------------------- _FOLD_TABLE = str.maketrans( { "ı": "i", "İ": "i", "I": "i", "ş": "s", "Ş": "s", "ğ": "g", "Ğ": "g", "ç": "c", "Ç": "c", "ö": "o", "Ö": "o", "ü": "u", "Ü": "u", } ) def fold(text: str) -> str: """Case- and accent-insensitive key for operator input (Turkish included). ``HAZIR``, ``Hazır`` and ``hazir`` must all mean the same filter value, and ``str.lower()`` alone does not get there: ``"HAZIR".lower()`` is ``hazir`` while ``"hazır".lower()`` is ``hazır``. """ return text.translate(_FOLD_TABLE).lower() def _text(value: Any) -> str | None: """Non-empty string, or ``None`` -- the panel never shows a bare ``None``.""" if isinstance(value, str): stripped = value.strip() return stripped or None if isinstance(value, bool): return None if isinstance(value, (int, float)): return str(value) return None def first_param(params: Mapping[str, Any], key: str) -> str: """First value of a query parameter, in both ``str`` and ``list`` shapes.""" value = params.get(key) if value is None: return "" if isinstance(value, (list, tuple)): value = value[0] if value else "" return _text(str(value)) or "" def timestamp_now() -> str: return datetime.now().astimezone().isoformat(timespec="seconds") def _alias_index(items: Iterable[tuple[str, str]]) -> dict[str, str]: """Build a folded lookup table, complaining loudly about collisions.""" table: dict[str, str] = {} for key, value in items: folded = fold(key) existing = table.get(folded) if existing is not None and existing != value: raise ValueError(f"alias collision: {key!r} -> {value!r} / {existing!r}") table[folded] = value return table STATE_ALIASES: dict[str, str] = _alias_index( [(spec.state, spec.state) for spec in STATE_VOCABULARY] + [(spec.word, spec.state) for spec in STATE_VOCABULARY] + [(spec.token_suffix, spec.state) for spec in STATE_VOCABULARY] ) WAVE_ALIASES: dict[str, str] = _alias_index( [(wave.code, wave.code) for wave in WAVES] + [(alias, wave.code) for wave in WAVES for alias in wave.aliases] ) CHANNEL_ALIASES: dict[str, str] = _alias_index( [(spec.key, spec.key) for spec in CHANNELS] + [(spec.label, spec.key) for spec in CHANNELS] + [ ("ssh", "ssh_over_wg"), ("ssh-over-wg", "ssh_over_wg"), ("ssh_over_wireguard", "ssh_over_wg"), ("wireguard-ssh", "ssh_over_wg"), ("rdp", "xrdp"), ("rust_desk", "rustdesk"), ("mesh", "meshcentral"), ] ) CHANNEL_STATUS_ALIASES: dict[str, str] = _alias_index( [(status, status) for status in CHANNEL_STATUSES] + [(word, status) for status, word in CHANNEL_STATUS_WORDS.items()] ) # -------------------------------------------------------------------------- # Parsing # -------------------------------------------------------------------------- def _record_ref(record: Any, index: int) -> str: if isinstance(record, Mapping): for key in ("device_id", "dealer_id", "bayi_no", "hostname"): value = _text(record.get(key)) if value: return value return f"kayıt {index + 1}" def _parse_channel(raw: Any) -> tuple[str, str | None, str | None]: """-> (status, checked_at, advisory). Unknown shapes never crash the panel.""" if raw is None: return "missing", None, None if isinstance(raw, str): claimed, checked_at = raw, None elif isinstance(raw, Mapping): claimed = _text(raw.get("status")) or "unknown" checked_at = _text(raw.get("checked_at")) or _text(raw.get("checked")) else: return "unknown", None, "Kanal kaydı okunamadı: nesne ya da metin bekleniyor." status = CHANNEL_STATUS_ALIASES.get(fold(claimed)) if status is None: return "unknown", checked_at, f"Bilinmeyen kanal durumu yok sayıldı: «{claimed}»." return status, checked_at, None def parse_record(record: Any, index: int = 0) -> tuple[Device | None, InvalidRecord | None]: """Turn one raw inventory record into a ``Device`` or a refused record. A record is refused only when it cannot be identified at all; anything the panel can name it shows, with the reason it could not be trusted attached. """ ref = _record_ref(record, index) if not isinstance(record, Mapping): return None, InvalidRecord(ref, "Kayıt nesne değil; JSON nesnesi bekleniyor.") dealer_id = _text(record.get("dealer_id")) or _text(record.get("dealer")) \ or _text(record.get("bayi_no")) if not dealer_id: return None, InvalidRecord(ref, "Bayi no yok; cihaz kimliği türetilemez (docs/02 §9).") if not DEALER_PATTERN.match(dealer_id): return None, InvalidRecord( ref, f"Bayi no 8 hane değil: «{dealer_id}» (kural ^[0-9]{{8}}$).", ) device_id = DEVICE_ID_PREFIX + dealer_id hostname = HOSTNAME_PREFIX + dealer_id declared_id = _text(record.get("device_id")) if declared_id and declared_id != device_id: return None, InvalidRecord( ref, f"Cihaz kimliği kurala uymuyor: «{declared_id}» yerine «{device_id}» olmalı (docs/02)." ) declared_host = _text(record.get("hostname")) if declared_host and declared_host != hostname: return None, InvalidRecord( ref, f"Hostname kurala uymuyor: «{declared_host}» yerine «{hostname}» olmalı (docs/02)." ) wave_raw = _text(record.get("wave")) or _text(record.get("dalga")) if not wave_raw: return None, InvalidRecord(ref, "Dalga yok; cihaz bir yayılım dalgasına bağlanamaz (docs/18).") wave_code = WAVE_ALIASES.get(fold(wave_raw)) if wave_code is None: return None, InvalidRecord( ref, f"Bilinmeyen dalga: «{wave_raw}» (LAB, P1, P2, W1, W2, PROD)." ) notes: list[str] = [] state_raw = _text(record.get("state")) or _text(record.get("durum")) claimed_state: str | None = None reason: str | None = None if state_raw is None: state = "FAILED" reason = ( "Durum alanı yok. Panel durum uydurmaz; cihaz HATA olarak işaretlendi " "(docs/27)." ) else: resolved = STATE_ALIASES.get(fold(state_raw)) if resolved is None: state = "FAILED" claimed_state = state_raw reason = ( f"Durum alanı tanınmıyor: «{state_raw}». Panel durum uydurmaz; cihaz " "HATA olarak işaretlendi (docs/27)." ) else: state = resolved raw_channels = record.get("channels") statuses: dict[str, tuple[str, str | None]] = {} if raw_channels is None: notes.append("Kanallar bildirilmemiş; dört kanal «yok» sayıldı (docs/27).") elif not isinstance(raw_channels, Mapping): notes.append("«channels» alanı nesne değil; dört kanal «yok» sayıldı (docs/27).") else: for raw_key, raw_value in raw_channels.items(): key = CHANNEL_ALIASES.get(fold(str(raw_key))) if key is None: notes.append(f"Bilinmeyen kanal anahtarı yok sayıldı: «{raw_key}».") continue status, checked_at, advisory = _parse_channel(raw_value) statuses[key] = (status, checked_at) if advisory: notes.append(advisory) checks: list[ChannelCheck] = [] for spec in CHANNELS: status, checked_at = statuses.get(spec.key, ("missing", None)) checks.append(ChannelCheck(spec.key, spec.label, spec.hint, status, checked_at)) # READY gate (docs/27 §9.4): the claim is refused while a channel is # unverified. The panel does not downgrade the word to a warning -- a claim # the fleet cannot back is a failure and needs a person. if state == "READY": blocked = [check.label for check in checks if not check.is_verified] if blocked: claimed_state = "READY" state = "FAILED" reason = ( "HAZIR iddiası reddedildi: " + ", ".join(blocked) + " doğrulanmadı. READY yalnız dört kanalın hepsi doğrulandığında " "verilir (docs/27)." ) # A channel check that failed means the device needs a person. failed = [check.label for check in checks if check.status == "failed"] if failed and state not in ("FAILED",): reason = ( "Kanal doğrulaması başarısız: " + ", ".join(failed) + ". Cihaz bir insan bekliyor (docs/27)." ) state = "FAILED" # The panel never promotes: a record may lag behind the centre, never lead it. if state in ("PROVISIONED_OFFLINE", "ENROLLED") and all( check.is_verified for check in checks ): notes.append( "Dört kanal da doğrulandı, ancak merkez kaydı daha ileri bir durum " "demiyor; panel durumu yükseltmez (docs/27)." ) return ( Device( device_id=device_id, hostname=hostname, dealer_id=dealer_id, wave_code=wave_code, state=state, channels=tuple(checks), last_seen=_text(record.get("last_seen")) or _text(record.get("son_gorulme")), note=_text(record.get("note")), claimed_state=claimed_state, reason=reason, notes=tuple(notes), ), None, ) def parse_records(records: Sequence[Any]) -> tuple[tuple[Device, ...], tuple[InvalidRecord, ...]]: """Parse a whole document body; duplicate dealer numbers are refused (docs/02).""" devices: list[Device] = [] invalid: list[InvalidRecord] = [] seen: dict[str, str] = {} for index, record in enumerate(records): device, refused = parse_record(record, index) if refused is not None: invalid.append(refused) continue assert device is not None if device.dealer_id in seen: invalid.append( InvalidRecord( device.device_id, "Yinelenen bayi no; ikinci kayıt reddedildi (docs/02 §8).", ) ) continue seen[device.dealer_id] = device.device_id devices.append(device) return tuple(devices), tuple(invalid) # -------------------------------------------------------------------------- # Loading # -------------------------------------------------------------------------- def _error_snapshot(path: Path | None, error: str, stamp: str) -> FleetSnapshot: return FleetSnapshot( devices=(), invalid=(), source_kind="error", source_path=str(path) if path is not None else None, source_label=f"veri okunamadı: {path}" if path is not None else "veri okunamadı", error=error, loaded_at=stamp, ) def demo_snapshot(stamp: str | None = None) -> FleetSnapshot: """Labelled synthetic data, for a first look with no fleet attached.""" devices, invalid = parse_records(DEMO_DEVICES) return FleetSnapshot( devices=devices, invalid=invalid, source_kind="demo", source_path=None, source_label=DEMO_SOURCE_LABEL, error=None, loaded_at=stamp or timestamp_now(), generated_at=None, schema_version=SUPPORTED_SCHEMA_VERSION, ) def load_fleet( path: str | Path | None = None, *, env: Mapping[str, str] | None = None, now: str | None = None, default_path: str | Path | None = None, ) -> FleetSnapshot: """Read one inventory document into a ``FleetSnapshot``. Fall-back rules, deliberately different for the two cases: * no document requested and the default location is empty -> labelled demo data, so a first look is possible without a fleet; * a document *was* requested (``path`` or ``BF_FLEET_DATA``) but cannot be read, is not JSON, or breaks the schema -> an error snapshot. A broken feed never becomes demo data and never becomes an empty fleet. ``env``, ``now`` and ``default_path`` exist so a test can pin the source and the clock; the panel itself passes none of them. """ environment = os.environ if env is None else env stamp = now or timestamp_now() requested: str | None = None if path is not None: requested = str(path) else: from_env = _text(environment.get(DATA_PATH_ENV)) if from_env: requested = from_env explicit = requested is not None target = Path(requested) if requested is not None else Path( str(default_path) if default_path is not None else str(DEFAULT_DATA_PATH) ) if not target.is_file(): if explicit: return _error_snapshot( target, f"Envanter dosyası bulunamadı: {target}. Panel demo veriye düşmedi; " "filo durumu bilinmiyor.", stamp, ) return demo_snapshot(stamp) try: document = json.loads(target.read_text(encoding="utf-8")) except OSError as exc: return _error_snapshot( target, f"Envanter dosyası okunamadı: {target} ({exc.strerror or exc}).", stamp, ) except json.JSONDecodeError as exc: return _error_snapshot( target, f"Envanter dosyası JSON olarak çözümlenemedi: {target} " f"(satır {exc.lineno}, sütun {exc.colno}).", stamp, ) if not isinstance(document, Mapping): return _error_snapshot( target, f"Envanter belgesi nesne değil: {target} (JSON nesnesi bekleniyor).", stamp ) schema_version = document.get("schema_version") if not isinstance(schema_version, int) or isinstance(schema_version, bool): return _error_snapshot( target, f"Envanter belgesinde schema_version yok ya da sayı değil: {target}. " f"Bu panel şema sürümü {SUPPORTED_SCHEMA_VERSION} okur.", stamp, ) if schema_version > SUPPORTED_SCHEMA_VERSION: return _error_snapshot( target, f"Desteklenmeyen şema sürümü: {schema_version}; bu panel " f"{SUPPORTED_SCHEMA_VERSION} okur. Panel fail-closed durdu.", stamp, ) records = document.get("devices") if not isinstance(records, list): return _error_snapshot( target, f"Envanter belgesinde «devices» listesi yok: {target}.", stamp ) devices, invalid = parse_records(records) return FleetSnapshot( devices=devices, invalid=invalid, source_kind="file", source_path=str(target), source_label=f"envanter dosyası: {target}", error=None, loaded_at=stamp, generated_at=_text(document.get("generated_at")), schema_version=schema_version, ) # -------------------------------------------------------------------------- # Ordering and filtering # -------------------------------------------------------------------------- def sort_devices(devices: Iterable[Device]) -> tuple[Device, ...]: """Non-ready first (docs/DESIGN.md), then wave order, then identity. Reading order for an operator: what needs a person, then the rollout order, with the id as the last, stable tie-breaker. """ return tuple( sorted( devices, key=lambda device: ( STATE_ORDER.index(device.state), device.wave.order, device.device_id, ), ) ) def filter_devices(devices: Iterable[Device], criteria: FleetFilter) -> tuple[Device, ...]: return tuple(device for device in devices if criteria.matches(device)) def parse_filter(params: Mapping[str, Any]) -> tuple[FleetFilter, tuple[str, ...]]: """Normalise operator input; unknown values are ignored *visibly*.""" warnings: list[str] = [] state_raw = first_param(params, "durum") state = "" if state_raw: state = STATE_ALIASES.get(fold(state_raw), "") if not state: warnings.append( f"Bilinmeyen durum filtresi yok sayıldı: «{state_raw}». " "Sözlük: " + ", ".join(spec.word for spec in STATE_VOCABULARY) + "." ) wave_raw = first_param(params, "dalga") wave = "" if wave_raw: wave = WAVE_ALIASES.get(fold(wave_raw), "") if not wave: warnings.append( f"Bilinmeyen dalga filtresi yok sayıldı: «{wave_raw}». " "Dalgalar: " + ", ".join(w.code for w in WAVES) + "." ) only_not_ready = fold(first_param(params, "hazir_degil")) in ("1", "var", "true", "on", "evet") criteria = FleetFilter( query=first_param(params, "q"), state=state, wave=wave, only_not_ready=only_not_ready, ) return criteria, tuple(warnings) def state_counts(devices: Iterable[Device]) -> dict[str, int]: counted = {spec.word: 0 for spec in STATE_VOCABULARY} for device in devices: counted[device.state_label] += 1 return counted def wave_counts(devices: Iterable[Device]) -> dict[str, int]: counted = {wave.code: 0 for wave in WAVES} for device in devices: counted[device.wave_code] += 1 return counted