init: rough companion app stub

This commit is contained in:
2026-05-07 18:35:58 +02:00
commit 5f017ac05d
73 changed files with 3520 additions and 0 deletions
@@ -0,0 +1,6 @@
import '../../domain/models/floor_plan.dart';
abstract class FloorPlanRepository {
Future<FloorPlan?> getFloorPlan();
Future<FloorPlan> saveFloorPlan(FloorPlan plan);
}
@@ -0,0 +1,7 @@
import '../../domain/models/onboarding_status.dart';
abstract class OnboardingRepository {
Future<OnboardingStatus> getStatus();
/// Creates the first admin user and returns the issued JWT.
Future<String> createAdminUser({required String username, required String password});
}
@@ -0,0 +1,15 @@
import '../../domain/models/floor_plan.dart';
import '../sources/localiser/floor_client.dart';
import 'floor_plan_repository.dart';
class PhoenixFloorPlanRepository implements FloorPlanRepository {
const PhoenixFloorPlanRepository({required this.client});
final FloorClient client;
@override
Future<FloorPlan?> getFloorPlan() => throw UnimplementedError();
@override
Future<FloorPlan> saveFloorPlan(FloorPlan plan) => throw UnimplementedError();
}
@@ -0,0 +1,27 @@
import '../../domain/models/onboarding_status.dart';
import '../sources/localiser/onboarding_client.dart';
import 'onboarding_repository.dart';
class PhoenixOnboardingRepository implements OnboardingRepository {
const PhoenixOnboardingRepository({required this.client});
final OnboardingClient client;
@override
Future<OnboardingStatus> getStatus() async {
final checklist = await client.getChecklist();
if (!checklist.hasAdmin) return OnboardingStatus.notStarted;
if (!checklist.hasFloors) return OnboardingStatus.awaitingFloorPlan;
if (!checklist.hasSensorsPlaced) return OnboardingStatus.awaitingFirstSensor;
return OnboardingStatus.complete;
}
@override
Future<String> createAdminUser({
required String username,
required String password,
}) async {
final response = await client.setup(username, password);
return response.token;
}
}
@@ -0,0 +1,27 @@
import '../../domain/models/sensor.dart';
import '../../domain/models/position.dart';
import '../sources/localiser/sensor_client.dart';
import 'sensor_repository.dart';
class PhoenixSensorRepository implements SensorRepository {
const PhoenixSensorRepository({required this.client});
final SensorClient client;
@override
Future<List<Sensor>> getSensors() => throw UnimplementedError();
@override
Future<Sensor> getSensor(String id) => throw UnimplementedError();
@override
Future<Sensor> createSensor({required String name, required Position position}) =>
throw UnimplementedError();
@override
Future<Sensor> updateSensor(String id, {String? name, Position? position}) =>
throw UnimplementedError();
@override
Future<void> deleteSensor(String id) => throw UnimplementedError();
}
@@ -0,0 +1,37 @@
import '../../domain/models/tag.dart';
import '../../domain/models/particle.dart';
import '../sources/localiser/tag_client.dart';
import '../sources/localiser/realtime_data_client.dart';
import 'tag_repository.dart';
class PhoenixTagRepository implements TagRepository {
const PhoenixTagRepository({
required this.tagClient,
required this.realtime,
});
final TagClient tagClient;
final RealtimeDataClient realtime;
@override
Future<List<Tag>> getTags() => throw UnimplementedError();
@override
Future<Tag> getTag(String id) => throw UnimplementedError();
@override
Future<Tag> createTag({required String id, required String name}) =>
throw UnimplementedError();
@override
Future<Tag> updateTag(String id, {String? name}) => throw UnimplementedError();
@override
Future<void> deleteTag(String id) => throw UnimplementedError();
@override
Stream<List<TagPosition>> watchPositions() => throw UnimplementedError();
@override
Stream<List<Particle>> watchParticleCloud() => throw UnimplementedError();
}
@@ -0,0 +1,10 @@
import '../../domain/models/sensor.dart';
import '../../domain/models/position.dart';
abstract class SensorRepository {
Future<List<Sensor>> getSensors();
Future<Sensor> getSensor(String id);
Future<Sensor> createSensor({required String name, required Position position});
Future<Sensor> updateSensor(String id, {String? name, Position? position});
Future<void> deleteSensor(String id);
}
+16
View File
@@ -0,0 +1,16 @@
import '../../domain/models/tag.dart';
import '../../domain/models/particle.dart';
abstract class TagRepository {
Future<List<Tag>> getTags();
Future<Tag> getTag(String id);
Future<Tag> createTag({required String id, required String name});
Future<Tag> updateTag(String id, {String? name});
Future<void> deleteTag(String id);
/// Live stream of all tag positions, pushed by localiserd over Phoenix channel.
Stream<List<TagPosition>> watchPositions();
/// Live stream of particle filter cloud snapshots.
Stream<List<Particle>> watchParticleCloud();
}
+37
View File
@@ -0,0 +1,37 @@
// Uses flutter_blue_plus to drive the ESP-IDF Unified Provisioning GATT protocol.
// No BLE pairing or password required.
//
// Android: minSdkVersion must be ≥ 21 in android/app/build.gradle.
class BleScanResult {
const BleScanResult({
required this.deviceId,
required this.name,
required this.rssi,
});
final String deviceId;
final String name;
final int rssi;
}
class BleProvisioner {
// TODO: implement using flutter_blue_plus.
// Filter scan by the ESP-IDF provisioning service UUID advertised by your firmware.
/// Starts a BLE scan and emits discovered ESP32 provisioning devices.
Stream<BleScanResult> scan() => throw UnimplementedError();
Future<void> stopScan() async => throw UnimplementedError();
/// Connects to [deviceId] and sends WiFi credentials via the ESP-IDF
/// Unified Provisioning GATT profile (protobuf over BLE characteristic).
Future<void> provision(
String deviceId, {
required String ssid,
required String wifiPassword,
}) async =>
throw UnimplementedError();
void dispose() {}
}
@@ -0,0 +1,31 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
typedef Credentials = ({String username, String password});
class CredentialStore {
static const _usernameKey = 'localiserd_username';
static const _passwordKey = 'localiserd_password';
final _storage = const FlutterSecureStorage();
Future<void> save(Credentials credentials) => Future.wait([
_storage.write(key: _usernameKey, value: credentials.username),
_storage.write(key: _passwordKey, value: credentials.password),
]).then((_) {});
Future<Credentials?> load() async {
final results = await Future.wait([
_storage.read(key: _usernameKey),
_storage.read(key: _passwordKey),
]);
final username = results[0];
final password = results[1];
if (username == null || password == null) return null;
return (username: username, password: password);
}
Future<void> clear() => Future.wait([
_storage.delete(key: _usernameKey),
_storage.delete(key: _passwordKey),
]).then((_) {});
}
@@ -0,0 +1,36 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class FloorClient extends LocaliserdClient {
FloorClient({required super.config, required String super.token});
Future<List<dynamic>> getFloors() async =>
await get('/api/floors') as List<dynamic>;
Future<Map<String, dynamic>> getFloor(int id) async =>
await get('/api/floors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> createFloor(Map<String, dynamic> params) async =>
await post('/api/floors', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateFloor(
int id, Map<String, dynamic> params) async =>
await patch('/api/floors/$id', params) as Map<String, dynamic>;
Future<void> deleteFloor(int id) => delete('/api/floors/$id');
Future<List<dynamic>> getRooms(int floorId) async =>
await get('/api/floors/$floorId/rooms') as List<dynamic>;
Future<Map<String, dynamic>> createRoom(
int floorId, Map<String, dynamic> params) async =>
await post('/api/floors/$floorId/rooms', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateRoom(
int floorId, int id, Map<String, dynamic> params) async =>
await patch('/api/floors/$floorId/rooms/$id', params)
as Map<String, dynamic>;
Future<void> deleteRoom(int floorId, int id) =>
delete('/api/floors/$floorId/rooms/$id');
}
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'dart:io';
import '../../../domain/models/server_config.dart';
class ApiException implements Exception {
const ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException $statusCode: $message';
}
abstract class LocaliserdClient {
const LocaliserdClient({required this.config, this.token});
final ServerConfig config;
/// Null for unauthenticated clients (session, onboarding setup).
final String? token;
Uri _uri(String path) => Uri(
scheme: 'http',
host: config.host,
port: config.port,
path: path,
);
Future<dynamic> get(String path) => _request('GET', path);
Future<dynamic> post(String path, [Object? body]) => _request('POST', path, body);
Future<dynamic> put(String path, [Object? body]) => _request('PUT', path, body);
Future<dynamic> patch(String path, [Object? body]) => _request('PATCH', path, body);
Future<void> delete(String path) => _request('DELETE', path).then((_) {});
/// Like [delete] but returns the response body (for endpoints that do so).
Future<dynamic> deleteBody(String path) => _request('DELETE', path);
Future<dynamic> _request(String method, String path, [Object? body]) async {
final http = HttpClient();
try {
final request = await http.openUrl(method, _uri(path));
request.headers.contentType = ContentType.json;
if (token != null) {
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $token');
}
if (body != null) request.write(jsonEncode(body));
final response = await request.close();
if (response.statusCode == 204) return null;
final responseBody = await response.transform(utf8.decoder).join();
if (response.statusCode >= 400) {
final parsed = responseBody.isNotEmpty
? jsonDecode(responseBody) as Map<String, dynamic>?
: null;
throw ApiException(
response.statusCode,
parsed?['error']?.toString() ?? responseBody,
);
}
return responseBody.isEmpty ? null : jsonDecode(responseBody);
} finally {
http.close();
}
}
}
@@ -0,0 +1,47 @@
import '../../../domain/models/auth.dart';
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class OnboardingChecklist {
const OnboardingChecklist({
required this.hasAdmin,
required this.hasFloors,
required this.hasRooms,
required this.hasSensorsPlaced,
required this.hasTags,
});
final bool hasAdmin;
final bool hasFloors;
final bool hasRooms;
final bool hasSensorsPlaced;
final bool hasTags;
factory OnboardingChecklist.fromJson(Map<String, dynamic> json) =>
OnboardingChecklist(
hasAdmin: json['has_admin'] as bool,
hasFloors: json['has_floors'] as bool,
hasRooms: json['has_rooms'] as bool,
hasSensorsPlaced: json['has_sensors_placed'] as bool,
hasTags: json['has_tags'] as bool,
);
}
class OnboardingClient extends LocaliserdClient {
OnboardingClient({required super.config});
Future<OnboardingChecklist> getChecklist() async {
final json = await get('/api/onboarding') as Map<String, dynamic>;
return OnboardingChecklist.fromJson(json);
}
/// Creates the first admin user. Only succeeds when no users exist yet.
/// Returns the JWT so the caller can immediately authenticate.
Future<TokenResponse> setup(String username, String password) async {
final json = await post('/api/setup', {
'username': username,
'password': password,
}) as Map<String, dynamic>;
return TokenResponse.fromJson(json);
}
}
@@ -0,0 +1,72 @@
import 'package:phoenix_socket/phoenix_socket.dart';
import '../../../domain/models/server_config.dart';
class RealtimeDataClient {
RealtimeDataClient({required this.config, required this.token});
final ServerConfig config;
final String token;
PhoenixSocket? _socket;
final _channels = <String, PhoenixChannel>{};
Future<void> connect() async {
_socket = PhoenixSocket(
config.wsUrl,
socketOptions: PhoenixSocketOptions(
params: {'token': token},
),
);
await _socket!.connect();
}
Future<void> disconnect() async {
for (final channel in _channels.values) {
channel.leave();
}
_channels.clear();
_socket?.close();
_socket = null;
}
bool get isConnected => _socket?.isConnected ?? false;
/// Joins [topic] (if not already joined) and returns a stream of message
/// payloads for that topic. The stream stays open until [disconnect] is
/// called or the underlying socket closes.
Stream<Map<String, dynamic>> channel(
String topic, {
Map<String, dynamic> params = const {},
}) {
final socket = _socket;
if (socket == null) throw StateError('RealtimeDataClient not connected');
final channel = _channels.putIfAbsent(
topic,
() {
final ch = socket.addChannel(topic: topic, parameters: params);
ch.join();
return ch;
},
);
return channel.messages
.where((msg) => msg.event.value != 'phx_reply')
.map((msg) => msg.payload ?? const {});
}
/// Pushes [event] on [topic] and waits for the server reply.
/// The channel must have been joined first via [channel].
Future<Map<String, dynamic>> push(
String topic,
String event,
Map<String, dynamic> payload,
) async {
final ch = _channels[topic];
if (ch == null) throw StateError('Channel $topic has not been joined');
final reply = await ch.push(event, payload).future;
return (reply.response as Map<String, dynamic>?) ?? const {};
}
}
@@ -0,0 +1,37 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class SensorClient extends LocaliserdClient {
SensorClient({required ServerConfig config, required String token})
: super(config: config, token: token);
Future<List<dynamic>> getSensors() async =>
await get('/api/sensors') as List<dynamic>;
Future<List<dynamic>> getUnplacedSensors() async =>
await get('/api/sensors/unplaced') as List<dynamic>;
Future<Map<String, dynamic>> getSensor(int id) async =>
await get('/api/sensors/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> updateSensor(
int id, Map<String, dynamic> params) async =>
await put('/api/sensors/$id', params) as Map<String, dynamic>;
Future<void> deleteSensor(int id) => delete('/api/sensors/$id');
Future<Map<String, dynamic>> placeSensor(
int id, Map<String, dynamic> params) async =>
await put('/api/sensors/$id/place', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> unplaceSensor(int id) async =>
await deleteBody('/api/sensors/$id/place') as Map<String, dynamic>;
Future<Map<String, dynamic>> startCalibration(
int id, double referenceDistance) async =>
await post('/api/sensors/$id/calibration/start',
{'reference_distance': referenceDistance}) as Map<String, dynamic>;
Future<Map<String, dynamic>> stopCalibration(int id) async =>
await post('/api/sensors/$id/calibration/stop') as Map<String, dynamic>;
}
@@ -0,0 +1,15 @@
import '../../../domain/models/auth.dart';
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class SessionClient extends LocaliserdClient {
SessionClient({required super.config});
Future<TokenResponse> login(String username, String password) async {
final json = await post('/api/session', {
'username': username,
'password': password,
}) as Map<String, dynamic>;
return TokenResponse.fromJson(json);
}
}
@@ -0,0 +1,21 @@
import '../../../domain/models/server_config.dart';
import 'localiser_client.dart';
class TagClient extends LocaliserdClient {
TagClient({required super.config, required String super.token});
Future<List<dynamic>> getTags() async =>
await get('/api/tags') as List<dynamic>;
Future<Map<String, dynamic>> getTag(int id) async =>
await get('/api/tags/$id') as Map<String, dynamic>;
Future<Map<String, dynamic>> createTag(Map<String, dynamic> params) async =>
await post('/api/tags', params) as Map<String, dynamic>;
Future<Map<String, dynamic>> updateTag(
int id, Map<String, dynamic> params) async =>
await patch('/api/tags/$id', params) as Map<String, dynamic>;
Future<void> deleteTag(int id) => delete('/api/tags/$id');
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:async';
import 'package:multicast_dns/multicast_dns.dart';
import '../../../domain/models/server_config.dart';
class MdnsDiscovery {
static const String serviceType = '_localiserd._tcp';
MDnsClient? _client;
Stream<ServerConfig> discover() async* {
_client?.stop();
final client = MDnsClient();
_client = client;
await client.start();
try {
await for (final ptr in client.lookup<PtrResourceRecord>(
ResourceRecordQuery.serverPointer(serviceType),
)) {
await for (final srv in client.lookup<SrvResourceRecord>(
ResourceRecordQuery.service(ptr.domainName),
)) {
await for (final ip in client.lookup<IPAddressResourceRecord>(
ResourceRecordQuery.addressIPv4(srv.target),
)) {
yield ServerConfig(host: ip.address.address, port: srv.port);
}
}
}
} finally {
client.stop();
if (_client == client) _client = null;
}
}
Future<void> stop() async {
_client?.stop();
_client = null;
}
}