Merge pull request #297 from zjs81/dev-improments

Improvements to path tracing and location handling
This commit is contained in:
zjs81 2026-03-14 18:42:58 -07:00 committed by GitHub
commit 9199aab7f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 247 additions and 139 deletions

View file

@ -294,6 +294,10 @@ class MeshCoreConnector extends ChangeNotifier {
);
}
List<Contact> get allContacts => List.unmodifiable([
..._contacts,
..._discoveredContacts.where((c) => !c.isActive),
]);
List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts);
}
@ -726,6 +730,9 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys
..clear()
..addAll(cached.map((c) => c.publicKeyHex));
_contacts
..clear()
..addAll(cached);
for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex);
}
@ -1558,6 +1565,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data);
} else {
@ -2962,6 +2973,8 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame);
if (contact != null) {
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
@ -4770,6 +4783,12 @@ class MeshCoreConnector extends ChangeNotifier {
(_autoAddRoomServers && type == advTypeRoom) ||
(_autoAddSensors && type == advTypeSensor)) {
_handleContactAdvert(newContact);
_handleDiscovery(
newContact,
rawPacket,
noNotify: true,
addActive: true,
);
} else {
_handleDiscovery(newContact, rawPacket);
}
@ -4794,8 +4813,20 @@ class MeshCoreConnector extends ChangeNotifier {
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude,
longitude: hasLocation ? longitude : existing.longitude,
latitude:
hasLocation &&
latitude != null &&
latitude.abs() <= 90 &&
(latitude != 0 || longitude != 0)
? latitude
: existing.latitude,
longitude:
hasLocation &&
longitude != null &&
longitude.abs() <= 180 &&
(latitude != 0 || longitude != 0)
? longitude
: existing.longitude,
name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length,
@ -4866,11 +4897,11 @@ class MeshCoreConnector extends ChangeNotifier {
try {
reader.skipBytes(1); // Skip the response code byte
final flags = reader.readByte();
_autoAddUsers = flags & autoAddChatFlag != 0;
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0;
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0;
_autoAddSensors = flags & autoAddSensorFlag != 0;
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0;
_autoAddUsers = (flags & autoAddChatFlag) != 0;
_autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
_autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
_autoAddSensors = (flags & autoAddSensorFlag) != 0;
_overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
} catch (e) {
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
}
@ -4880,6 +4911,7 @@ class MeshCoreConnector extends ChangeNotifier {
Contact contact,
Uint8List rawPacket, {
bool noNotify = false,
bool addActive = false,
}) {
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
@ -4900,7 +4932,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: contact.longitude,
lastSeen: contact.lastSeen,
flags: 0,
isActive: false,
isActive: addActive,
);
notifyListeners();
unawaited(_persistDiscoveredContacts());
@ -4918,7 +4950,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: contact.longitude,
lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt,
isActive: false,
isActive: addActive,
flags: 0,
);
_discoveredContacts.add(disContact);

View file

@ -64,6 +64,8 @@ class MeshCoreUsbManager {
Future<void> write(Uint8List data) => _service.write(data);
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
// --- Label management ---
void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName);

View file

@ -40,6 +40,8 @@ class AppSettings {
final UnitSystem unitSystem;
final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts;
final String tcpServerAddress;
final int tcpServerPort;
AppSettings({
this.clearPathOnMaxRetry = false,
@ -68,6 +70,8 @@ class AppSettings {
this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true,
this.tcpServerAddress = '',
this.tcpServerPort = 0,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {};
@ -100,6 +104,8 @@ class AppSettings {
'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts,
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
};
}
@ -157,6 +163,8 @@ class AppSettings {
{},
mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true,
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
);
}
@ -187,6 +195,8 @@ class AppSettings {
UnitSystem? unitSystem,
Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts,
String? tcpServerAddress,
int? tcpServerPort,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@ -225,6 +235,8 @@ class AppSettings {
mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
);
}
}

View file

@ -65,7 +65,17 @@ class Contact {
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
bool get hasLocation {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({
@ -108,7 +118,7 @@ class Contact {
}
String get pathIdList {
final pathBytes = _pathBytesForDisplay;
final pathBytes = pathBytesForDisplay;
if (pathBytes.isEmpty) return '';
final parts = <String>[];
final groupSize = pathHashSize;
@ -130,43 +140,7 @@ class Contact {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
}
Uint8List? get traceRouteBytes {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
Uint8List get pathBytesForDisplay {
if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0);
@ -197,6 +171,7 @@ class Contact {
double? lat, lon;
final latRaw = reader.readInt32LE();
final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6;
lon = lonRaw / 1e6;

View file

@ -40,10 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
@ -65,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: primaryPath,
flipPathRound: true,
reversePathRound: !message.isOutgoing && !channelMessage,
flipPathAround: true,
reversePathAround:
!(!channelMessage && !message.isOutgoing),
),
),
),
@ -367,10 +365,7 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[];

View file

@ -858,7 +858,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
flipPathAround: true,
targetContact: widget.contact,
),
),
@ -1027,7 +1027,7 @@ class _ChatScreenState extends State<ChatScreen> {
final currentPathLabel = _currentPathLabel(currentContact);
// Filter out the current contact from available contacts
final availableContacts = connector.contacts
final availableContacts = connector.allContacts
.where((c) => c != widget.contact)
.toList();

View file

@ -1239,7 +1239,7 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0
title: contact.pathBytesForDisplay.isNotEmpty
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
@ -1247,10 +1247,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0),
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
),
),
);
@ -1275,10 +1277,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context,
MaterialPageRoute(
builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0
title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0),
path: contact.pathBytesForDisplay,
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
),
),
);
@ -1320,7 +1324,8 @@ class _ContactsScreenState extends State<ContactsScreen>
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.traceRouteBytes ?? Uint8List(0),
path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
),
),

View file

@ -137,10 +137,7 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings;
final allContacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts.where((c) => !c.isActive),
];
final allContacts = connector.allContacts;
final contacts = settings.mapShowDiscoveryContacts
? allContacts
@ -179,20 +176,13 @@ class _MapScreenState extends State<MapScreen> {
// Filter by location
final contactsWithLocation = filteredByKeyPrefix.where((c) {
if (!c.hasLocation) {
return false;
}
return _checkLocationPlausibility(c.latitude!, c.longitude!);
return c.hasLocation;
}).toList();
// All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = allContacts
.where(
(c) =>
c.hasLocation &&
_checkLocationPlausibility(c.latitude!, c.longitude!),
)
.where((c) => c.hasLocation)
.toList();
// Compute guessed locations with caching

View file

@ -124,10 +124,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame);
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
try {
final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());

View file

@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
final String title;
final Uint8List path;
final int? repeaterId;
final bool flipPathRound;
final bool reversePathRound;
final bool flipPathAround;
final bool reversePathAround;
final Contact? targetContact;
const PathTraceMapScreen({
@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
required this.title,
required this.path,
this.repeaterId,
this.flipPathRound = false,
this.reversePathRound = false,
this.flipPathAround = false,
this.reversePathAround = false,
this.targetContact,
});
@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
@ -158,21 +159,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
});
}
final Uint8List path;
Uint8List pathTmp = widget.reversePathRound
final pathTmp = widget.reversePathAround
? Uint8List.fromList(widget.path.reversed.toList())
: widget.path;
if (widget.flipPathRound) {
path = buildPath(pathTmp);
} else {
path = pathTmp;
}
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen',
noNotify: !mounted,
);
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@ -263,10 +259,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList();
Map<int, Contact> pathContacts = {};
final contacts = <Contact>[
...connector.contacts,
...connector.discoveredContacts,
];
final contacts = connector.allContacts;
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) {
if (listEquals(
@ -312,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Compute endpoint position for the target contact.
LatLng? targetPos;
bool targetGuessed = false;
final target = widget.targetContact;
if (target != null) {
if (target.hasLocation) {
targetPos = LatLng(target.latitude!, target.longitude!);
} else if (pathData.isNotEmpty) {
_targetContact = widget.targetContact;
if (_targetContact != null) {
final tc = _targetContact!;
if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathRound), the target-side hop sits
// in the middle of the symmetric sequence; .last is the local side.
final lastHop = (widget.flipPathRound && pathData.length > 1)
? pathData[(pathData.length - 1) ~/ 2]
: pathData.last;
final peers = connector.contacts
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = widget.reversePathAround
? widget.path.first
: widget.path.last;
final peers = connector.allContacts
.where(
(c) =>
c.hasLocation &&
@ -339,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
peers.length;
const offsetDeg = 0.003;
final angle = (target.publicKey[1] / 255.0) * 2 * pi;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else if (inferredPositions.containsKey(lastHop)) {
final lat = inferredPositions[lastHop]!.latitude;
final lon = inferredPositions[lastHop]!.longitude;
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else {
// As a last resort, just place it at the same position as the last hop.
final contact = pathContacts[lastHop];
if (contact != null && contact.hasLocation) {
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
contact.latitude! + offsetDeg * cos(angle),
contact.longitude! + offsetDeg * sin(angle),
);
targetGuessed = true;
}
}
}
}
@ -353,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0;
int hopLastLast = 0;
for (final hop in _traceData!.pathData) {
if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths
}
final contact = _traceData!.pathContacts[hop];
if (contact != null && contact.hasLocation) {
_points.add(LatLng(contact.latitude!, contact.longitude!));
@ -361,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final inferred = inferredPositions[hop];
if (inferred != null) _points.add(inferred);
}
hopLastLast = hopLast;
hopLast = hop;
}
if (targetPos != null) {
if (_targetContact != null && _targetContact!.type == advTypeChat) {
_points.add(targetPos);
}
}
if (targetPos != null) _points.add(targetPos);
_polylines = _points.length > 1
? [
Polyline(
@ -451,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
],
),
),
if (_hasData) _buildMapPathTrace(context, tileCache),
if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_points.isEmpty &&
!_hasData &&
!_isLoading &&
@ -480,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers(
List<int> pathData, {
required bool showLabels,
required Contact? target,
}) {
final markers = <Marker>[];
int hopLast = 0;
int hopLastLast = 0;
for (final hop in pathData) {
final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation;
if (!hasGps && inferred == null) continue;
if (hop == hopLastLast && widget.flipPathAround) {
continue; //skip duplicate hops in round-trip paths
}
if (!hasGps && inferred == null) {
hopLastLast = hopLast;
hopLast = hop;
continue; //skip hops with no GPS and no inferred position
}
final point = hasGps
? LatLng(contact.latitude!, contact.longitude!)
: inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
markers.add(
Marker(
point: point,
@ -532,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
),
);
}
hopLastLast = hopLast;
hopLast = hop;
}
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
@ -581,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Add target contact endpoint marker.
final targetPos = _targetContactPosition;
if (targetPos != null) {
if (targetPos != null && target != null && target.type == advTypeChat) {
final isGuessed = _targetContactIsGuessed;
final targetName = widget.targetContact?.name ?? '?';
final targetName = target.name;
markers.add(
Marker(
point: targetPos,
@ -719,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Widget _buildMapPathTrace(
BuildContext context,
MapTileCacheService tileCache,
Contact? target,
) {
return FlutterMap(
key: _mapKey,
@ -757,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers: _buildHopMarkers(
_traceData!.pathData,
showLabels: _showNodeLabels,
target: target,
),
),
],

View file

@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
@ -27,8 +28,14 @@ class _TcpScreenState extends State<TcpScreen> {
@override
void initState() {
super.initState();
_hostController = TextEditingController();
_portController = TextEditingController(text: '5000');
_hostController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerAddress,
);
_portController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
: '',
);
_connector = context.read<MeshCoreConnector>();
_connectionListener = () {
@ -39,6 +46,12 @@ class _TcpScreenState extends State<TcpScreen> {
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected &&
!_navigatedToContacts) {
context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text,
);
context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0,
);
_navigatedToContacts = true;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()),

View file

@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) {
if (!_enabled && !kDebugMode) return;
if (!_enabled) {
@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
_entries.removeRange(0, _entries.length - maxEntries);
}
notifyListeners();
if (!noNotify) {
notifyListeners();
}
// Also print to console for development
debugPrint('[$tag] $message');
}
void info(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.info);
void info(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
}
void warn(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.warning);
void warn(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
}
void error(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.error);
void error(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
}
void clear() {

View file

@ -182,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated));
}
Future<void> setTcpServerAddress(String value) async {
await updateSettings(_settings.copyWith(tcpServerAddress: value));
}
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
}

View file

@ -189,6 +189,10 @@ class UsbSerialService {
serial.setStopBits1();
serial.setFlowControlNone();
serial.setRTS(false);
// Toggle DTR lowhigh so the device sees a fresh connection even
// if the previous disconnect didn't cleanly signal DTR drop.
serial.setDTR(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
serial.setDTR(true);
_serial = serial;
// Update the normalized port name to whichever candidate succeeded.
@ -249,6 +253,21 @@ class UsbSerialService {
_status = UsbSerialStatus.connected;
}
Future<void> writeRaw(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(data);
}
}
Future<void> write(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
@ -300,6 +319,7 @@ class UsbSerialService {
_serial = null;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort();
}
} catch (_) {
@ -350,6 +370,7 @@ class UsbSerialService {
final serial = _serial;
try {
if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); // synchronous C call kills the SerialThread
}
} catch (_) {}

View file

@ -127,6 +127,17 @@ class UsbSerialService {
}
}
Future<void> writeRaw(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
}
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
'write'.toJS,
data.toJS,
);
await promise.toDart;
}
Future<void> write(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');

View file

@ -23,23 +23,23 @@ class AppLogger {
bool get isEnabled => _enabled;
/// Log an info message
void info(String message, {String tag = 'App'}) {
void info(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) {
_service!.info(message, tag: tag);
_service!.info(message, tag: tag, noNotify: noNotify);
}
}
/// Log a warning message
void warn(String message, {String tag = 'App'}) {
void warn(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) {
_service!.warn(message, tag: tag);
_service!.warn(message, tag: tag, noNotify: noNotify);
}
}
/// Log an error message
void error(String message, {String tag = 'App'}) {
void error(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) {
_service!.error(message, tag: tag);
_service!.error(message, tag: tag, noNotify: noNotify);
}
}
@ -48,9 +48,10 @@ class AppLogger {
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) {
if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level);
_service!.log(message, tag: tag, level: level, noNotify: noNotify);
}
}
}

View file

@ -78,7 +78,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
flipPathAround: true,
targetContact: widget.contact,
),
),
@ -107,7 +107,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
}
final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts
final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList();

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
void _filterValidContacts() {
_validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3)
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList();
}

View file

@ -157,10 +157,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr,
widget.connector.currentSf,
);
final allContacts = [
...widget.connector.contacts,
...widget.connector.discoveredContacts,
];
final allContacts = widget.connector.allContacts;
final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name)