diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index ac4b6de..5c6b553 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,4 +1,6 @@ +import 'dart:collection'; import 'dart:convert'; +import 'dart:ffi'; import 'dart:typed_data'; // Command codes (to device) @@ -29,6 +31,8 @@ const int cmdGetContactByKey = 30; const int cmdGetChannel = 31; const int cmdSetChannel = 32; const int cmdGetRadioSettings = 57; +const int cmdGetTelemetryReq = 39; +const int cmdSendBinaryReq = 50; // Text message types const int txtTypePlain = 0; @@ -73,6 +77,9 @@ const int pushCodeLoginFail = 0x86; const int pushCodeStatusResponse = 0x87; const int pushCodeLogRxData = 0x88; const int pushCodeNewAdvert = 0x8A; +const int pushCodeTelemetryResponse = 0x8B; +const int pushCodeBinaryResponse = 0x8C; + // Contact/advertisement types const int advTypeChat = 1; @@ -614,3 +621,23 @@ Uint8List buildSendCliCommandFrame( frame[frame.length - 1] = 0; // null terminator return frame; } + +//Build a telemetry request frame +//Format: [cmd][pub_key x32][req_type][payload] +Uint8List buildSendBinaryReq( + Uint8List repeaterPubKey, + { + int attempt = 0, + int? timestampSeconds, + Uint8List? payload, + }) { + int offset = 0; + final frame = Uint8List(1 + 32 + 1 + (payload?.length ?? 0)); + frame[offset++] = cmdSendBinaryReq; + frame.setRange(offset, offset + 32, repeaterPubKey); + if (payload != null && payload.isNotEmpty) { + offset += 32; + frame.setRange(offset, offset + payload.length, payload); + } + return frame; +} \ No newline at end of file diff --git a/lib/helpers/buffer_reader.dart b/lib/helpers/buffer_reader.dart new file mode 100644 index 0000000..6bb4679 --- /dev/null +++ b/lib/helpers/buffer_reader.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class BufferReader { + int pointer = 0; + final Uint8List buffer; + + BufferReader(Uint8List data) : buffer = Uint8List.fromList(data); + + int getRemainingBytesCount() { + return buffer.length - pointer; + } + + int readByte() { + return readBytes(1)[0]; + } + + Uint8List readBytes(int count) { + final data = buffer.sublist(pointer, pointer + count); + pointer += count; + return data; + } + + Uint8List readRemainingBytes() { + return readBytes(getRemainingBytesCount()); + } + + String readString() { + return utf8.decode(readRemainingBytes()); + } + + String readCString(int maxLength) { + final value = []; + final bytes = readBytes(maxLength); + for (final byte in bytes) { + // if we find a null terminator character, we have reached the end of the cstring + if (byte == 0) { + return utf8.decode(Uint8List.fromList(value)); + } + value.add(byte); + } + return utf8.decode(Uint8List.fromList(value)); + } + + int readInt8() { + final bytes = readBytes(1); + return ByteData.view(bytes.buffer).getInt8(0); + } + + int readUInt8() { + final bytes = readBytes(1); + return ByteData.view(bytes.buffer).getUint8(0); + } + + int readUInt16LE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getUint16(0, Endian.little); + } + + int readUInt16BE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getUint16(0, Endian.big); + } + + int readUInt32LE() { + final bytes = readBytes(4); + return ByteData.view(bytes.buffer).getUint32(0, Endian.little); + } + + int readUInt32BE() { + final bytes = readBytes(4); + return ByteData.view(bytes.buffer).getUint32(0, Endian.big); + } + + int readInt16LE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getInt16(0, Endian.little); + } + + int readInt16BE() { + final bytes = readBytes(2); + return ByteData.view(bytes.buffer).getInt16(0, Endian.big); + } + + int readInt32LE() { + final bytes = readBytes(4); + return ByteData.view(bytes.buffer).getInt32(0, Endian.little); + } + + int readInt24BE() { + // read 24-bit (3 bytes) big endian integer + var value = (readByte() << 16) | (readByte() << 8) | readByte(); + + // convert 24-bit signed integer to 32-bit signed integer + // 0x800000 is the sign bit for a 24-bit value + // if it's set, value is negative in 24-bit two's complement + if ((value & 0x800000) != 0) { + value -= 0x1000000; + } + + return value; + } +} diff --git a/lib/helpers/buffer_writer.dart b/lib/helpers/buffer_writer.dart new file mode 100644 index 0000000..945acc4 --- /dev/null +++ b/lib/helpers/buffer_writer.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +class BufferWriter { + final BytesBuilder _builder = BytesBuilder(); + + Uint8List toBytes() { + return _builder.toBytes(); + } + + void writeBytes(Uint8List bytes) { + _builder.add(bytes); + } + + void writeByte(int byte) { + _builder.addByte(byte); + } + + void writeUInt16LE(int num) { + final bytes = Uint8List(2); + final data = ByteData.view(bytes.buffer); + data.setUint16(0, num, Endian.little); + writeBytes(bytes); + } + + void writeUInt32LE(int num) { + final bytes = Uint8List(4); + final data = ByteData.view(bytes.buffer); + data.setUint32(0, num, Endian.little); + writeBytes(bytes); + } + + void writeInt32LE(int num) { + final bytes = Uint8List(4); + final data = ByteData.view(bytes.buffer); + data.setInt32(0, num, Endian.little); + writeBytes(bytes); + } + + void writeString(String string) { + writeBytes(Uint8List.fromList(utf8.encode(string))); + } + + void writeCString(String string, int maxLength) { + final bytes = Uint8List(maxLength); + final encodedString = utf8.encode(string); + + for (var i = 0; i < maxLength && i < encodedString.length; i++) { + bytes[i] = encodedString[i]; + } + + // ensure the last byte is always a null terminator + bytes[maxLength - 1] = 0; + + writeBytes(bytes); + } +} diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart new file mode 100644 index 0000000..5ee1307 --- /dev/null +++ b/lib/helpers/cayenne_lpp.dart @@ -0,0 +1,191 @@ +import 'dart:typed_data'; +import 'buffer_reader.dart'; +import 'buffer_writer.dart'; + +class CayenneLpp { + static const int lppDigitalInput = 0; // 1 byte + static const int lppDigitalOutput = 1; // 1 byte + static const int lppAnalogInput = 2; // 2 bytes, 0.01 signed + static const int lppAnalogOutput = 3; // 2 bytes, 0.01 signed + static const int lppGenericSensor = 100; // 4 bytes, unsigned + static const int lppLuminosity = 101; // 2 bytes, 1 lux unsigned + static const int lppPresence = 102; // 1 byte, bool + static const int lppTemperature = 103; // 2 bytes, 0.1°C signed + static const int lppRelativeHumidity = 104; // 1 byte, 0.5% unsigned + static const int lppAccelerometer = 113; // 2 bytes per axis, 0.001G + static const int lppBarometricPressure = 115; // 2 bytes 0.1hPa unsigned + static const int lppVoltage = 116; // 2 bytes 0.01V unsigned + static const int lppCurrent = 117; // 2 bytes 0.001A unsigned + static const int lppFrequency = 118; // 4 bytes 1Hz unsigned + static const int lppPercentage = 120; // 1 byte 1-100% unsigned + static const int lppAltitude = 121; // 2 byte 1m signed + static const int lppConcentration = 125; // 2 bytes, 1 ppm unsigned + static const int lppPower = 128; // 2 byte, 1W, unsigned + static const int lppDistance = 130; // 4 byte, 0.001m, unsigned + static const int lppEnergy = 131; // 4 byte, 0.001kWh, unsigned + static const int lppDirection = 132; // 2 bytes, 1deg, unsigned + static const int lppUnixTime = 133; // 4 bytes, unsigned + static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s + static const int lppColour = 135; // 1 byte per RGB Color + static const int lppGps = 136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter + static const int lppSwitch = 142; // 1 byte, 0/1 + static const int lppPolyline = 240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas + + final BufferWriter _writer = BufferWriter(); + + Uint8List toBytes() { + return _writer.toBytes(); + } + + void addDigitalInput(int channel, int value) { + _writer.writeByte(channel); + _writer.writeByte(lppDigitalInput); + _writer.writeByte(value); + } + + void addTemperature(int channel, double value) { + _writer.writeByte(channel); + _writer.writeByte(lppTemperature); + final val = (value * 10).toInt(); + _writer.writeBytes(_int16ToBE(val)); + } + + void addVoltage(int channel, double value) { + _writer.writeByte(channel); + _writer.writeByte(lppVoltage); + final val = (value * 100).toInt(); + _writer.writeBytes(_int16ToBE(val)); + } + + void addGps(int channel, double lat, double lon, double alt) { + _writer.writeByte(channel); + _writer.writeByte(lppGps); + _writer.writeBytes(_int24ToBE((lat * 10000).toInt())); + _writer.writeBytes(_int24ToBE((lon * 10000).toInt())); + _writer.writeBytes(_int24ToBE((alt * 100).toInt())); + } + + Uint8List _int16ToBE(int value) { + final bytes = Uint8List(2); + final data = ByteData.view(bytes.buffer); + data.setInt16(0, value, Endian.big); + return bytes; + } + + Uint8List _int24ToBE(int value) { + final bytes = Uint8List(3); + bytes[0] = (value >> 16) & 0xFF; + bytes[1] = (value >> 8) & 0xFF; + bytes[2] = value & 0xFF; + return bytes; + } + + static List> parse(Uint8List bytes) { + final buffer = BufferReader(bytes); + final telemetry = >[]; + + while (buffer.getRemainingBytesCount() >= 2) { + final channel = buffer.readUInt8(); + final type = buffer.readUInt8(); + + if (channel == 0 && type == 0) { + break; + } + + switch (type) { + case lppGenericSensor: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt32BE(), + }); + break; + case lppLuminosity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPresence: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppTemperature: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 10, + }); + break; + case lppRelativeHumidity: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8() / 2, + }); + break; + case lppBarometricPressure: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE() / 10, + }); + break; + case lppVoltage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 100, + }); + break; + case lppCurrent: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readInt16BE() / 1000, + }); + break; + case lppPercentage: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt8(), + }); + break; + case lppConcentration: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppPower: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': buffer.readUInt16BE(), + }); + break; + case lppGps: + telemetry.add({ + 'channel': channel, + 'type': type, + 'value': { + 'latitude': buffer.readInt24BE() / 10000, + 'longitude': buffer.readInt24BE() / 10000, + 'altitude': buffer.readInt24BE() / 100, + }, + }); + break; + default: + return telemetry; + } + } + + return telemetry; + } +} diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index f9ba230..8601daa 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -3,6 +3,7 @@ import '../models/contact.dart'; import 'repeater_status_screen.dart'; import 'repeater_cli_screen.dart'; import 'repeater_settings_screen.dart'; +import 'telemetry_screen.dart'; class RepeaterHubScreen extends StatelessWidget { final Contact repeater; @@ -100,6 +101,26 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), + const SizedBox(height: 16), + // Status button + _buildManagementCard( + context, + icon: Icons.bar_chart_sharp, + title: 'Telemetry', + subtitle: 'View telemetry of sensors and system stats', + color: Colors.teal, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TelemetryScreen( + repeater: repeater, + password: password, + ), + ), + ); + }, + ), const SizedBox(height: 12), // CLI button _buildManagementCard( diff --git a/lib/screens/telemetry_screen.dart b/lib/screens/telemetry_screen.dart new file mode 100644 index 0000000..1d0beff --- /dev/null +++ b/lib/screens/telemetry_screen.dart @@ -0,0 +1,271 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/contact.dart'; +import '../models/path_selection.dart'; +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../services/repeater_command_service.dart'; +import '../widgets/path_management_dialog.dart'; +import '../helpers/cayenne_lpp.dart'; + +class TelemetryScreen extends StatefulWidget { + final Contact repeater; + final String password; + + const TelemetryScreen({ + super.key, + required this.repeater, + required this.password, + }); + + @override + State createState() => _TelemetryScreenState(); +} + +class _TelemetryScreenState extends State { + static const int _statusPayloadOffset = 8; + static const int _statusStatsSize = 52; + static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; + Uint8List _tagData = Uint8List(4); + int _timeEstment = 0; + + bool _isLoading = false; + Timer? _statusTimeout; + StreamSubscription? _frameSubscription; + RepeaterCommandService? _commandService; + PathSelection? _pendingStatusSelection; + + @override + void initState() { + super.initState(); + final connector = Provider.of(context, listen: false); + _commandService = RepeaterCommandService(connector); + _setupMessageListener(); + _loadTelemetry(); + } + + void _setupMessageListener() { + final connector = Provider.of(context, listen: false); + + // Listen for incoming text messages from the repeater + _frameSubscription = connector.receivedFrames.listen((frame) { + if (frame.isEmpty) return; + + if(frame[0] == respCodeSent){ + _tagData = frame.sublist(2, 6); + _timeEstment = frame.buffer.asByteData().getUint32(6, Endian.little); + } + + // Check if it's a binary response + if (frame[0] == pushCodeBinaryResponse && listEquals(frame.sublist(2, 6), _tagData)) { + _handleStatusResponse(context, frame.sublist(6)); + } + }); +} + + void _handleStatusResponse(BuildContext context, Uint8List frame) { + + final parsedTelemetry = CayenneLpp.parse(frame); + for (final entry in parsedTelemetry) { + print('Telemetry - Channel: ${entry['channel']}, Type: ${entry['type']}, Value: ${entry['value']}'); + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Received status response (not implemented).'), + backgroundColor: Colors.green, + ) + ); + _statusTimeout?.cancel(); + if (!mounted) return; + setState(() { + _isLoading = false; + }); + } + + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.repeater.publicKeyHex, + orElse: () => widget.repeater, + ); + } + + Future _loadTelemetry() async { + if (_commandService == null) return; + + setState(() { + _isLoading = true; + }); + try { + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); + final selection = await connector.preparePathForContactSend(repeater); + _pendingStatusSelection = selection; + final frame = buildSendBinaryReq(repeater.publicKey, payload: Uint8List.fromList([reqTypeGetTelemetry])); + await connector.sendFrame(frame); + + final pathLengthValue = selection.useFlood ? -1 : selection.hopCount; + final messageBytes = frame.length >= _statusResponseBytes + ? frame.length + : _statusResponseBytes; + final timeoutMs = connector.calculateTimeout( + pathLength: pathLengthValue, + messageBytes: messageBytes, + ); + _statusTimeout?.cancel(); + _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () { + if (!mounted) return; + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Status request timed out.'), + backgroundColor: Colors.red, + ), + ); + _recordStatusResult(false); + }); + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error loading status: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() { + _isLoading = false; + }); + } + } + + void _recordStatusResult(bool success) { + final selection = _pendingStatusSelection; + if (selection == null) return; + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); + connector.recordRepeaterPathResult(repeater, selection, success, null); + _pendingStatusSelection = null; + } + + @override + void dispose() { + _frameSubscription?.cancel(); + _commandService?.dispose(); + _statusTimeout?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Repeater Status'), + Text( + repeater.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), + ), + ], + ), + centerTitle: false, + actions: [ + PopupMenuButton( + icon: Icon(isFloodMode ? Icons.waves : Icons.route), + tooltip: 'Routing mode', + onSelected: (mode) async { + if (mode == 'flood') { + await connector.setPathOverride(repeater, pathLen: -1); + } else { + await connector.setPathOverride(repeater, pathLen: null); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'auto', + child: Row( + children: [ + Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Auto (use saved path)', + style: TextStyle( + fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + PopupMenuItem( + value: 'flood', + child: Row( + children: [ + Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), + const SizedBox(width: 8), + Text( + 'Force Flood Mode', + style: TextStyle( + fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.timeline), + tooltip: 'Path management', + onPressed: () => PathManagementDialog.show(context, contact: repeater), + ), + IconButton( + icon: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + onPressed: _isLoading ? null : _loadTelemetry, + tooltip: 'Refresh', + ), + ], + ), + body: SafeArea( + top: false, + child: RefreshIndicator( + onRefresh: _loadTelemetry, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text("Not implemented yet", style: Theme.of(context).textTheme.bodyMedium), + //_buildSystemInfoCard(), + //const SizedBox(height: 16), + //_buildRadioStatsCard(), + //const SizedBox(height: 16), + //_buildPacketStatsCard(), + ], + ), + ), + ), + ); + } +} \ No newline at end of file