feat(#135): GPS state query via CMD_OFFBAND_GPS (0xC1) + poll flood fix (#140)

Offband 0xC1 GPS query: 1-byte request -> RESP 0xC1 ASCII reply
(enabled/detected/active/fix/baud/lat/lon/alt_cm/sats/time), parsed into
OffbandGpsStatus (lat/lon /1e6). New GPS-status section in the Location dialog
(live-fix vs stored banner + coords/sats/alt/time). Validated against RedCreek's
feat/gps-state-query test firmware.

#140: the per-minute GPS poll now uses the lightweight 0xC1 query instead of
CMD_APP_START (which re-walked channels + re-drained messages = the BLE re-init
flood); a live fix updates self-lat/lon. Validated on b42 (APP_START ~1/min ->
~0, 0xC1 polls clean every 60s, channel re-walk gone).

Gemini (fix-then-ship): atomic completer-claim in _handleOffbandGps to shrink
the stale-reply race; full elimination needs a protocol sequence id, but the
poll shares in-flight via the re-entrancy guard and the query button is disabled
during a request, so concurrent requests don't occur in practice. The hardcoded
snackbars Gemini flagged in _editLocation are pre-existing l10n debt, not
introduced here; all new strings are localized.

Closes #135
Closes #140

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/145/head
Strycher 3 weeks ago
parent 13e38230c6
commit 7f2548ca95

@ -13,6 +13,7 @@ import '../models/channel_message.dart';
import '../models/companion_radio_stats.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/offband_gps_status.dart';
import '../models/path_selection.dart';
import '../models/translation_support.dart';
import '../helpers/reaction_helper.dart';
@ -2549,6 +2550,7 @@ class MeshCoreConnector extends ChangeNotifier {
_firmwareVerCode = null;
_firmwareVersion = null;
_deviceModel = null;
_latestOffbandGpsStatus = null;
_batteryMillivolts = null;
_repeaterBatterySnapshots.clear();
_batteryRequested = false;
@ -2637,8 +2639,11 @@ class MeshCoreConnector extends ChangeNotifier {
_batteryPollTimer = null;
}
/// Start polling the radio's GPS-backed self-info every minute.
/// No-op if already running. Triggered when the radio reports `gps=1`.
/// Poll the radio's GPS position every minute via the lightweight Offband
/// `0xC1` query NOT `CMD_APP_START`, which re-inits the whole device
/// (re-walks channels, re-drains messages) and floods slow BLE links. (#140)
/// No-op if already running. Triggered when the radio reports `gps=1`; the
/// reply updates self-lat/lon via `_handleOffbandGps`. (#135)
void _startGpsLocationPolling() {
if (_gpsLocationPollTimer != null) return;
_gpsLocationPollTimer = Timer.periodic(_gpsLocationPollInterval, (timer) {
@ -2647,7 +2652,7 @@ class MeshCoreConnector extends ChangeNotifier {
_gpsLocationPollTimer = null;
return;
}
unawaited(sendFrame(buildAppStartFrame()));
unawaited(requestOffbandGps());
});
}
@ -3805,6 +3810,61 @@ class MeshCoreConnector extends ChangeNotifier {
await getChannels(force: true);
}
// --- Offband GPS query (CMD_OFFBAND_GPS / RESP_CODE_OFFBAND_GPS = 0xC1) (#135)
OffbandGpsStatus? _latestOffbandGpsStatus;
Completer<OffbandGpsStatus?>? _offbandGpsCompleter;
/// Latest GPS state from the most recent [requestOffbandGps], or null until
/// one completes (cleared on disconnect).
OffbandGpsStatus? get offbandGpsStatus => _latestOffbandGpsStatus;
/// Sends the 1-byte `0xC1` request and resolves with the parsed reply, or null
/// on timeout / disconnect. Companion-available (no observer gate). (#135)
Future<OffbandGpsStatus?> requestOffbandGps({
Duration timeout = const Duration(seconds: 5),
}) async {
if (!isConnected) return null;
final existing = _offbandGpsCompleter;
if (existing != null && !existing.isCompleted) {
// A query is already in flight share it rather than orphaning it.
return existing.future.timeout(timeout, onTimeout: () => null);
}
final completer = Completer<OffbandGpsStatus?>();
_offbandGpsCompleter = completer;
await sendFrame(buildOffbandGpsRequestFrame());
try {
return await completer.future.timeout(timeout);
} on TimeoutException {
return null;
} finally {
if (identical(_offbandGpsCompleter, completer)) {
_offbandGpsCompleter = null;
}
}
}
void _handleOffbandGps(Uint8List frame) {
if (frame.length < 2) return;
final text = utf8.decode(frame.sublist(1), allowMalformed: true);
final status = OffbandGpsStatus.parse(text);
// Claim the in-flight request's completer up front so a late reply can't
// complete a newer request's future (stale-data race). (#135)
final completer = _offbandGpsCompleter;
_offbandGpsCompleter = null;
_latestOffbandGpsStatus = status;
// A live fix is the device's real position — keep self-lat/lon (and the
// map) fresh from it, replacing what the old APP_START GPS poll did. (#140)
if (status.hasLiveFix) {
_selfLatitude = status.latitude;
_selfLongitude = status.longitude;
}
_appDebugLogService?.info('Offband GPS: $text', tag: 'GPS');
if (completer != null && !completer.isCompleted) {
completer.complete(status);
}
notifyListeners();
}
void _handleFrame(List<int> data) {
if (data.isEmpty) return;
_lastRxTime = DateTime.now();
@ -3823,6 +3883,9 @@ class MeshCoreConnector extends ChangeNotifier {
case respCodeDeviceInfo:
_handleDeviceInfo(frame);
break;
case respCodeOffbandGps:
_handleOffbandGps(frame);
break;
case respCodeSelfInfo:
debugPrint('Got SELF_INFO');
_handleSelfInfo(frame);

@ -255,6 +255,14 @@ const int respCodeCustomVars = 21;
const int respCodeAutoAddConfig = 25;
const int respCodeStats = 24;
/// Offband fork-only extension space (0xC0+) never collides with upstream,
/// never submitted upstream. Request and reply share the code. (#135)
const int cmdOffbandGps = 0xC1;
const int respCodeOffbandGps = 0xC1;
/// Request frame for [cmdOffbandGps] a bare 1-byte command, no payload. (#135)
Uint8List buildOffbandGpsRequestFrame() => Uint8List.fromList([cmdOffbandGps]);
const int statsTypeCore = 0;
const int statsTypeRadio = 1;
const int statsTypePackets = 2;

@ -167,6 +167,16 @@
"settings_locationGPSEnable": "GPS Enable",
"settings_locationGPSEnableSubtitle": "Enables GPS to automatically update location.",
"settings_locationIntervalSec": "Interval for GPS (Seconds)",
"settings_gpsStatusTitle": "GPS status",
"settings_gpsStatusRefresh": "Query GPS",
"settings_gpsStatusLiveFix": "Live GPS fix",
"settings_gpsStatusNoFix": "No fix — using stored/last position",
"settings_gpsStatusCoords": "Coordinates",
"settings_gpsStatusSats": "Satellites",
"settings_gpsStatusAltitude": "Altitude",
"settings_gpsStatusTime": "Fix time",
"settings_gpsStatusTapRefresh": "Tap Query GPS to read the current GPS state",
"settings_gpsStatusNoResponse": "No response — device may need the GPS-query firmware build",
"settings_locationIntervalInvalid": "Interval must be at least 60 seconds, and less than 86400 seconds.",
"settings_latitude": "Latitude",
"settings_longitude": "Longitude",

@ -802,6 +802,66 @@ abstract class AppLocalizations {
/// **'Interval for GPS (Seconds)'**
String get settings_locationIntervalSec;
/// No description provided for @settings_gpsStatusTitle.
///
/// In en, this message translates to:
/// **'GPS status'**
String get settings_gpsStatusTitle;
/// No description provided for @settings_gpsStatusRefresh.
///
/// In en, this message translates to:
/// **'Query GPS'**
String get settings_gpsStatusRefresh;
/// No description provided for @settings_gpsStatusLiveFix.
///
/// In en, this message translates to:
/// **'Live GPS fix'**
String get settings_gpsStatusLiveFix;
/// No description provided for @settings_gpsStatusNoFix.
///
/// In en, this message translates to:
/// **'No fix — using stored/last position'**
String get settings_gpsStatusNoFix;
/// No description provided for @settings_gpsStatusCoords.
///
/// In en, this message translates to:
/// **'Coordinates'**
String get settings_gpsStatusCoords;
/// No description provided for @settings_gpsStatusSats.
///
/// In en, this message translates to:
/// **'Satellites'**
String get settings_gpsStatusSats;
/// No description provided for @settings_gpsStatusAltitude.
///
/// In en, this message translates to:
/// **'Altitude'**
String get settings_gpsStatusAltitude;
/// No description provided for @settings_gpsStatusTime.
///
/// In en, this message translates to:
/// **'Fix time'**
String get settings_gpsStatusTime;
/// No description provided for @settings_gpsStatusTapRefresh.
///
/// In en, this message translates to:
/// **'Tap Query GPS to read the current GPS state'**
String get settings_gpsStatusTapRefresh;
/// No description provided for @settings_gpsStatusNoResponse.
///
/// In en, this message translates to:
/// **'No response — device may need the GPS-query firmware build'**
String get settings_gpsStatusNoResponse;
/// No description provided for @settings_locationIntervalInvalid.
///
/// In en, this message translates to:

@ -378,6 +378,38 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Интервал за GPS (Секунди)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.';

@ -380,6 +380,38 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Intervall für GPS (Sekunden)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Das Intervall muss mindestens 60 Sekunden und weniger als 86400 Sekunden betragen.';

@ -374,6 +374,38 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Interval for GPS (Seconds)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Interval must be at least 60 seconds, and less than 86400 seconds.';

@ -378,6 +378,38 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Intervalo para GPS (Segundos)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.';

@ -381,6 +381,38 @@ class AppLocalizationsFr extends AppLocalizations {
String get settings_locationIntervalSec =>
'Intervalle de mise-à-jour du GPS (Secondes)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'L\'intervalle doit être compris entre 60 et 86400 secondes.';

@ -380,6 +380,38 @@ class AppLocalizationsHu extends AppLocalizations {
String get settings_locationIntervalSec =>
'GPS-számolási intervallum (másodpercek)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Az intervallum legalább 60 másodpercnek, de legfeljebb 86400 másodpercnak kell lennie.';

@ -380,6 +380,38 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Intervallo GPS (Secondi)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'L\'intervallo deve essere di almeno 60 secondi e inferiore a 86400 secondi.';

@ -363,6 +363,38 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'GPS データの取得間隔(秒)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'間隔は少なくとも60秒で、86400秒未満でなければなりません。';

@ -363,6 +363,38 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'GPS 간격 (초)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'간격은 최소 60초 이상, 86400초 미만이어야 합니다.';

@ -377,6 +377,38 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Interval voor GPS (Seconden)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'De intervallen moeten minstens 60 seconden zijn en minder dan 86400 seconden.';

@ -383,6 +383,38 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Interwał dla GPS (Sekundy)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Interwał musi wynosić co najmniej 60 sekund i mniej niż 86400 sekund.';

@ -380,6 +380,38 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Intervalo para GPS (Segundos)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'O intervalo deve ser de pelo menos 60 segundos e inferior a 86400 segundos.';

@ -379,6 +379,38 @@ class AppLocalizationsRu extends AppLocalizations {
String get settings_locationIntervalSec =>
'Интервал для позиционирования GPS (секунды)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Интервал должен составлять не менее 60 секунд и не более 86400 секунд.';

@ -378,6 +378,38 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Interval pre GPS (Sekundy)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Interval musí byť aspoň 60 sekúnd a menej ako 86400 sekúnd.';

@ -376,6 +376,38 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Interval za GPS (Sekunde)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Intervallo mora biti vsaj 60 sekund in manj kot 86400 sekund.';

@ -375,6 +375,38 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Interval för GPS (Sekunder)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Intervalet måste vara minst 60 sekunder och mindre än 86400 sekunder.';

@ -377,6 +377,38 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'Інтервал для GPS (Секунди)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid =>
'Інтервал має бути не менше 60 секунд і менше 86400 секунд.';

@ -360,6 +360,38 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settings_locationIntervalSec => 'GPS 间隔(秒)';
@override
String get settings_gpsStatusTitle => 'GPS status';
@override
String get settings_gpsStatusRefresh => 'Query GPS';
@override
String get settings_gpsStatusLiveFix => 'Live GPS fix';
@override
String get settings_gpsStatusNoFix => 'No fix — using stored/last position';
@override
String get settings_gpsStatusCoords => 'Coordinates';
@override
String get settings_gpsStatusSats => 'Satellites';
@override
String get settings_gpsStatusAltitude => 'Altitude';
@override
String get settings_gpsStatusTime => 'Fix time';
@override
String get settings_gpsStatusTapRefresh =>
'Tap Query GPS to read the current GPS state';
@override
String get settings_gpsStatusNoResponse =>
'No response — device may need the GPS-query firmware build';
@override
String get settings_locationIntervalInvalid => '间隔时间必须至少为 60 秒,但不超过 86400 秒。';

@ -0,0 +1,64 @@
/// Parsed payload of a `RESP_CODE_OFFBAND_GPS` (0xC1) reply the on-demand GPS
/// state from an Offband-fork companion radio. (#135)
///
/// Wire format is one ASCII string of space-separated `key=value` tokens, e.g.
/// `enabled=1 detected=1 active=1 fix=1 lat=39421530 lon=-84449000 alt_cm=23390 sats=12 time=1782531085`.
/// `lat`/`lon` are 1e-6 degrees (same units as the SELF_INFO frame).
class OffbandGpsStatus {
final bool enabled;
final bool detected;
final bool active;
final bool fix;
final double? latitude;
final double? longitude;
final int? altitudeCm;
final int? satellites;
final DateTime? timestampUtc;
const OffbandGpsStatus({
required this.enabled,
required this.detected,
required this.active,
required this.fix,
this.latitude,
this.longitude,
this.altitudeCm,
this.satellites,
this.timestampUtc,
});
/// True when the radio reports a live satellite fix with usable coordinates
/// i.e. position is GPS-driven, not a stored/manual location. (#135)
bool get hasLiveFix => fix && latitude != null && longitude != null;
/// Parses the ASCII `key=value` reply string. Unknown/missing keys are
/// tolerated (null); `lat`/`lon` are scaled from 1e-6 degrees.
static OffbandGpsStatus parse(String raw) {
final fields = <String, String>{};
for (final token in raw.trim().split(RegExp(r'\s+'))) {
final eq = token.indexOf('=');
if (eq > 0) fields[token.substring(0, eq)] = token.substring(eq + 1);
}
int? asInt(String key) {
final v = fields[key];
return v == null ? null : int.tryParse(v);
}
final lat = asInt('lat');
final lon = asInt('lon');
final time = asInt('time');
return OffbandGpsStatus(
enabled: fields['enabled'] == '1',
detected: fields['detected'] == '1',
active: fields['active'] == '1',
fix: fields['fix'] == '1',
latitude: lat == null ? null : lat / 1e6,
longitude: lon == null ? null : lon / 1e6,
altitudeCm: asInt('alt_cm'),
satellites: asInt('sats'),
timestampUtc: (time == null || time <= 0)
? null
: DateTime.fromMillisecondsSinceEpoch(time * 1000, isUtc: true),
);
}
}

@ -8,6 +8,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/offband_gps_status.dart';
import '../models/radio_settings.dart';
import '../services/app_debug_log_service.dart';
import '../connector/observer_config_client.dart';
@ -680,6 +681,124 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildGpsQuerySection(
BuildContext context,
OffbandGpsStatus? status,
bool querying,
bool queriedOnce,
VoidCallback onRefresh,
) {
final l10n = context.l10n;
final theme = Theme.of(context);
Widget row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
),
);
final children = <Widget>[
const SizedBox(height: 8),
const Divider(),
Row(
children: [
Expanded(
child: Text(
l10n.settings_gpsStatusTitle,
style: theme.textTheme.titleSmall,
),
),
if (querying)
const Padding(
padding: EdgeInsets.all(8),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(Icons.refresh),
tooltip: l10n.settings_gpsStatusRefresh,
onPressed: onRefresh,
),
],
),
];
if (status != null) {
final live = status.hasLiveFix;
final color = live ? Colors.green : Colors.orange;
children.add(
Row(
children: [
Icon(
live ? Icons.gps_fixed : Icons.gps_off,
size: 18,
color: color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
live
? l10n.settings_gpsStatusLiveFix
: l10n.settings_gpsStatusNoFix,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
),
],
),
);
children.add(const SizedBox(height: 8));
final coords = (status.latitude != null && status.longitude != null)
? '${status.latitude!.toStringAsFixed(6)}, '
'${status.longitude!.toStringAsFixed(6)}'
: '';
children.add(row(l10n.settings_gpsStatusCoords, coords));
children.add(
row(l10n.settings_gpsStatusSats, status.satellites?.toString() ?? ''),
);
children.add(
row(
l10n.settings_gpsStatusAltitude,
status.altitudeCm != null
? '${(status.altitudeCm! / 100).toStringAsFixed(1)} m'
: '',
),
);
final t = status.timestampUtc?.toLocal();
final ml = MaterialLocalizations.of(context);
final timeStr = t == null
? ''
: '${ml.formatCompactDate(t)} '
'${ml.formatTimeOfDay(TimeOfDay.fromDateTime(t))}';
children.add(row(l10n.settings_gpsStatusTime, timeStr));
} else {
children.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
queriedOnce
? l10n.settings_gpsStatusNoResponse
: l10n.settings_gpsStatusTapRefresh,
style: theme.textTheme.bodySmall,
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
}
void _editLocation(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
final latController = TextEditingController();
@ -698,6 +817,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
intervalController.text = currentInterval.toString();
OffbandGpsStatus? gpsStatus = connector.offbandGpsStatus;
bool queryingGps = false;
bool queriedOnce = false;
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
@ -763,6 +886,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
),
_buildGpsQuerySection(
context,
gpsStatus,
queryingGps,
queriedOnce,
() async {
setDialogState(() => queryingGps = true);
final s = await connector.requestOffbandGps();
if (!dialogContext.mounted) return;
setDialogState(() {
queryingGps = false;
queriedOnce = true;
gpsStatus = s;
});
},
),
],
],
),

@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/offband_gps_status.dart';
void main() {
group('OffbandGpsStatus.parse', () {
test('parses a full reply with a live fix', () {
final s = OffbandGpsStatus.parse(
'enabled=1 detected=1 active=1 fix=1 lat=39421530 lon=-84449000 '
'alt_cm=23390 sats=12 time=1782531085',
);
expect(s.enabled, isTrue);
expect(s.detected, isTrue);
expect(s.active, isTrue);
expect(s.fix, isTrue);
expect(s.latitude, closeTo(39.42153, 1e-9));
expect(s.longitude, closeTo(-84.449, 1e-9));
expect(s.altitudeCm, 23390);
expect(s.satellites, 12);
expect(
s.timestampUtc,
DateTime.fromMillisecondsSinceEpoch(1782531085 * 1000, isUtc: true),
);
expect(s.hasLiveFix, isTrue);
});
test('no fix → flags false, hasLiveFix false even with stale coords', () {
final s = OffbandGpsStatus.parse(
'enabled=1 detected=1 active=0 fix=0 lat=39421530 lon=-84449000 '
'sats=0 time=0',
);
expect(s.fix, isFalse);
expect(s.active, isFalse);
expect(s.hasLiveFix, isFalse);
expect(s.timestampUtc, isNull); // time=0 null
});
test('missing optional keys are tolerated', () {
final s = OffbandGpsStatus.parse('enabled=0 detected=0 active=0 fix=0');
expect(s.enabled, isFalse);
expect(s.latitude, isNull);
expect(s.longitude, isNull);
expect(s.altitudeCm, isNull);
expect(s.satellites, isNull);
expect(s.timestampUtc, isNull);
expect(s.hasLiveFix, isFalse);
});
test('extra whitespace and unknown keys are ignored', () {
final s = OffbandGpsStatus.parse(
' enabled=1 fix=1 foo=bar lat=1000000 lon=2000000 ',
);
expect(s.enabled, isTrue);
expect(s.fix, isTrue);
expect(s.latitude, closeTo(1.0, 1e-9));
expect(s.longitude, closeTo(2.0, 1e-9));
expect(s.hasLiveFix, isTrue);
});
});
}

@ -2,6 +2,16 @@
"bg": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -16,6 +26,16 @@
"de": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -30,6 +50,16 @@
"es": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -44,6 +74,16 @@
"fr": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -58,6 +98,16 @@
"hu": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -72,6 +122,16 @@
"it": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -86,6 +146,16 @@
"ja": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -100,6 +170,16 @@
"ko": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -114,6 +194,16 @@
"nl": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -128,6 +218,16 @@
"pl": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -142,6 +242,16 @@
"pt": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -156,6 +266,16 @@
"ru": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -170,6 +290,16 @@
"sk": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -184,6 +314,16 @@
"sl": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -198,6 +338,16 @@
"sv": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -212,6 +362,16 @@
"uk": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",
@ -226,6 +386,16 @@
"zh": [
"settings_messageSettings",
"settings_messageSettingsSubtitle",
"settings_gpsStatusTitle",
"settings_gpsStatusRefresh",
"settings_gpsStatusLiveFix",
"settings_gpsStatusNoFix",
"settings_gpsStatusCoords",
"settings_gpsStatusSats",
"settings_gpsStatusAltitude",
"settings_gpsStatusTime",
"settings_gpsStatusTapRefresh",
"settings_gpsStatusNoResponse",
"settings_infoFirmware",
"settings_infoModel",
"appSettings_clock",

Loading…
Cancel
Save

Powered by TurnKey Linux.