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
Strycher 2 months ago
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…
Cancel
Save

Powered by TurnKey Linux.