dhcp-simulator 2.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dhcp/__init__.py +2 -0
- dhcp/ai_operations/contracts.py +19 -0
- dhcp/apps/operations_console.py +33 -0
- dhcp/apps/simulation_console.py +35 -0
- dhcp/audit/journal.py +313 -0
- dhcp/communications/lease.py +10 -0
- dhcp/communications/simulation_link.py +9 -0
- dhcp/core/clock.py +7 -0
- dhcp/core/monitoring_audio.py +741 -0
- dhcp/core/numeric.py +20 -0
- dhcp/domain/enums.py +35 -0
- dhcp/domain/models.py +110 -0
- dhcp/domain/state_machines.py +23 -0
- dhcp/emergencies/engine.py +8 -0
- dhcp/eta/engine.py +9 -0
- dhcp/identity/authorization.py +11 -0
- dhcp/mapping/__init__.py +3 -0
- dhcp/mapping/engine.py +134 -0
- dhcp/mapping/models.py +41 -0
- dhcp/mapping/providers/__init__.py +2 -0
- dhcp/mapping/providers/base.py +7 -0
- dhcp/mapping/providers/maptiler.py +13 -0
- dhcp/persistence/__init__.py +3 -0
- dhcp/persistence/sqlite_repository.py +791 -0
- dhcp/runtime.py +853 -0
- dhcp/safety/kernel.py +321 -0
- dhcp/services/coordinator.py +232 -0
- dhcp/simulation/aircraft.py +493 -0
- dhcp/simulation/airspace_operations.py +241 -0
- dhcp/simulation/awareness.py +42 -0
- dhcp/simulation/dispatch.py +124 -0
- dhcp/simulation/flight_phases.py +1617 -0
- dhcp/simulation/flight_sequencing.py +160 -0
- dhcp/simulation/heavy_transport.py +328 -0
- dhcp/simulation/hub.py +103 -0
- dhcp/simulation/intelligence.py +262 -0
- dhcp/simulation/live_operations.py +187 -0
- dhcp/simulation/maintenance_execution.py +135 -0
- dhcp/simulation/model_evidence.py +572 -0
- dhcp/simulation/operations.py +133 -0
- dhcp/simulation/performance.py +434 -0
- dhcp/simulation/postflight_operations.py +174 -0
- dhcp/simulation/recovery_operations.py +255 -0
- dhcp/simulation/reference_scenarios.py +755 -0
- dhcp/simulation/reliability.py +102 -0
- dhcp/simulation/replay_evidence.py +306 -0
- dhcp/simulation/resilience.py +171 -0
- dhcp/simulation/scenarios.py +40 -0
- dhcp/simulation/scheduling.py +134 -0
- dhcp/simulation/sensitivity.py +619 -0
- dhcp/simulation/session.py +3047 -0
- dhcp/simulation/traffic.py +150 -0
- dhcp/simulation/turnaround.py +114 -0
- dhcp/ui/__init__.py +1 -0
- dhcp/ui/assets/icons/activity.svg +1 -0
- dhcp/ui/assets/icons/alert.svg +1 -0
- dhcp/ui/assets/icons/audio.svg +4 -0
- dhcp/ui/assets/icons/audio_muted.svg +4 -0
- dhcp/ui/assets/icons/audit.svg +1 -0
- dhcp/ui/assets/icons/battery.svg +1 -0
- dhcp/ui/assets/icons/communications.svg +1 -0
- dhcp/ui/assets/icons/drone.svg +1 -0
- dhcp/ui/assets/icons/filter.svg +1 -0
- dhcp/ui/assets/icons/link.svg +1 -0
- dhcp/ui/assets/icons/map.svg +1 -0
- dhcp/ui/assets/icons/mission.svg +1 -0
- dhcp/ui/assets/icons/more.svg +1 -0
- dhcp/ui/assets/icons/overview.svg +1 -0
- dhcp/ui/assets/icons/pad.svg +1 -0
- dhcp/ui/assets/icons/pause.svg +1 -0
- dhcp/ui/assets/icons/play.svg +1 -0
- dhcp/ui/assets/icons/reset.svg +1 -0
- dhcp/ui/assets/icons/route.svg +1 -0
- dhcp/ui/assets/icons/settings.svg +1 -0
- dhcp/ui/assets/icons/simulation.svg +1 -0
- dhcp/ui/assets/icons/telemetry.svg +1 -0
- dhcp/ui/assets/icons/transport.svg +5 -0
- dhcp/ui/assets/map/maplibre_shell.html +3 -0
- dhcp/ui/assets/transport_classes/README.md +35 -0
- dhcp/ui/assets/transport_classes/extra_heavy.png +0 -0
- dhcp/ui/assets/transport_classes/heavy.png +0 -0
- dhcp/ui/assets/transport_classes/large.png +0 -0
- dhcp/ui/data_catalog.py +69 -0
- dhcp/ui/design_system.py +74 -0
- dhcp/ui/focus.py +24 -0
- dhcp/ui/graphics.py +123 -0
- dhcp/ui/icons.py +24 -0
- dhcp/ui/main_window.py +247 -0
- dhcp/ui/menus.py +34 -0
- dhcp/ui/monitoring_audio_adapter.py +285 -0
- dhcp/ui/pages.py +1693 -0
- dhcp/ui/theme.py +80 -0
- dhcp/ui/widgets.py +490 -0
- dhcp/ui/workspace.py +25 -0
- dhcp_simulator-2.9.0.dist-info/METADATA +298 -0
- dhcp_simulator-2.9.0.dist-info/RECORD +98 -0
- dhcp_simulator-2.9.0.dist-info/WHEEL +4 -0
- dhcp_simulator-2.9.0.dist-info/licenses/LICENSE +21 -0
dhcp/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from uuid import UUID, uuid4
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class AIRecommendation:
|
|
8
|
+
mission_id: UUID
|
|
9
|
+
action: str
|
|
10
|
+
confidence: float
|
|
11
|
+
evidence_refs: tuple[str, ...]
|
|
12
|
+
limitations: tuple[str, ...]
|
|
13
|
+
expires_at: datetime
|
|
14
|
+
requires_human_approval: bool = True
|
|
15
|
+
recommendation_id: UUID = field(default_factory=uuid4)
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
if not 0 <= self.confidence <= 1:
|
|
19
|
+
raise ValueError("confidence must be between 0 and 1")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
def main() -> None:
|
|
2
|
+
import sys
|
|
3
|
+
try:
|
|
4
|
+
from PyQt6.QtWidgets import QApplication
|
|
5
|
+
except ImportError as exc:
|
|
6
|
+
raise SystemExit("Install the optional UI dependency: pip install -e '.[ui]'") from exc
|
|
7
|
+
from dhcp.ui.main_window import OperationsMainWindow
|
|
8
|
+
from dhcp.ui.theme import APP_STYLESHEET
|
|
9
|
+
app = QApplication([])
|
|
10
|
+
app.setApplicationName("DHCP Simulator")
|
|
11
|
+
app.setStyleSheet(APP_STYLESHEET)
|
|
12
|
+
window = OperationsMainWindow()
|
|
13
|
+
|
|
14
|
+
def contain_uncaught_exception(exc_type, exc, traceback) -> None:
|
|
15
|
+
# Qt callback failures must never terminate the operations console.
|
|
16
|
+
window.session.containment.capture(
|
|
17
|
+
"application", "uncaught_exception", exc, window.session.elapsed_s, recovered=True
|
|
18
|
+
)
|
|
19
|
+
try:
|
|
20
|
+
window.session.request_command(
|
|
21
|
+
"pause_after_uncaught_ui_exception",
|
|
22
|
+
window.session.pause,
|
|
23
|
+
)
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
sys.excepthook = contain_uncaught_exception
|
|
28
|
+
window.show()
|
|
29
|
+
app.exec()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from dhcp.core.clock import ManualClock
|
|
2
|
+
from dhcp.audit.journal import AppendOnlyJournal
|
|
3
|
+
from dhcp.communications.lease import LeaseRegistry
|
|
4
|
+
from dhcp.safety.kernel import SafetyKernel
|
|
5
|
+
from dhcp.services.coordinator import CommandCoordinator
|
|
6
|
+
from dhcp.domain.models import *
|
|
7
|
+
from dhcp.domain.enums import *
|
|
8
|
+
from dhcp.simulation.aircraft import SimulatedAircraft
|
|
9
|
+
from dhcp.emergencies.engine import EmergencyEngine
|
|
10
|
+
from dhcp.eta.engine import EtaEngine
|
|
11
|
+
|
|
12
|
+
def command(a,m,op,kind,seq,clock):
|
|
13
|
+
return AuthorizedCommand(a.aircraft_id,m.mission_id,kind,op,1,seq,clock.now(),clock.now()+30)
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
clock=ManualClock(100); journal=AppendOnlyJournal(); leases=LeaseRegistry(); safety=SafetyKernel()
|
|
17
|
+
coordinator=CommandCoordinator(clock,leases,safety,journal)
|
|
18
|
+
aircraft=Aircraft("SIM-101"); mission=Mission(aircraft.aircraft_id,1.2,3.0)
|
|
19
|
+
operator=OperatorContext("operator-1",frozenset({"dispatcher","pad_operator","flight_operator","emergency_operator"}))
|
|
20
|
+
leases.grant(ControlLease(aircraft.aircraft_id,operator.operator_id,1,clock.now(),clock.now()+3600))
|
|
21
|
+
seq=0
|
|
22
|
+
for kind in (CommandType.RESERVE,CommandType.BEGIN_PREFLIGHT,CommandType.AUTHORIZE_LAUNCH,CommandType.LAUNCH):
|
|
23
|
+
seq+=1; decision=coordinator.execute(aircraft,mission,command(aircraft,mission,operator,kind,seq,clock)); print(kind,decision.reason_code)
|
|
24
|
+
sim=SimulatedAircraft(aircraft,mission); emergency=EmergencyEngine(); eta=EtaEngine()
|
|
25
|
+
while mission.progress<0.92:
|
|
26
|
+
clock.advance(5); telemetry=sim.step(clock.now(),5); issue=emergency.evaluate(telemetry)
|
|
27
|
+
estimate=eta.estimate(mission.route_distance_km,mission.progress,max(telemetry.speed_mps,0.1))
|
|
28
|
+
print(f"progress={mission.progress:5.1%} battery={aircraft.battery_pct:5.1f}% ETA={estimate.seconds:5.0f}s")
|
|
29
|
+
if issue: print("EMERGENCY",issue.classification,issue.response); break
|
|
30
|
+
if not issue:
|
|
31
|
+
seq+=1; print(CommandType.AUTHORIZE_LANDING,coordinator.execute(aircraft,mission,command(aircraft,mission,operator,CommandType.AUTHORIZE_LANDING,seq,clock),telemetry).reason_code)
|
|
32
|
+
seq+=1; print(CommandType.LAND,coordinator.execute(aircraft,mission,command(aircraft,mission,operator,CommandType.LAND,seq,clock),telemetry).reason_code)
|
|
33
|
+
print("final",aircraft.state,mission.state,"audit_records",len(journal.records),"journal_valid",journal.verify())
|
|
34
|
+
|
|
35
|
+
if __name__=='__main__': main()
|
dhcp/audit/journal.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import mkstemp
|
|
10
|
+
from threading import Lock
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from dhcp.domain.models import Event
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_ZERO_HASH = "0" * 64
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JournalCorruptionError(RuntimeError):
|
|
22
|
+
"""Raised when a complete journal record fails structural or hash validation."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, path: Path, line_number: int, reason: str) -> None:
|
|
25
|
+
super().__init__(f"journal corruption at {path}, line {line_number}: {reason}")
|
|
26
|
+
self.path = path
|
|
27
|
+
self.line_number = line_number
|
|
28
|
+
self.reason = reason
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class JournalRecoveryReport:
|
|
33
|
+
path: str | None
|
|
34
|
+
loaded_records: int
|
|
35
|
+
original_bytes: int
|
|
36
|
+
valid_bytes: int
|
|
37
|
+
truncated_bytes: int = 0
|
|
38
|
+
recovered_trailing_partial: bool = False
|
|
39
|
+
normalized_encoding: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _freeze_json(value: Any) -> Any:
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
return MappingProxyType({str(key): _freeze_json(item) for key, item in value.items()})
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return tuple(_freeze_json(item) for item in value)
|
|
47
|
+
if isinstance(value, tuple):
|
|
48
|
+
return tuple(_freeze_json(item) for item in value)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class StoredEvent:
|
|
54
|
+
"""Immutable event representation retained by the audit chain."""
|
|
55
|
+
|
|
56
|
+
event_type: str
|
|
57
|
+
payload: Mapping[str, Any]
|
|
58
|
+
event_id: UUID | str
|
|
59
|
+
occurred_at: datetime
|
|
60
|
+
_canonical_text: str = field(repr=False)
|
|
61
|
+
|
|
62
|
+
def canonical(self) -> str:
|
|
63
|
+
return self._canonical_text
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _stored_event(canonical_text: str) -> StoredEvent:
|
|
67
|
+
try:
|
|
68
|
+
raw = json.loads(canonical_text)
|
|
69
|
+
except (TypeError, ValueError) as exc:
|
|
70
|
+
raise ValueError("event payload is not valid canonical JSON") from exc
|
|
71
|
+
if not isinstance(raw, dict):
|
|
72
|
+
raise ValueError("canonical event must be a JSON object")
|
|
73
|
+
required = {"event_id", "event_type", "occurred_at", "payload"}
|
|
74
|
+
if set(raw) != required:
|
|
75
|
+
raise ValueError("canonical event has unexpected fields")
|
|
76
|
+
if not isinstance(raw["event_type"], str) or not isinstance(raw["payload"], dict):
|
|
77
|
+
raise ValueError("canonical event types are invalid")
|
|
78
|
+
try:
|
|
79
|
+
occurred_at = datetime.fromisoformat(str(raw["occurred_at"]))
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise ValueError("canonical event timestamp is invalid") from exc
|
|
82
|
+
try:
|
|
83
|
+
event_id: UUID | str = UUID(str(raw["event_id"]))
|
|
84
|
+
except ValueError:
|
|
85
|
+
event_id = str(raw["event_id"])
|
|
86
|
+
return StoredEvent(
|
|
87
|
+
event_type=raw["event_type"],
|
|
88
|
+
payload=_freeze_json(raw["payload"]),
|
|
89
|
+
event_id=event_id,
|
|
90
|
+
occurred_at=occurred_at,
|
|
91
|
+
_canonical_text=canonical_text,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class JournalRecord:
|
|
97
|
+
index: int
|
|
98
|
+
previous_hash: str
|
|
99
|
+
record_hash: str
|
|
100
|
+
event: StoredEvent
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _record_bytes(record: JournalRecord) -> bytes:
|
|
104
|
+
return (
|
|
105
|
+
f"{record.index}|{record.previous_hash}|{record.record_hash}|"
|
|
106
|
+
f"{record.event.canonical()}\n"
|
|
107
|
+
).encode("utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AppendOnlyJournal:
|
|
111
|
+
"""Thread-safe hash-chain journal with crash-safe restart and rollback support."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, path: Path | str | None = None):
|
|
114
|
+
self._records: list[JournalRecord] = []
|
|
115
|
+
self._encoded_records: list[bytes] = []
|
|
116
|
+
self._lock = Lock()
|
|
117
|
+
self.path = Path(path) if path is not None else None
|
|
118
|
+
if self.path is not None:
|
|
119
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
self._recovery_report = self._load()
|
|
121
|
+
else:
|
|
122
|
+
self._recovery_report = JournalRecoveryReport(None, 0, 0, 0)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def recovery_report(self) -> JournalRecoveryReport:
|
|
126
|
+
return self._recovery_report
|
|
127
|
+
|
|
128
|
+
def _parse_record(
|
|
129
|
+
self,
|
|
130
|
+
content: bytes,
|
|
131
|
+
*,
|
|
132
|
+
line_number: int,
|
|
133
|
+
expected_index: int,
|
|
134
|
+
previous_hash: str,
|
|
135
|
+
) -> JournalRecord:
|
|
136
|
+
try:
|
|
137
|
+
line = content.decode("utf-8")
|
|
138
|
+
except UnicodeDecodeError as exc:
|
|
139
|
+
raise ValueError("record is not valid UTF-8") from exc
|
|
140
|
+
parts = line.split("|", 3)
|
|
141
|
+
if len(parts) != 4:
|
|
142
|
+
raise ValueError("record does not contain four fields")
|
|
143
|
+
raw_index, stored_previous, stored_hash, canonical_text = parts
|
|
144
|
+
try:
|
|
145
|
+
index = int(raw_index)
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
raise ValueError("record index is not an integer") from exc
|
|
148
|
+
if index != expected_index:
|
|
149
|
+
raise ValueError(f"expected index {expected_index}, found {index}")
|
|
150
|
+
if stored_previous != previous_hash:
|
|
151
|
+
raise ValueError("previous hash does not match the chain")
|
|
152
|
+
if len(stored_hash) != 64:
|
|
153
|
+
raise ValueError("record hash has invalid length")
|
|
154
|
+
try:
|
|
155
|
+
int(stored_hash, 16)
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise ValueError("record hash is not hexadecimal") from exc
|
|
158
|
+
expected_hash = sha256((stored_previous + canonical_text).encode("utf-8")).hexdigest()
|
|
159
|
+
if stored_hash != expected_hash:
|
|
160
|
+
raise ValueError("record hash verification failed")
|
|
161
|
+
event = _stored_event(canonical_text)
|
|
162
|
+
return JournalRecord(index, stored_previous, stored_hash, event)
|
|
163
|
+
|
|
164
|
+
def _load(self) -> JournalRecoveryReport:
|
|
165
|
+
assert self.path is not None
|
|
166
|
+
if not self.path.exists():
|
|
167
|
+
self.path.touch()
|
|
168
|
+
raw = self.path.read_bytes()
|
|
169
|
+
original_size = len(raw)
|
|
170
|
+
physical_lines = raw.splitlines(keepends=True)
|
|
171
|
+
valid_source_bytes = 0
|
|
172
|
+
recovered_partial = False
|
|
173
|
+
previous = _ZERO_HASH
|
|
174
|
+
for position, physical_line in enumerate(physical_lines):
|
|
175
|
+
complete = physical_line.endswith(b"\n")
|
|
176
|
+
content = physical_line.rstrip(b"\r\n")
|
|
177
|
+
line_number = position + 1
|
|
178
|
+
try:
|
|
179
|
+
record = self._parse_record(
|
|
180
|
+
content,
|
|
181
|
+
line_number=line_number,
|
|
182
|
+
expected_index=len(self._records),
|
|
183
|
+
previous_hash=previous,
|
|
184
|
+
)
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
is_last_incomplete = position == len(physical_lines) - 1 and not complete
|
|
187
|
+
if not is_last_incomplete:
|
|
188
|
+
raise JournalCorruptionError(self.path, line_number, str(exc)) from exc
|
|
189
|
+
recovered_partial = True
|
|
190
|
+
break
|
|
191
|
+
self._records.append(record)
|
|
192
|
+
encoded = _record_bytes(record)
|
|
193
|
+
self._encoded_records.append(encoded)
|
|
194
|
+
previous = record.record_hash
|
|
195
|
+
valid_source_bytes += len(physical_line)
|
|
196
|
+
|
|
197
|
+
normalized = b"".join(self._encoded_records)
|
|
198
|
+
normalized_encoding = raw[:valid_source_bytes] != normalized
|
|
199
|
+
truncated_bytes = original_size - valid_source_bytes if recovered_partial else 0
|
|
200
|
+
if recovered_partial or normalized_encoding or raw != normalized:
|
|
201
|
+
self._replace_file_locked(normalized)
|
|
202
|
+
return JournalRecoveryReport(
|
|
203
|
+
path=str(self.path),
|
|
204
|
+
loaded_records=len(self._records),
|
|
205
|
+
original_bytes=original_size,
|
|
206
|
+
valid_bytes=len(normalized),
|
|
207
|
+
truncated_bytes=max(0, truncated_bytes),
|
|
208
|
+
recovered_trailing_partial=recovered_partial,
|
|
209
|
+
normalized_encoding=normalized_encoding,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _fsync_directory(self) -> None:
|
|
213
|
+
assert self.path is not None
|
|
214
|
+
try:
|
|
215
|
+
descriptor = os.open(self.path.parent, os.O_RDONLY)
|
|
216
|
+
except OSError:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
os.fsync(descriptor)
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
finally:
|
|
223
|
+
os.close(descriptor)
|
|
224
|
+
|
|
225
|
+
def _replace_file_locked(self, content: bytes) -> None:
|
|
226
|
+
assert self.path is not None
|
|
227
|
+
descriptor, temporary_name = mkstemp(
|
|
228
|
+
prefix=f".{self.path.name}.",
|
|
229
|
+
suffix=".tmp",
|
|
230
|
+
dir=self.path.parent,
|
|
231
|
+
)
|
|
232
|
+
temporary = Path(temporary_name)
|
|
233
|
+
try:
|
|
234
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
235
|
+
handle.write(content)
|
|
236
|
+
handle.flush()
|
|
237
|
+
os.fsync(handle.fileno())
|
|
238
|
+
os.replace(temporary, self.path)
|
|
239
|
+
self._fsync_directory()
|
|
240
|
+
finally:
|
|
241
|
+
if temporary.exists():
|
|
242
|
+
temporary.unlink()
|
|
243
|
+
|
|
244
|
+
def append(self, event: Event) -> JournalRecord:
|
|
245
|
+
canonical_text = event.canonical()
|
|
246
|
+
stored = _stored_event(canonical_text)
|
|
247
|
+
with self._lock:
|
|
248
|
+
previous = self._records[-1].record_hash if self._records else _ZERO_HASH
|
|
249
|
+
digest = sha256((previous + canonical_text).encode("utf-8")).hexdigest()
|
|
250
|
+
record = JournalRecord(len(self._records), previous, digest, stored)
|
|
251
|
+
encoded = _record_bytes(record)
|
|
252
|
+
if self.path is not None:
|
|
253
|
+
original_size = self.path.stat().st_size if self.path.exists() else 0
|
|
254
|
+
try:
|
|
255
|
+
with self.path.open("ab") as handle:
|
|
256
|
+
written = handle.write(encoded)
|
|
257
|
+
if written != len(encoded):
|
|
258
|
+
raise OSError("short audit-journal write")
|
|
259
|
+
handle.flush()
|
|
260
|
+
os.fsync(handle.fileno())
|
|
261
|
+
except BaseException:
|
|
262
|
+
try:
|
|
263
|
+
with self.path.open("r+b") as handle:
|
|
264
|
+
handle.truncate(original_size)
|
|
265
|
+
handle.flush()
|
|
266
|
+
os.fsync(handle.fileno())
|
|
267
|
+
except OSError:
|
|
268
|
+
pass
|
|
269
|
+
raise
|
|
270
|
+
self._records.append(record)
|
|
271
|
+
self._encoded_records.append(encoded)
|
|
272
|
+
return record
|
|
273
|
+
|
|
274
|
+
def checkpoint(self) -> int:
|
|
275
|
+
"""Return a rollback marker representing the current record count."""
|
|
276
|
+
with self._lock:
|
|
277
|
+
return len(self._records)
|
|
278
|
+
|
|
279
|
+
def rollback(self, checkpoint: int) -> None:
|
|
280
|
+
"""Atomically restore the chain to a prior ``checkpoint()`` marker."""
|
|
281
|
+
if not isinstance(checkpoint, int):
|
|
282
|
+
raise TypeError("checkpoint must be an integer record count")
|
|
283
|
+
with self._lock:
|
|
284
|
+
if checkpoint < 0 or checkpoint > len(self._records):
|
|
285
|
+
raise ValueError("checkpoint is outside the current journal")
|
|
286
|
+
if checkpoint == len(self._records):
|
|
287
|
+
return
|
|
288
|
+
retained_bytes = b"".join(self._encoded_records[:checkpoint])
|
|
289
|
+
if self.path is not None:
|
|
290
|
+
self._replace_file_locked(retained_bytes)
|
|
291
|
+
del self._records[checkpoint:]
|
|
292
|
+
del self._encoded_records[checkpoint:]
|
|
293
|
+
|
|
294
|
+
def verify(self) -> bool:
|
|
295
|
+
with self._lock:
|
|
296
|
+
previous = _ZERO_HASH
|
|
297
|
+
for expected_index, record in enumerate(self._records):
|
|
298
|
+
expected = sha256(
|
|
299
|
+
(previous + record.event.canonical()).encode("utf-8")
|
|
300
|
+
).hexdigest()
|
|
301
|
+
if (
|
|
302
|
+
record.index != expected_index
|
|
303
|
+
or record.previous_hash != previous
|
|
304
|
+
or expected != record.record_hash
|
|
305
|
+
):
|
|
306
|
+
return False
|
|
307
|
+
previous = record.record_hash
|
|
308
|
+
return True
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def records(self) -> tuple[JournalRecord, ...]:
|
|
312
|
+
with self._lock:
|
|
313
|
+
return tuple(self._records)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from dhcp.domain.models import ControlLease
|
|
2
|
+
class LeaseRegistry:
|
|
3
|
+
def __init__(self): self._leases={}
|
|
4
|
+
def grant(self,lease:ControlLease):
|
|
5
|
+
current=self._leases.get(lease.aircraft_id)
|
|
6
|
+
if current and lease.epoch<=current.epoch: raise ValueError("control epoch must increase")
|
|
7
|
+
self._leases[lease.aircraft_id]=lease
|
|
8
|
+
def validate(self,aircraft_id,owner,epoch,now):
|
|
9
|
+
lease=self._leases.get(aircraft_id)
|
|
10
|
+
return bool(lease and lease.owner==owner and lease.epoch==epoch and lease.active(now))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from dhcp.domain.enums import OperationalMode
|
|
2
|
+
class SimulationAircraftLink:
|
|
3
|
+
def __init__(self,mode=OperationalMode.SIMULATION):
|
|
4
|
+
if mode is not OperationalMode.SIMULATION: raise RuntimeError("simulation link cannot operate outside SIMULATION mode")
|
|
5
|
+
self.mode=mode; self.connected=False
|
|
6
|
+
async def connect(self): self.connected=True
|
|
7
|
+
async def submit_command(self,command):
|
|
8
|
+
if not self.connected: raise RuntimeError("link not connected")
|
|
9
|
+
return {"command_id":str(command.command_id),"acknowledged":True,"sequence":command.sequence_number}
|
dhcp/core/clock.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from time import monotonic
|
|
2
|
+
class MonotonicClock:
|
|
3
|
+
def now(self)->float: return monotonic()
|
|
4
|
+
class ManualClock:
|
|
5
|
+
def __init__(self,start:float=0.0): self.value=start
|
|
6
|
+
def now(self)->float: return self.value
|
|
7
|
+
def advance(self,seconds:float)->None: self.value+=seconds
|