feat(ground-suite): add replay observability console
This commit is contained in:
1 parent
1d28db2983
commit
c06c293f49
33 files changed
+2636
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Receive-only bytes from one MAVLink transport endpoint.
|
||||
///
|
||||
/// A source has one reader and owns its close lifecycle. A future adapter will
|
||||
/// bind serial, UDP, or a recording source here; no source in the first slice
|
||||
/// may write bytes to a vehicle.
|
||||
abstract interface class MavlinkByteSource {
|
||||
Stream<Uint8List> get chunks;
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../observations/vehicle_data_update.dart';
|
||||
import 'mavlink_byte_source.dart';
|
||||
|
||||
/// Converts a receive-only MAVLink byte stream into semantically safe events.
|
||||
///
|
||||
/// TODO(gcs-live-mavlink): generate this adapter from pinned MAVLink Common
|
||||
/// definitions plus Kaptan's `kaptan.xml` root. It must reject MAVLink 1 and
|
||||
/// CRC-invalid MAVLink 2 frames, validate every fixed diagnostic length before
|
||||
/// UTF-8 decoding, retain unknown enum values and flags, and emit parser or
|
||||
/// semantic drop updates without ending the connection.
|
||||
abstract interface class MavlinkEventDecoder {
|
||||
Stream<VehicleDataUpdate> decode(MavlinkByteSource source);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/// A MAVLink severity value retained exactly as it appeared on the wire.
|
||||
///
|
||||
/// Known values follow MAVLink's numeric severity semantics. Unknown values
|
||||
/// remain displayable so a newer vehicle cannot silently lose diagnostic
|
||||
/// meaning before Ground Suite has been updated.
|
||||
final class MavSeverity {
|
||||
const MavSeverity(this.wireValue);
|
||||
|
||||
static const emergency = MavSeverity(0);
|
||||
static const alert = MavSeverity(1);
|
||||
static const critical = MavSeverity(2);
|
||||
static const error = MavSeverity(3);
|
||||
static const warning = MavSeverity(4);
|
||||
static const notice = MavSeverity(5);
|
||||
static const info = MavSeverity(6);
|
||||
static const debug = MavSeverity(7);
|
||||
|
||||
final int wireValue;
|
||||
|
||||
String get label => switch (wireValue) {
|
||||
0 => 'Emergency',
|
||||
1 => 'Alert',
|
||||
2 => 'Critical',
|
||||
3 => 'Error',
|
||||
4 => 'Warning',
|
||||
5 => 'Notice',
|
||||
6 => 'Info',
|
||||
7 => 'Debug',
|
||||
_ => 'Unknown ($wireValue)',
|
||||
};
|
||||
}
|
||||
|
||||
/// A KAPTAN_SHUTDOWN reason retained exactly as it appeared on the wire.
|
||||
final class KaptanShutdownReason {
|
||||
const KaptanShutdownReason(this.wireValue);
|
||||
|
||||
static const operator = KaptanShutdownReason(1);
|
||||
static const safety = KaptanShutdownReason(2);
|
||||
|
||||
final int wireValue;
|
||||
|
||||
String get label => switch (wireValue) {
|
||||
1 => 'Operator',
|
||||
2 => 'Safety',
|
||||
_ => 'Unknown ($wireValue)',
|
||||
};
|
||||
}
|
||||
|
||||
enum TelemetryKind {
|
||||
rawImu,
|
||||
distanceSensor,
|
||||
opticalFlow;
|
||||
|
||||
String get label => switch (this) {
|
||||
TelemetryKind.rawImu => 'Raw IMU',
|
||||
TelemetryKind.distanceSensor => 'Distance sensor',
|
||||
TelemetryKind.opticalFlow => 'Optical flow',
|
||||
};
|
||||
}
|
||||
|
||||
sealed class ObservedEvent {
|
||||
const ObservedEvent({required this.receivedAt});
|
||||
|
||||
final DateTime receivedAt;
|
||||
}
|
||||
|
||||
final class HeartbeatEvent extends ObservedEvent {
|
||||
const HeartbeatEvent({
|
||||
required super.receivedAt,
|
||||
required this.systemId,
|
||||
required this.componentId,
|
||||
required this.armed,
|
||||
required this.vehicleState,
|
||||
});
|
||||
|
||||
final int systemId;
|
||||
final int componentId;
|
||||
final bool armed;
|
||||
final String vehicleState;
|
||||
}
|
||||
|
||||
final class TelemetryEvent extends ObservedEvent {
|
||||
const TelemetryEvent({required super.receivedAt, required this.kind});
|
||||
|
||||
final TelemetryKind kind;
|
||||
}
|
||||
|
||||
final class LogEvent extends ObservedEvent {
|
||||
const LogEvent({
|
||||
required super.receivedAt,
|
||||
required this.level,
|
||||
required this.message,
|
||||
this.flags = 0,
|
||||
});
|
||||
|
||||
static const traceFlag = 1;
|
||||
static const truncatedFlag = 2;
|
||||
static const coalescedFlag = 4;
|
||||
static const knownFlags = traceFlag | truncatedFlag | coalescedFlag;
|
||||
|
||||
final MavSeverity level;
|
||||
final String message;
|
||||
final int flags;
|
||||
|
||||
bool get isTrace => flags & traceFlag != 0;
|
||||
bool get isTruncated => flags & truncatedFlag != 0;
|
||||
bool get isCoalesced => flags & coalescedFlag != 0;
|
||||
int get unknownFlags => flags & ~knownFlags;
|
||||
}
|
||||
|
||||
final class ErrorEvent extends ObservedEvent {
|
||||
const ErrorEvent({
|
||||
required super.receivedAt,
|
||||
required this.path,
|
||||
this.additionalInformation = '',
|
||||
this.flags = 0,
|
||||
});
|
||||
|
||||
static const truncatedFlag = 2;
|
||||
static const coalescedFlag = 4;
|
||||
static const knownFlags = truncatedFlag | coalescedFlag;
|
||||
|
||||
final String path;
|
||||
final String additionalInformation;
|
||||
final int flags;
|
||||
|
||||
bool get isTruncated => flags & truncatedFlag != 0;
|
||||
bool get isCoalesced => flags & coalescedFlag != 0;
|
||||
int get unknownFlags => flags & ~knownFlags;
|
||||
}
|
||||
|
||||
final class ShutdownEvent extends ObservedEvent {
|
||||
const ShutdownEvent({required super.receivedAt, required this.reason});
|
||||
|
||||
final KaptanShutdownReason reason;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'vehicle_data_update.dart';
|
||||
|
||||
/// A receive-only source of already-classified vehicle observations.
|
||||
///
|
||||
/// The replay implementation is the first concrete port. A live port may be
|
||||
/// added only after the custom MAVLink decoder has its length/CRC/enum safety
|
||||
/// contract and endpoint lifecycle tests.
|
||||
abstract interface class VehicleDataPort {
|
||||
String get label;
|
||||
|
||||
bool get isReplay;
|
||||
|
||||
Stream<VehicleDataUpdate> observe();
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'observed_event.dart';
|
||||
|
||||
/// One receive-side outcome from a vehicle-data transport.
|
||||
///
|
||||
/// Expected malformed or unsupported MAVLink frames are reported as drops and
|
||||
/// must not terminate the transport stream. Transport failures remain stream
|
||||
/// errors because the connection lifecycle must handle them separately.
|
||||
sealed class VehicleDataUpdate {
|
||||
const VehicleDataUpdate();
|
||||
}
|
||||
|
||||
/// A classified observation that is safe to present.
|
||||
final class ObservationReceived extends VehicleDataUpdate {
|
||||
const ObservationReceived(this.event);
|
||||
|
||||
final ObservedEvent event;
|
||||
}
|
||||
|
||||
enum ObservationDropKind { parser, semantic }
|
||||
|
||||
/// One or more rejected frames, retained only as bounded accounting.
|
||||
final class ObservationDropped extends VehicleDataUpdate {
|
||||
const ObservationDropped({required this.kind, this.count = 1})
|
||||
: assert(count > 0);
|
||||
|
||||
final ObservationDropKind kind;
|
||||
final int count;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import '../observations/observed_event.dart';
|
||||
import '../observations/vehicle_data_port.dart';
|
||||
import '../observations/vehicle_data_update.dart';
|
||||
|
||||
/// Deterministic observations for the first Ground Suite workflow.
|
||||
///
|
||||
/// This is intentionally a typed replay rather than a pretend connected
|
||||
/// vehicle. Raw frame replay moves behind the MAVLink decoder once bindings
|
||||
/// are generated from the MAVLink Common and Kaptan dialect definitions.
|
||||
final class ReplayVehicleDataPort implements VehicleDataPort {
|
||||
const ReplayVehicleDataPort();
|
||||
|
||||
@override
|
||||
String get label => 'Replay fixture';
|
||||
|
||||
@override
|
||||
bool get isReplay => true;
|
||||
|
||||
@override
|
||||
Stream<VehicleDataUpdate> observe() => Stream<VehicleDataUpdate>.fromIterable(
|
||||
_events.map(ObservationReceived.new),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
|
||||
static final List<ObservedEvent> _events = [
|
||||
HeartbeatEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12),
|
||||
systemId: 1,
|
||||
componentId: 1,
|
||||
armed: false,
|
||||
vehicleState: 'Standby',
|
||||
),
|
||||
TelemetryEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 1),
|
||||
kind: TelemetryKind.rawImu,
|
||||
),
|
||||
TelemetryEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 2),
|
||||
kind: TelemetryKind.distanceSensor,
|
||||
),
|
||||
LogEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 3),
|
||||
level: MavSeverity.warning,
|
||||
message: 'Range aid stale',
|
||||
flags: LogEvent.coalescedFlag,
|
||||
),
|
||||
ErrorEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 4),
|
||||
path: 'kaptan_drivers::lidar_lite_v3::error::Error::TransferFault',
|
||||
additionalInformation: 'The replay contains a bounded error detail.',
|
||||
flags: ErrorEvent.truncatedFlag,
|
||||
),
|
||||
LogEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 5),
|
||||
level: MavSeverity.info,
|
||||
message: 'Replay fixture complete',
|
||||
),
|
||||
ShutdownEvent(
|
||||
receivedAt: DateTime.utc(2026, 8, 10, 12, 0, 6),
|
||||
reason: KaptanShutdownReason.operator,
|
||||
),
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user