init: rough companion app stub
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user