near final
This commit is contained in:
179
lib/backend/live_information.dart
Normal file
179
lib/backend/live_information.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
|
||||
// Singleton
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appwrite/appwrite.dart' as appwrite;
|
||||
import 'package:appwrite/models.dart' as models;
|
||||
import 'package:bus_infotainment/audio_cache.dart';
|
||||
import 'package:bus_infotainment/auth/api_constants.dart';
|
||||
import 'package:bus_infotainment/auth/auth_api.dart';
|
||||
import 'package:bus_infotainment/backend/modules/announcement.dart';
|
||||
import 'package:bus_infotainment/backend/modules/commands.dart';
|
||||
import 'package:bus_infotainment/backend/modules/synced_time.dart';
|
||||
import 'package:bus_infotainment/backend/modules/tracker.dart';
|
||||
import 'package:bus_infotainment/tfl_datasets.dart';
|
||||
import 'package:bus_infotainment/utils/audio%20wrapper.dart';
|
||||
import 'package:bus_infotainment/utils/delegates.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:ntp/ntp.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class LiveInformation {
|
||||
|
||||
static final LiveInformation _singleton = LiveInformation._internal();
|
||||
|
||||
factory LiveInformation() {
|
||||
return _singleton;
|
||||
}
|
||||
|
||||
LiveInformation._internal();
|
||||
|
||||
Future<void> initialize() async {
|
||||
|
||||
{
|
||||
// By default, load the bus sequences from the assets
|
||||
print("Loading bus sequences from assets");
|
||||
busSequences = BusSequences.fromCSV(
|
||||
await rootBundle.loadString("assets/datasets/bus-blinds.csv"),
|
||||
await rootBundle.loadString("assets/datasets/bus-sequences.csv")
|
||||
);
|
||||
print("Loaded bus sequences from assets");
|
||||
|
||||
try {
|
||||
|
||||
http.Response response = await http.get(Uri.parse('https://tfl.gov.uk/bus-sequences.csv'));
|
||||
|
||||
busSequences = BusSequences.fromCSV(
|
||||
await rootBundle.loadString("assets/datasets/bus-blinds.csv"),
|
||||
response.body
|
||||
);
|
||||
|
||||
print("Loaded bus sequences from TFL");
|
||||
|
||||
} catch (e) {
|
||||
print("Failed to load bus sequences from TFL. Using local copy.");
|
||||
}
|
||||
|
||||
String sessionID = "test";
|
||||
|
||||
commandModule = CommandModule(sessionID);
|
||||
|
||||
}
|
||||
|
||||
// Initialise modules
|
||||
syncedTimeModule = SyncedTimeModule();
|
||||
announcementModule = AnnouncementModule();
|
||||
|
||||
// Tracker module is not supported on desktop
|
||||
if (defaultTargetPlatform != TargetPlatform.windows || defaultTargetPlatform != TargetPlatform.linux || defaultTargetPlatform != TargetPlatform.macOS) {
|
||||
// Tracker module is not supported on web
|
||||
await Permission.location.request();
|
||||
trackerModule = TrackerModule();
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
AuthAPI auth = AuthAPI();
|
||||
|
||||
// Modules
|
||||
late CommandModule commandModule;
|
||||
late BusSequences busSequences;
|
||||
late AnnouncementModule announcementModule;
|
||||
late SyncedTimeModule syncedTimeModule;
|
||||
late TrackerModule trackerModule;
|
||||
|
||||
// Important variables
|
||||
BusRouteVariant? _currentRouteVariant;
|
||||
|
||||
// Events
|
||||
EventDelegate<BusRouteVariant> routeVariantDelegate = EventDelegate();
|
||||
|
||||
// Internal methods
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Future<void> setRouteVariant_Internal(BusRouteVariant routeVariant) async {
|
||||
|
||||
// Set the current route variant
|
||||
_currentRouteVariant = routeVariant;
|
||||
|
||||
// Let everyone know that the route variant has been set/changed
|
||||
routeVariantDelegate.trigger(routeVariant);
|
||||
|
||||
// Get all of the files that need to be cached
|
||||
List<String> audioFiles = [];
|
||||
|
||||
for (BusRouteStop stop in routeVariant.busStops) {
|
||||
audioFiles.add(stop.getAudioFileName());
|
||||
}
|
||||
|
||||
// Cache/Load the audio files
|
||||
await announcementModule
|
||||
.announcementCache
|
||||
.loadAnnouncements(audioFiles);
|
||||
}
|
||||
|
||||
// Public methods
|
||||
|
||||
BusRouteVariant? getRouteVariant() {
|
||||
return _currentRouteVariant;
|
||||
}
|
||||
|
||||
Future<void> setRouteVariant(BusRouteVariant routeVariant) async {
|
||||
await commandModule.executeCommand(
|
||||
"setroute ${routeVariant.busRoute.routeNumber} ${routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant)}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
Everything under this will be considered legacy code
|
||||
*/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
class AnnouncementQueueEntry {
|
||||
final String displayText;
|
||||
final List<AudioWrapperSource> audioSources;
|
||||
bool sendToServer = true;
|
||||
DateTime? scheduledTime;
|
||||
DateTime? timestamp;
|
||||
|
||||
AnnouncementQueueEntry({required this.displayText, required this.audioSources, this.sendToServer = true, this.scheduledTime, this.timestamp});
|
||||
}
|
||||
|
||||
class NamedAnnouncementQueueEntry extends AnnouncementQueueEntry {
|
||||
final String shortName;
|
||||
|
||||
NamedAnnouncementQueueEntry({
|
||||
required this.shortName,
|
||||
required String displayText,
|
||||
required List<AudioWrapperSource> audioSources,
|
||||
DateTime? scheduledTime,
|
||||
DateTime? timestamp,
|
||||
bool sendToServer = true,
|
||||
}) : super(
|
||||
displayText: displayText,
|
||||
audioSources: audioSources,
|
||||
sendToServer: sendToServer,
|
||||
scheduledTime: scheduledTime,
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
var abs = (int value) => value < 0 ? -value : value;
|
||||
330
lib/backend/modules/announcement.dart
Normal file
330
lib/backend/modules/announcement.dart
Normal file
@@ -0,0 +1,330 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bus_infotainment/audio_cache.dart';
|
||||
import 'package:bus_infotainment/backend/live_information.dart';
|
||||
import 'package:bus_infotainment/tfl_datasets.dart';
|
||||
import 'package:bus_infotainment/utils/audio%20wrapper.dart';
|
||||
import 'package:bus_infotainment/utils/delegates.dart';
|
||||
|
||||
import 'info_module.dart';
|
||||
|
||||
class AnnouncementModule extends InfoModule {
|
||||
|
||||
AnnouncementCache announcementCache = AnnouncementCache();
|
||||
|
||||
// Constructor
|
||||
AnnouncementModule() {
|
||||
refreshTimer();
|
||||
}
|
||||
|
||||
// Queue
|
||||
List<AnnouncementQueueEntry> queue = [];
|
||||
AnnouncementQueueEntry? currentAnnouncement;
|
||||
DateTime? currentAnnouncementTimeStamp;
|
||||
String defaultText = "*** NO MESSAGE ***";
|
||||
bool isPlaying = false;
|
||||
|
||||
// Audio
|
||||
AudioWrapper audioPlayer = AudioWrapper();
|
||||
|
||||
// Events
|
||||
final EventDelegate<AnnouncementQueueEntry> onAnnouncement = EventDelegate();
|
||||
|
||||
// Timer
|
||||
Timer refreshTimer() => Timer.periodic(const Duration(milliseconds: 200), (timer) async {
|
||||
|
||||
if (!isPlaying) {
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
isPlaying = true;
|
||||
AnnouncementQueueEntry nextAnnouncement = queue.first;
|
||||
|
||||
bool proceeding = await _internalAccountForInconsistentTime(
|
||||
announcement: nextAnnouncement,
|
||||
timerInterval: const Duration(milliseconds: 200),
|
||||
callback: () {
|
||||
queue.removeAt(0);
|
||||
print("Announcement proceeding");
|
||||
}
|
||||
);
|
||||
|
||||
if (!proceeding) {
|
||||
isPlaying = false;
|
||||
print("Announcement not proceeding");
|
||||
print("Queue: ${queue.length}");
|
||||
return;
|
||||
}
|
||||
|
||||
currentAnnouncement = nextAnnouncement;
|
||||
currentAnnouncementTimeStamp = liveInformation.syncedTimeModule.Now();
|
||||
|
||||
onAnnouncement.trigger(currentAnnouncement!);
|
||||
|
||||
if (currentAnnouncement!.audioSources.isNotEmpty) {
|
||||
try {
|
||||
for (AudioWrapperSource source in currentAnnouncement!.audioSources) {
|
||||
|
||||
await audioPlayer.loadSource(source);
|
||||
|
||||
Duration? duration = await audioPlayer.play();
|
||||
await Future.delayed(duration!);
|
||||
if (currentAnnouncement?.audioSources.last != source) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
audioPlayer.stop();
|
||||
|
||||
} catch (e) {
|
||||
// Do nothing
|
||||
print("Error playing announcement: $e");
|
||||
}
|
||||
} else {
|
||||
if (queue.isNotEmpty) {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
isPlaying = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
// Will call the callback function if the announcement will be proceeding
|
||||
Future<bool> _internalAccountForInconsistentTime({
|
||||
required AnnouncementQueueEntry announcement,
|
||||
required Duration timerInterval,
|
||||
required Function() callback
|
||||
}) async {
|
||||
DateTime now = liveInformation.syncedTimeModule.Now();
|
||||
if (announcement.scheduledTime != null) {
|
||||
|
||||
if (now.isAfter(announcement.scheduledTime!)) {
|
||||
callback();
|
||||
return true;
|
||||
}
|
||||
|
||||
int milisecondDifference = abs(now.millisecondsSinceEpoch - announcement.scheduledTime!.millisecondsSinceEpoch);
|
||||
if (milisecondDifference <= timerInterval.inMilliseconds) {
|
||||
// Account for the time lost by the periodic timer
|
||||
callback();
|
||||
await Future.delayed(Duration(milliseconds: timerInterval.inMilliseconds - milisecondDifference));
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
int get defaultAnnouncementDelay => liveInformation.auth.isAuthenticated() ? 2 : 0;
|
||||
|
||||
// Methods
|
||||
Future<void> queueAnnounceByAudioName({
|
||||
required String displayText,
|
||||
List<String> audioNames = const [],
|
||||
DateTime? scheduledTime = null,
|
||||
bool sendToServer = true
|
||||
}) async {
|
||||
|
||||
if (sendToServer) {
|
||||
|
||||
scheduledTime ??= liveInformation.syncedTimeModule.Now().add(Duration(seconds: defaultAnnouncementDelay));
|
||||
|
||||
String audioNamesString = "";
|
||||
|
||||
for (var audioName in audioNames) {
|
||||
audioNamesString += "\"$audioName\" ";
|
||||
}
|
||||
|
||||
liveInformation.commandModule.executeCommand(
|
||||
"announce manual \"$displayText\" ${audioNamesString} ${scheduledTime?.millisecondsSinceEpoch ?? ""}"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the announcements
|
||||
await announcementCache.loadAnnouncements(audioNames);
|
||||
|
||||
List<AudioWrapperSource> sources = [];
|
||||
|
||||
print("Audio names: $audioNames");
|
||||
|
||||
for (var audioName in audioNames) {
|
||||
Uint8List? audioData = announcementCache[audioName];
|
||||
|
||||
if (audioData == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sources.add(AudioWrapperByteSource(audioData));
|
||||
|
||||
}
|
||||
|
||||
queue.add(
|
||||
AnnouncementQueueEntry(
|
||||
displayText: displayText,
|
||||
audioSources: sources,
|
||||
scheduledTime: scheduledTime
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
void queueAnnounementByInfoIndex({
|
||||
int infoIndex = -1,
|
||||
DateTime? scheduledTime = null,
|
||||
bool sendToServer = true
|
||||
}) {
|
||||
|
||||
if (sendToServer) {
|
||||
|
||||
scheduledTime ??= liveInformation.syncedTimeModule.Now().add(Duration(seconds: defaultAnnouncementDelay));
|
||||
|
||||
liveInformation.commandModule.executeCommand(
|
||||
"announce info $infoIndex ${scheduledTime?.millisecondsSinceEpoch ?? ""}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
NamedAnnouncementQueueEntry clone = NamedAnnouncementQueueEntry(
|
||||
shortName: manualAnnouncements[infoIndex].shortName,
|
||||
displayText: manualAnnouncements[infoIndex].displayText,
|
||||
audioSources: manualAnnouncements[infoIndex].audioSources,
|
||||
scheduledTime: scheduledTime
|
||||
);
|
||||
|
||||
queue.add(clone);
|
||||
}
|
||||
|
||||
Future<void> queueAnnouncementByRouteVariant({
|
||||
required BusRouteVariant routeVariant,
|
||||
DateTime? scheduledTime = null,
|
||||
bool sendToServer = true
|
||||
}) async {
|
||||
|
||||
if (sendToServer) {
|
||||
|
||||
scheduledTime ??= liveInformation.syncedTimeModule.Now().add(Duration(seconds: defaultAnnouncementDelay));
|
||||
|
||||
liveInformation.commandModule.executeCommand(
|
||||
"announce dest \"${routeVariant.busRoute.routeNumber}\" ${routeVariant.busRoute.routeVariants.values.toList().indexOf(routeVariant)} ${scheduledTime?.millisecondsSinceEpoch ?? ""}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
String routeNumber = routeVariant.busRoute.routeNumber;
|
||||
String destination = routeVariant.destination!.destination;
|
||||
|
||||
String audioRoute = "R_${routeVariant.busRoute.routeNumber}_001.mp3";
|
||||
|
||||
await announcementCache.loadAnnouncements([audioRoute]);
|
||||
|
||||
AudioWrapperSource sourceRoute = AudioWrapperByteSource(announcementCache[audioRoute]!);
|
||||
AudioWrapperSource sourceDestination = AudioWrapperByteSource(await routeVariant.destination!.getAudioBytes());
|
||||
|
||||
AnnouncementQueueEntry announcement = AnnouncementQueueEntry(
|
||||
displayText: "$routeNumber to $destination",
|
||||
audioSources: [sourceRoute, AudioWrapperAssetSource("audio/to_destination.wav"), sourceDestination],
|
||||
scheduledTime: scheduledTime
|
||||
);
|
||||
|
||||
queue.add(announcement);
|
||||
}
|
||||
|
||||
// Constants
|
||||
|
||||
final List<NamedAnnouncementQueueEntry> manualAnnouncements = [
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Driver Change",
|
||||
displayText: "Driver Change",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/driverchange.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "No Standing Upr Deck",
|
||||
displayText: "No standing on the upper deck",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/nostanding.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Face Covering",
|
||||
displayText: "Please wear a face covering!",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/facecovering.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Seats Upstairs",
|
||||
displayText: "Seats are available upstairs",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/seatsupstairs.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Bus Terminates Here",
|
||||
displayText: "Bus terminates here. Please take your belongings with you",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/busterminateshere.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Bus On Diversion",
|
||||
displayText: "Bus on diversion. Please listen for further announcements",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/busondiversion.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Destination Change",
|
||||
displayText: "Destination Changed - please listen for further instructions",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/destinationchange.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Wheelchair Space",
|
||||
displayText: "Wheelchair space requested",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/wheelchairspace1.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Move Down The Bus",
|
||||
displayText: "Please move down the bus",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/movedownthebus.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Next Stop Closed",
|
||||
displayText: "The next bus stop is closed",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/nextstopclosed.wav")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "CCTV In Operation",
|
||||
displayText: "CCTV is in operation on this bus",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/cctvoperation.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Safe Door Opening",
|
||||
displayText: "Driver will open the doors when it is safe to do so",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/safedooropening.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Buggy Safety",
|
||||
displayText: "For your child's safety, please remain with your buggy",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/buggysafety.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Wheelchair Space 2",
|
||||
displayText: "Wheelchair priority space required",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/wheelchairspace2.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Service Regulation",
|
||||
displayText: "Regulating service - please listen for further information",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/serviceregulation.mp3")],
|
||||
),
|
||||
NamedAnnouncementQueueEntry(
|
||||
shortName: "Bus Ready To Depart",
|
||||
displayText: "This bus is ready to depart",
|
||||
audioSources: [AudioWrapperAssetSource("audio/manual_announcements/readytodepart.mp3")],
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
}
|
||||
224
lib/backend/modules/commands.dart
Normal file
224
lib/backend/modules/commands.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:bus_infotainment/auth/api_constants.dart';
|
||||
import 'package:bus_infotainment/backend/live_information.dart';
|
||||
import 'package:bus_infotainment/tfl_datasets.dart';
|
||||
import 'package:bus_infotainment/utils/audio%20wrapper.dart';
|
||||
import 'package:bus_infotainment/utils/delegates.dart';
|
||||
import 'package:appwrite/appwrite.dart' as appwrite;
|
||||
import 'package:appwrite/models.dart' as models;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../auth/auth_api.dart';
|
||||
import 'info_module.dart';
|
||||
|
||||
class CommandModule extends InfoModule {
|
||||
|
||||
final String sessionID;
|
||||
late final String clientID;
|
||||
|
||||
List<CommandInfo> _commandHistory = [];
|
||||
get commandHistory => _commandHistory;
|
||||
EventDelegate<CommandInfo> onCommandReceived = EventDelegate();
|
||||
|
||||
CommandModule(this.sessionID){
|
||||
// generate a random client ID
|
||||
var uuid = Uuid();
|
||||
|
||||
clientID = uuid.v4();
|
||||
|
||||
if (liveInformation.auth.isAuthenticated()){
|
||||
print("Auth is authenticated");
|
||||
_setupListener();
|
||||
} else {
|
||||
print("Auth is not authenticated");
|
||||
liveInformation.auth.onLogin.addListener((value) {
|
||||
_setupListener();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Will execute the command an event which is triggered when a response is received
|
||||
Future<EventDelegate> executeCommand(String command) async {
|
||||
EventDelegate<String> delegate = EventDelegate();
|
||||
|
||||
final client = liveInformation.auth.client;
|
||||
|
||||
final databases = appwrite.Databases(client);
|
||||
|
||||
if (liveInformation.auth.status == AuthStatus.AUTHENTICATED) {
|
||||
final document = await databases.createDocument(
|
||||
databaseId: ApiConstants.INFO_Q_DATABASE_ID,
|
||||
collectionId: ApiConstants.COMMANDS_COLLECTION_ID,
|
||||
documentId: appwrite.ID.unique(),
|
||||
data: {
|
||||
"session_id": sessionID,
|
||||
"command": command,
|
||||
"client_id": clientID,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_onCommandReceived(CommandInfo(command, clientID));
|
||||
|
||||
return delegate;
|
||||
}
|
||||
|
||||
Future<void> _onCommandReceived(CommandInfo commandInfo) async {
|
||||
|
||||
commandHistory.add(commandInfo);
|
||||
onCommandReceived.trigger(commandInfo);
|
||||
|
||||
print("Received command: ${commandInfo.command}");
|
||||
|
||||
List<String> commandParts = splitCommand(commandInfo.command);
|
||||
String command = commandParts[0];
|
||||
List<String> args = commandParts.sublist(1);
|
||||
|
||||
if (command == "Response:") {
|
||||
|
||||
}
|
||||
else if (command == "announce") {
|
||||
|
||||
final displayText = args[1];
|
||||
|
||||
if (args[0] == "manual") {
|
||||
// announce manual <DisplayText> <AudioFileName>... <ScheduledTime>
|
||||
|
||||
|
||||
List<String> audioFileNames = args.sublist(2);
|
||||
try {
|
||||
if (int.parse(audioFileNames.last) != null) {
|
||||
audioFileNames.removeLast();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
DateTime scheduledTime = LiveInformation().syncedTimeModule.Now().add(Duration(seconds: 1));
|
||||
try {
|
||||
if (int.parse(args.last) != null) {
|
||||
scheduledTime = DateTime.fromMillisecondsSinceEpoch(int.parse(args.last));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
liveInformation.announcementModule.queueAnnounceByAudioName(
|
||||
displayText: displayText,
|
||||
audioNames: audioFileNames,
|
||||
scheduledTime: scheduledTime,
|
||||
sendToServer: false
|
||||
);
|
||||
|
||||
}
|
||||
else if (args[0] == "info") {
|
||||
|
||||
int InfoIndex = int.parse(args[1]);
|
||||
|
||||
DateTime scheduledTime = LiveInformation().syncedTimeModule.Now();
|
||||
try {
|
||||
if (int.parse(args.last) != null) {
|
||||
scheduledTime = DateTime.fromMillisecondsSinceEpoch(int.parse(args.last));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
liveInformation.announcementModule.queueAnnounementByInfoIndex(
|
||||
infoIndex: InfoIndex,
|
||||
scheduledTime: scheduledTime,
|
||||
sendToServer: false
|
||||
);
|
||||
}
|
||||
else if (args[0].startsWith("dest")) {
|
||||
// announce destination <RouteNumber> <RouteVariantIndex> <ScheduledTime>
|
||||
|
||||
String routeNumber = args[1];
|
||||
int routeVariantIndex = int.parse(args[2]);
|
||||
|
||||
DateTime scheduledTime = LiveInformation().syncedTimeModule.Now();
|
||||
try {
|
||||
if (int.parse(args.last) != null) {
|
||||
scheduledTime = DateTime.fromMillisecondsSinceEpoch(int.parse(args.last));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
BusRoute route = LiveInformation().busSequences.routes[routeNumber]!;
|
||||
BusRouteVariant routeVariant = route.routeVariants.values.toList()[routeVariantIndex];
|
||||
|
||||
liveInformation.announcementModule.queueAnnouncementByRouteVariant(
|
||||
routeVariant: routeVariant,
|
||||
scheduledTime: scheduledTime,
|
||||
sendToServer: false
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
else if (command == "setroute") {
|
||||
// setroute <RouteNumber> <RouteVariantIndex>
|
||||
|
||||
LiveInformation liveInformation = LiveInformation();
|
||||
|
||||
String routeNumber = args[0];
|
||||
int routeVariantIndex = int.parse(args[1]);
|
||||
|
||||
BusRoute route = liveInformation.busSequences.routes[routeNumber]!;
|
||||
BusRouteVariant routeVariant = route.routeVariants.values.toList()[routeVariantIndex];
|
||||
|
||||
liveInformation.setRouteVariant_Internal(
|
||||
routeVariant
|
||||
);
|
||||
executeCommand("Response: v \"Client $clientID set its route to ($routeNumber to ${routeVariant.busStops.last.formattedStopName})\"");
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
appwrite.RealtimeSubscription? _subscription;
|
||||
Future<void> _setupListener() async {
|
||||
if (_subscription != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final realtime = appwrite.Realtime(LiveInformation().auth.client);
|
||||
|
||||
_subscription = realtime.subscribe(
|
||||
['databases.${ApiConstants.INFO_Q_DATABASE_ID}.collections.${ApiConstants.COMMANDS_COLLECTION_ID}.documents']
|
||||
);
|
||||
_subscription!.stream.listen((event) {
|
||||
print(jsonEncode(event.payload));
|
||||
|
||||
// Only do something if the document was created or updated
|
||||
if (!(event.events.first.contains("create") || event.events.first.contains("update"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
final commandInfo = CommandInfo(event.payload['command'], event.payload['client_id']);
|
||||
|
||||
if (commandInfo.clientID != clientID) {
|
||||
_onCommandReceived(commandInfo);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
print("Listening for commands");
|
||||
|
||||
await Future.delayed(Duration(seconds: 90));
|
||||
|
||||
await _subscription!.close();
|
||||
_subscription = null;
|
||||
_setupListener();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class CommandInfo {
|
||||
final String command;
|
||||
final String clientID;
|
||||
|
||||
CommandInfo(this.command, this.clientID);
|
||||
}
|
||||
|
||||
List<String> splitCommand(String command) {
|
||||
var regex = RegExp(r'([^\s"]+)|"([^"]*)"');
|
||||
var matches = regex.allMatches(command);
|
||||
return matches.map((match) => match.group(0)!.replaceAll('"', '')).toList();
|
||||
}
|
||||
8
lib/backend/modules/info_module.dart
Normal file
8
lib/backend/modules/info_module.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
import 'package:bus_infotainment/backend/live_information.dart';
|
||||
|
||||
abstract class InfoModule {
|
||||
|
||||
LiveInformation get liveInformation => LiveInformation();
|
||||
|
||||
}
|
||||
36
lib/backend/modules/synced_time.dart
Normal file
36
lib/backend/modules/synced_time.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'info_module.dart';
|
||||
|
||||
class SyncedTimeModule extends InfoModule {
|
||||
|
||||
int timeOffset = -1;
|
||||
DateTime lastUpdate = DateTime.now().add(const Duration(seconds: -15));
|
||||
|
||||
SyncedTimeModule() {
|
||||
refreshTimer();
|
||||
}
|
||||
|
||||
Timer refreshTimer() => Timer.periodic(const Duration(seconds: 10), (timer) async {
|
||||
var res = await http.get(Uri.parse('http://worldtimeapi.org/api/timezone/Europe/London'));
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
DateTime time = DateTime.parse(json['datetime']);
|
||||
timeOffset = time.millisecondsSinceEpoch - DateTime.now().millisecondsSinceEpoch;
|
||||
lastUpdate = DateTime.now();
|
||||
print("Time offset: $timeOffset");
|
||||
} else {
|
||||
print("Failed to get time from worldtimeapi.org");
|
||||
}
|
||||
});
|
||||
|
||||
DateTime Now() {
|
||||
if (timeOffset == -1) {
|
||||
return DateTime.now();
|
||||
}
|
||||
return DateTime.now().add(Duration(milliseconds: timeOffset));
|
||||
}
|
||||
}
|
||||
199
lib/backend/modules/tracker.dart
Normal file
199
lib/backend/modules/tracker.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bus_infotainment/backend/live_information.dart';
|
||||
import 'package:bus_infotainment/backend/modules/info_module.dart';
|
||||
import 'package:bus_infotainment/tfl_datasets.dart';
|
||||
import 'package:bus_infotainment/utils/OrdinanceSurveyUtils.dart';
|
||||
import 'package:bus_infotainment/utils/audio%20wrapper.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:vector_math/vector_math.dart';
|
||||
|
||||
class TrackerModule extends InfoModule {
|
||||
|
||||
|
||||
TrackerModule() {
|
||||
locationStream();
|
||||
Geolocator.getLastKnownPosition().then((Position? position) {
|
||||
this._position = position;
|
||||
updateNearestStop();
|
||||
});
|
||||
liveInformation.routeVariantDelegate.addListener((routeVariant) {
|
||||
print("Route variant changed");
|
||||
updateNearestStop();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Position? _position;
|
||||
Position? get position => _position;
|
||||
|
||||
StreamSubscription<Position> locationStream() => Geolocator.getPositionStream(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 1,
|
||||
)
|
||||
).listen((Position position) {
|
||||
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._position = position;
|
||||
updateNearestStop();
|
||||
});
|
||||
|
||||
Timer refreshTimer() => Timer.periodic(Duration(seconds: 1), (timer) async {
|
||||
_position = await Geolocator.getCurrentPosition();
|
||||
});
|
||||
|
||||
BusRouteStop? nearestStop;
|
||||
bool hasArrived = false;
|
||||
|
||||
Future<void> updateNearestStop() async {
|
||||
if (liveInformation.getRouteVariant() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the closest stop
|
||||
BusRouteStop closestStop = liveInformation.getRouteVariant()!.busStops.first;
|
||||
double closestDistance = OSGrid
|
||||
.toNorthingEasting(_position!.latitude, _position!.longitude)
|
||||
.distanceTo(Vector2(closestStop.easting.toDouble(), closestStop.northing.toDouble()));
|
||||
|
||||
for (BusRouteStop stop in liveInformation.getRouteVariant()!.busStops) {
|
||||
double distance = OSGrid
|
||||
.toNorthingEasting(_position!.latitude, _position!.longitude)
|
||||
.distanceTo(Vector2(stop.easting.toDouble(), stop.northing.toDouble()));
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestStop = stop;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
double relativeDistance = _calculateRelativeDistance(closestStop, _position!.latitude, _position!.longitude);
|
||||
|
||||
|
||||
|
||||
if (relativeDistance < -10) {
|
||||
print("Closest stop is behind us: ${closestStop.formattedStopName}");
|
||||
print("Relative distance: $relativeDistance");
|
||||
|
||||
int stopIndex = liveInformation.getRouteVariant()!.busStops.indexOf(closestStop);
|
||||
|
||||
closestStop = liveInformation.getRouteVariant()!.busStops[stopIndex + 1];
|
||||
|
||||
print("Closest stop is now: ${closestStop.formattedStopName}");
|
||||
} else {
|
||||
print("Closest stop is in front of us: ${closestStop.formattedStopName}");
|
||||
}
|
||||
|
||||
bool preExisting = true;
|
||||
|
||||
if (nearestStop != closestStop) {
|
||||
nearestStop = closestStop;
|
||||
hasArrived = false;
|
||||
preExisting = false;
|
||||
}
|
||||
|
||||
print("Closest stop is the same as before");
|
||||
|
||||
double distance = OSGrid
|
||||
.toNorthingEasting(_position!.latitude, _position!.longitude)
|
||||
.distanceTo(Vector2(nearestStop!.easting.toDouble(), nearestStop!.northing.toDouble()));
|
||||
|
||||
// get the speed in mph
|
||||
double speed = _position!.speed * 2.23694;
|
||||
print("Speed: $speed");
|
||||
|
||||
Duration? duration;
|
||||
{
|
||||
// Testing some audio stuff
|
||||
Uint8List? audioBytes = liveInformation.announcementModule.announcementCache[nearestStop!.getAudioFileName()];
|
||||
AudioWrapperByteSource audioSource = AudioWrapperByteSource(audioBytes!);
|
||||
|
||||
AudioWrapper audio = AudioWrapper();
|
||||
|
||||
await audio.loadSource(audioSource);
|
||||
|
||||
duration = await audio.getDuration();
|
||||
|
||||
print("Duration of audio: $duration");
|
||||
}
|
||||
|
||||
// get the estimated distance travelled in 5 seconds, in meters
|
||||
double distanceTravelled = speed * (3 + duration!.inSeconds);
|
||||
|
||||
// adjust for the it takes to send the announcement to other devices
|
||||
distance -= distanceTravelled;
|
||||
|
||||
// get the time to the stop in seconds
|
||||
double timeToStop = distance / speed;
|
||||
|
||||
print("Distance to stop: $distance");
|
||||
print("Time to stop: $timeToStop");
|
||||
|
||||
int secondsBefore = 7;
|
||||
|
||||
print("Seconds before: $secondsBefore");
|
||||
|
||||
if ((timeToStop < secondsBefore ) && !hasArrived && relativeDistance > 0) {
|
||||
print("We are at the stop");
|
||||
hasArrived = true;
|
||||
liveInformation.announcementModule.queueAnnounceByAudioName(
|
||||
displayText: "${nearestStop!.formattedStopName}",
|
||||
audioNames: [
|
||||
// "A_NEXT_STOP_001.mp3",
|
||||
nearestStop!.getAudioFileName()
|
||||
],
|
||||
sendToServer: true
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!hasArrived && !preExisting) {
|
||||
liveInformation.announcementModule.queueAnnounceByAudioName(
|
||||
displayText: "${closestStop.formattedStopName}",
|
||||
audioNames: [],
|
||||
sendToServer: true
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
print("Closest stop: ${closestStop.formattedStopName} in ${closestDistance.round()} meters");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
double _calculateRelativeDistance(BusRouteStop stop, double latitude, double longitude) {
|
||||
|
||||
List<double> toLatLong = OSGrid.toLatLong(stop.northing.toDouble(), stop.easting.toDouble());
|
||||
|
||||
Vector2 stopPoint = OSGrid.toNorthingEasting(toLatLong[0], toLatLong[1]);
|
||||
Vector2 currentPoint = OSGrid.toNorthingEasting(latitude, longitude);
|
||||
|
||||
// calculate the heading from the current point to the stop point
|
||||
// 0 degrees is north, 90 degrees is east, 180 degrees is south, 270 degrees is west
|
||||
double toHeading = degrees(atan2(stopPoint.x - currentPoint.x, stopPoint.y - currentPoint.y));
|
||||
toHeading = (toHeading + 360) % 360;
|
||||
|
||||
// get the dot product of the heading and the stop heading
|
||||
double dotProduct = cos(radians(toHeading)) * cos(radians(stop.heading.toDouble())) + sin(radians(toHeading)) * sin(radians(stop.heading.toDouble()));
|
||||
|
||||
return (dotProduct.sign) * _calculateDistance(latitude, longitude, toLatLong[0], toLatLong[1]);
|
||||
}
|
||||
|
||||
double _calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||
|
||||
// Convert to eastings and northings
|
||||
Vector2 point1 = OSGrid.toNorthingEasting(lat1, lon1);
|
||||
Vector2 point2 = OSGrid.toNorthingEasting(lat2, lon2);
|
||||
|
||||
return point1.distanceTo(point2);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user