feat(#549): CoreScope HTTP observer-count query service
Best-effort GET /api/packets?hash=H&groupByHash=true against a CoreScope instance (default map.okimesh.org); returns observer_count or null. Never throws, so the chat UI degrades silently to radio-only when offline or the hash is unknown. Part of epic #524. Agent: QuietSnow (session 31eaba02)pull/556/head
parent
5f0234c8db
commit
d79dfffad3
@ -0,0 +1,66 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Default CoreScope instance (OKIMesh). Owner-run; its read API is public.
|
||||
const String kDefaultCoreScopeHost = 'map.okimesh.org';
|
||||
|
||||
/// Queries a CoreScope instance for how many observers reported a given packet,
|
||||
/// keyed by the firmware/mesh packet hash (#524). Read-only and best-effort:
|
||||
/// any failure (offline, timeout, non-200, bad body, unknown packet) returns
|
||||
/// null so the chat UI can silently fall back to radio-only. Never throws.
|
||||
class CoreScopeService {
|
||||
CoreScopeService({
|
||||
http.Client? client,
|
||||
this.host = kDefaultCoreScopeHost,
|
||||
this.useTls = true,
|
||||
this.timeout = const Duration(seconds: 6),
|
||||
}) : _client = client ?? http.Client();
|
||||
|
||||
final http.Client _client;
|
||||
final String host;
|
||||
final bool useTls;
|
||||
final Duration timeout;
|
||||
|
||||
/// Unique observers that reported the packet with [packetHash]
|
||||
/// (16 lowercase hex chars). Returns null on any error, or when CoreScope
|
||||
/// has no record of the hash yet.
|
||||
Future<int?> fetchObserverCount(String packetHash) async {
|
||||
if (packetHash.isEmpty) return null;
|
||||
final uri = Uri(
|
||||
scheme: useTls ? 'https' : 'http',
|
||||
host: host,
|
||||
path: '/api/packets',
|
||||
queryParameters: {
|
||||
'hash': packetHash,
|
||||
'groupByHash': 'true',
|
||||
'limit': '1',
|
||||
},
|
||||
);
|
||||
try {
|
||||
final resp = await _client.get(uri).timeout(timeout);
|
||||
if (resp.statusCode != 200) {
|
||||
appLogger.warn(
|
||||
'HTTP ${resp.statusCode} for hash $packetHash',
|
||||
tag: 'CoreScope',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final body = jsonDecode(resp.body);
|
||||
if (body is! Map) return null;
|
||||
final packets = body['packets'];
|
||||
if (packets is! List || packets.isEmpty) return null;
|
||||
final first = packets.first;
|
||||
if (first is! Map) return null;
|
||||
final count = first['observer_count'];
|
||||
return count is num ? count.toInt() : null;
|
||||
} catch (e) {
|
||||
appLogger.warn('Query failed for $packetHash: $e', tag: 'CoreScope');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _client.close();
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:meshcore_open/services/corescope_service.dart';
|
||||
|
||||
void main() {
|
||||
group('CoreScopeService.fetchObserverCount', () {
|
||||
test('parses observer_count from a grouped-by-hash response', () async {
|
||||
late Uri captured;
|
||||
final service = CoreScopeService(
|
||||
host: 'map.okimesh.org',
|
||||
client: MockClient((req) async {
|
||||
captured = req.url;
|
||||
return http.Response(
|
||||
'{"packets":[{"hash":"abc123","observer_count":7}],"total":1}',
|
||||
200,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await service.fetchObserverCount('abc123'), 7);
|
||||
// Correct endpoint + query built from the hash.
|
||||
expect(captured.scheme, 'https');
|
||||
expect(captured.host, 'map.okimesh.org');
|
||||
expect(captured.path, '/api/packets');
|
||||
expect(captured.queryParameters['hash'], 'abc123');
|
||||
expect(captured.queryParameters['groupByHash'], 'true');
|
||||
});
|
||||
|
||||
test('returns null when CoreScope has no record (empty packets)', () async {
|
||||
final service = CoreScopeService(
|
||||
client: MockClient(
|
||||
(_) async => http.Response('{"packets":[],"total":0}', 200),
|
||||
),
|
||||
);
|
||||
expect(await service.fetchObserverCount('deadbeef'), isNull);
|
||||
});
|
||||
|
||||
test('returns null on non-200', () async {
|
||||
final service = CoreScopeService(
|
||||
client: MockClient((_) async => http.Response('nope', 503)),
|
||||
);
|
||||
expect(await service.fetchObserverCount('abc123'), isNull);
|
||||
});
|
||||
|
||||
test('returns null on malformed body', () async {
|
||||
final service = CoreScopeService(
|
||||
client: MockClient((_) async => http.Response('not json', 200)),
|
||||
);
|
||||
expect(await service.fetchObserverCount('abc123'), isNull);
|
||||
});
|
||||
|
||||
test('returns null (never throws) on transport failure', () async {
|
||||
final service = CoreScopeService(
|
||||
client: MockClient((_) async => throw Exception('offline')),
|
||||
);
|
||||
expect(await service.fetchObserverCount('abc123'), isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'returns null for an empty hash without hitting the network',
|
||||
() async {
|
||||
var called = false;
|
||||
final service = CoreScopeService(
|
||||
client: MockClient((_) async {
|
||||
called = true;
|
||||
return http.Response('{}', 200);
|
||||
}),
|
||||
);
|
||||
expect(await service.fetchObserverCount(''), isNull);
|
||||
expect(called, isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue