Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 58 additions & 23 deletions lib/core/meshtastic/data/meshtastic_client_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,37 @@ const String _meshtasticServiceUuid = '6ba1b218-15a8-461f-9fa8-5dcae273eafd';
/// Channel slots every Meshtastic radio reports (`MAX_NUM_CHANNELS`).
/// Production impl: talks BLE to a Meshtastic radio.
class MeshtasticClientImpl implements MeshtasticService {
MeshtasticClientImpl({
mesh.MeshtasticClient? client,
Future<bool> Function()? debugBluetoothSupported,
Stream<BluetoothAdapterState>? debugAdapterStates,
Duration debugAdapterTimeout = const Duration(seconds: 8),
List<BluetoothDevice> Function()? debugConnectedDevices,
Future<List<BluetoothDevice>> Function(List<Guid>)? debugSystemDevices,
// `client` stays public. `this._client` would hide the seam from tests,
// and the field is reassigned the first time the radio is created.
// ignore: prefer_initializing_formals
}) : _client = client,
_bluetoothSupported = debugBluetoothSupported,
_adapterStates = debugAdapterStates,
_adapterTimeout = debugAdapterTimeout,
_connectedDevices = debugConnectedDevices,
_systemDevices = debugSystemDevices;

mesh.MeshtasticClient? _client;
final Future<bool> Function()? _bluetoothSupported;
final Stream<BluetoothAdapterState>? _adapterStates;
final Duration _adapterTimeout;
final List<BluetoothDevice> Function()? _connectedDevices;
final Future<List<BluetoothDevice>> Function(List<Guid>)? _systemDevices;

/// How long a channel write waits for the radio to read the slot back.
///
/// Eight seconds in production: a managed-mode radio never replies, and
/// that silence is the signal. Tests shorten it so the timeout branch does
/// not wait out the real window.
@visibleForTesting
Duration channelConfirmTimeout = const Duration(seconds: 8);
bool _initialized = false;
int? _cachedSdk;
bool _logBridgeInstalled = false;
Expand All @@ -54,25 +84,29 @@ class MeshtasticClientImpl implements MeshtasticService {
StreamController<MeshTraffic>.broadcast();
final MeshTrafficCounter _counter = MeshTrafficCounter();

bool _rxHooked = false;

mesh.MeshtasticClient get _c {
_installLogBridge();
final existing = _client;
if (existing != null) return existing;
final client =
existing ?? mesh.MeshtasticClient(now: () => AppTime.utc.toLocal());
// Every timestamp the transport stamps comes from the calibrated clock,
// not the device's. The 24-hour retention window and the chart axes are
// computed against `AppTime`, so a device clock that is off by three hours
// would file each reading three hours out of place — the retention would
// drop rows that are not old yet, and "last reading" would show the
// offset instead of the age.
final created = mesh.MeshtasticClient(now: () => AppTime.utc.toLocal());
_client = created;
// Counted here, from one permanent subscription — not inside the mapped
// public streams, which run once per listener (and not at all when nobody
// is listening).
// Never cancelled: the client lives as long as the app does, and the
// counters are session totals.
created.packetStream.listen(_countRx);
return created;
_client = client;
if (!_rxHooked) {
_rxHooked = true;
// Counted here, from one permanent subscription — not inside the mapped
// public streams, which run once per listener (and not at all when
// nobody is listening). Never cancelled: the client lives as long as
// the app does, and the counters are session totals.
client.packetStream.listen(_countRx);
}
return client;
}

void _countRx(mesh.MeshPacketWrapper packet) {
Expand Down Expand Up @@ -164,7 +198,9 @@ class MeshtasticClientImpl implements MeshtasticService {
// bluetoothConnect/bluetoothScan as permanently denied, so it always
// throws there. Permissions are handled above; do its remaining
// environment checks here.
if (!await FlutterBluePlus.isSupported) {
final supported =
await (_bluetoothSupported?.call() ?? FlutterBluePlus.isSupported);
if (!supported) {
Log.warning('meshtastic initialize: bluetooth not supported');
return const Err(
UnexpectedFailure('Bluetooth is not supported on this device'),
Expand Down Expand Up @@ -235,9 +271,9 @@ class MeshtasticClientImpl implements MeshtasticService {
// falsely reports Bluetooth off.
final BluetoothAdapterState state;
try {
state = await FlutterBluePlus.adapterState
state = await (_adapterStates ?? FlutterBluePlus.adapterState)
.firstWhere((s) => s != BluetoothAdapterState.unknown)
.timeout(const Duration(seconds: 8));
.timeout(_adapterTimeout);
} on TimeoutException {
Log.warning('meshtastic initialize: adapter state never settled');
return const UnexpectedFailure(
Expand Down Expand Up @@ -343,7 +379,8 @@ class MeshtasticClientImpl implements MeshtasticService {
}

Future<void> _dropOurLink(String id) async {
for (final device in FlutterBluePlus.connectedDevices) {
for (final device
in _connectedDevices?.call() ?? FlutterBluePlus.connectedDevices) {
if (device.remoteId.toString() != id) continue;
Log.info('meshtastic connect: dropping our stale link to $id');
try {
Expand All @@ -356,17 +393,17 @@ class MeshtasticClientImpl implements MeshtasticService {

@override
Future<MeshLinkOwner> linkOwner(String deviceId) async {
if (FlutterBluePlus.connectedDevices.any(
(d) => d.remoteId.toString() == deviceId,
)) {
final ours = _connectedDevices?.call() ?? FlutterBluePlus.connectedDevices;
if (ours.any((d) => d.remoteId.toString() == deviceId)) {
return MeshLinkOwner.thisApp;
}
try {
// Both platforms report links opened by *any* app here (iOS needs the
// service filter for privacy; Android ignores it).
final system = await FlutterBluePlus.systemDevices([
Guid(_meshtasticServiceUuid),
]);
final serviceIds = [Guid(_meshtasticServiceUuid)];
final system =
await (_systemDevices?.call(serviceIds) ??
FlutterBluePlus.systemDevices(serviceIds));
final held = system.any((d) => d.remoteId.toString() == deviceId);
if (held) {
Log.warning('meshtastic: $deviceId is already held by another app');
Expand Down Expand Up @@ -853,9 +890,7 @@ class MeshtasticClientImpl implements MeshtasticService {
mesh.AdminMessage(getChannelRequest: index + 1),
wantResponse: true,
);
final written = await confirmation.future.timeout(
const Duration(seconds: 8),
);
final written = await confirmation.future.timeout(channelConfirmTimeout);
if (written.settings.name != spec.name ||
!_samePsk(written.settings.psk, spec.psk)) {
Log.warning('meshtastic channel: slot $index did not take the write');
Expand Down
50 changes: 50 additions & 0 deletions lib/core/models/lat_lng.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,56 @@ class LatLng {
String toString() => 'LatLng($latitude, $longitude)';
}

/// `origin.distanceTo(point) <= metres`, with the origin terms computed once.
///
/// [contains] evaluates the same haversine `a` as [LatLng.distanceTo] and
/// compares it with `sin²(metres / 2R)`. That drops the per-point `sqrt` and
/// `atan2` that only exist to turn `a` back into metres. A radius of at least
/// half the earth's circumference contains every point — [LatLng.distanceTo]
/// cannot return more than that either. On the exact boundary the two can
/// disagree by a fraction of a nanometre, and only by reporting "just
/// outside".
final class DistanceWithin {
DistanceWithin(LatLng origin, double metres)
: latitude = origin.latitude,
_longitude = origin.longitude,
_cosLat = math.cos(degToRad(origin.latitude)),
_none = metres < 0,
_all = metres >= _halfCircumference,
_limit = _chordLimit(metres);

static const double _earthRadius = 6378137.0;

/// The most [LatLng.distanceTo] can return: half a turn of the sphere.
static const double _halfCircumference = _earthRadius * math.pi;

final double latitude;
final double _longitude;
final double _cosLat;
final bool _none;
final bool _all;
final double _limit;

static double _chordLimit(double metres) {
if (metres < 0 || metres >= _halfCircumference) return 0;
final s = math.sin(metres / (2 * _earthRadius));
return s * s;
}

bool contains(double pointLat, double pointLng) {
if (_none) return false;
if (_all) return true;
final dLat = degToRad(pointLat - latitude);
final dLng = degToRad(pointLng - _longitude);
final sinHalfLat = math.sin(dLat / 2);
final sinHalfLng = math.sin(dLng / 2);
final a =
sinHalfLat * sinHalfLat +
_cosLat * math.cos(degToRad(pointLat)) * sinHalfLng * sinHalfLng;
return a <= _limit;
}
}

/// Maps a single GeoJSON `[lng, lat]` pair to a [LatLng] (mind the axis order).
LatLng latLngFromPair(List<dynamic> pair) =>
LatLng((pair[1] as num).toDouble(), (pair[0] as num).toDouble());
Expand Down
14 changes: 8 additions & 6 deletions lib/features/earthquake/domain/rts_station_mark.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ enum RtsStationMarkKind {
/// One station's mark: its [kind], its discrete [level] (0–9), and the value
/// its dot is coloured by — the continuous reading, or the discrete level for
/// a station counted into a lit event.
typedef RtsStationMark =
({RtsStationMarkKind kind, int level, double colorValue});
typedef RtsStationMark = ({
RtsStationMarkKind kind,
int level,
double colorValue,
});

/// The mark for [reading], or null when it is not drawn at all.
///
Expand All @@ -58,10 +61,9 @@ RtsStationMark? rtsStationMark(
);
}
return (
kind:
eewActive && reading.intensity >= 0.2
? RtsStationMarkKind.grey
: RtsStationMarkKind.dot,
kind: eewActive && reading.intensity >= 0.2
? RtsStationMarkKind.grey
: RtsStationMarkKind.dot,
level: 0,
colorValue: 0,
);
Expand Down
Loading
Loading