diff options
author | Scott Murray <scott.murray@konsulko.com> | 2023-12-31 16:24:51 -0500 |
---|---|---|
committer | Scott Murray <scott.murray@konsulko.com> | 2024-01-03 18:23:52 -0500 |
commit | 4742fde5c48726357cc8db06d237e9db6c3df608 (patch) | |
tree | dcca2b3e3c6cb3a4a46b7ae603f64fa9ce5a086c /lib/data/data_providers | |
parent | fcd868bd73d35bd79074f3425317152565aeb275 (diff) |
Initial radio implementation
Notable changes:
- Add radio gRPC API protobuf definitation and generated files.
- Reworked existing single gRPC APIs library to split it into
per-API libraries to avoid name collision issues.
- Add radio gRPC client class and associated radio state class
and RiverPod providers.
- Split media controls and play list table classes into media
player and radio specific versions to facilitate customization
and wiring up their appropriate backends in a straightforward
fashion. Some potential rationalization of styling widgets
may be done as a follow up to avoid some duplication.
- Added radio configuration and presets loading. The presets
will be populated with the contents of a radio-presets.yaml
file from the configured location, the default location is
the /etc/xdg/AGL/ics-homescreen directory.
- Implemented FM radio player against the radio gRPC API.
For the sake of expediency, no attempt has been made to make
the player able to handle AM band support.
- Reworked media page navigation state so that active player is
restored when coming back to the page. Logic has been added to
start/stop the radio on navigating to or leaving the FM radio
sub-page. This will potentially be reworked before CES to work
with the pause/stop button present on the other pages.
- Started pruning down global exports.dart a bit to remove files
only used in a specific page/hierarchy, starting with media.
Bug-AGL: SPEC-5029
Change-Id: I1ae0aca4a7a8218e69e4286c863f01509a1cccb7
Signed-off-by: Scott Murray <scott.murray@konsulko.com>
Diffstat (limited to 'lib/data/data_providers')
-rw-r--r-- | lib/data/data_providers/app_config_provider.dart | 72 | ||||
-rw-r--r-- | lib/data/data_providers/app_launcher.dart | 36 | ||||
-rw-r--r-- | lib/data/data_providers/app_provider.dart | 19 | ||||
-rw-r--r-- | lib/data/data_providers/audio_notifier.dart | 8 | ||||
-rw-r--r-- | lib/data/data_providers/radio_client.dart | 161 | ||||
-rw-r--r-- | lib/data/data_providers/radio_notifier.dart | 31 | ||||
-rw-r--r-- | lib/data/data_providers/radio_presets_provider.dart | 49 | ||||
-rw-r--r-- | lib/data/data_providers/val_client.dart | 7 | ||||
-rw-r--r-- | lib/data/data_providers/vehicle_notifier.dart | 2 |
9 files changed, 351 insertions, 34 deletions
diff --git a/lib/data/data_providers/app_config_provider.dart b/lib/data/data_providers/app_config_provider.dart index 7e0ddc6..a60a462 100644 --- a/lib/data/data_providers/app_config_provider.dart +++ b/lib/data/data_providers/app_config_provider.dart @@ -35,14 +35,40 @@ class KuksaConfig { } } +class RadioConfig { + final String hostname; + final int port; + final String presets; + + static String defaultHostname = 'localhost'; + static int defaultPort = 50053; + static String defaultPresets = + '/etc/xdg/AGL/ics-homescreen/radio-presets.yaml'; + + RadioConfig( + {required this.hostname, required this.port, required this.presets}); + + static RadioConfig defaultConfig() { + return RadioConfig( + hostname: RadioConfig.defaultHostname, + port: RadioConfig.defaultPort, + presets: RadioConfig.defaultPresets); + } +} + class AppConfig { final bool disableBkgAnimation; final bool randomHybridAnimation; final KuksaConfig kuksaConfig; + final RadioConfig radioConfig; static String configFilePath = '/etc/xdg/AGL/ics-homescreen.yaml'; - AppConfig({required this.disableBkgAnimation, required this.randomHybridAnimation, required this.kuksaConfig}); + AppConfig( + {required this.disableBkgAnimation, + required this.randomHybridAnimation, + required this.kuksaConfig, + required this.radioConfig}); static KuksaConfig parseKuksaConfig(YamlMap kuksaMap) { try { @@ -64,7 +90,7 @@ class AppConfig { debugPrint("Reading authorization token $s"); try { token = File(s).readAsStringSync(); - } on Exception catch (_) { + } catch (_) { print("ERROR: Could not read authorization token file $token"); token = ""; } @@ -89,7 +115,7 @@ class AppConfig { } try { ca_cert = File(ca_path).readAsBytesSync(); - } on Exception catch (_) { + } catch (_) { print("ERROR: Could not read CA certificate file $ca_path"); ca_cert = []; } @@ -107,10 +133,33 @@ class AppConfig { use_tls: use_tls, ca_certificate: ca_cert, tls_server_name: tls_server_name); - } on Exception catch (_) { + } catch (_) { return KuksaConfig.defaultConfig(); } } + + static RadioConfig parseRadioConfig(YamlMap radioMap) { + try { + String hostname = RadioConfig.defaultHostname; + if (radioMap.containsKey('hostname')) { + hostname = radioMap['hostname']; + } + + int port = RadioConfig.defaultPort; + if (radioMap.containsKey('port')) { + port = radioMap['port']; + } + + String presets = RadioConfig.defaultPresets; + if (radioMap.containsKey('presets')) { + hostname = radioMap['presets']; + } + + return RadioConfig(hostname: hostname, port: port, presets: presets); + } catch (_) { + return RadioConfig.defaultConfig(); + } + } } final appConfigProvider = Provider((ref) { @@ -133,6 +182,13 @@ final appConfigProvider = Provider((ref) { tls_server_name: ""); } + RadioConfig radioConfig; + if (yamlMap.containsKey('radio')) { + radioConfig = AppConfig.parseRadioConfig(yamlMap['radio']); + } else { + radioConfig = RadioConfig.defaultConfig(); + } + bool disableBkgAnimation = disableBkgAnimationDefault; if (yamlMap.containsKey('disable-bg-animation')) { var value = yamlMap['disable-bg-animation']; @@ -152,11 +208,13 @@ final appConfigProvider = Provider((ref) { return AppConfig( disableBkgAnimation: disableBkgAnimation, randomHybridAnimation: randomHybridAnimation, - kuksaConfig: kuksaConfig); - } on Exception catch (_) { + kuksaConfig: kuksaConfig, + radioConfig: radioConfig); + } catch (_) { return AppConfig( disableBkgAnimation: false, randomHybridAnimation: false, - kuksaConfig: KuksaConfig.defaultConfig()); + kuksaConfig: KuksaConfig.defaultConfig(), + radioConfig: RadioConfig.defaultConfig()); } }); diff --git a/lib/data/data_providers/app_launcher.dart b/lib/data/data_providers/app_launcher.dart index b0199d3..8762643 100644 --- a/lib/data/data_providers/app_launcher.dart +++ b/lib/data/data_providers/app_launcher.dart @@ -1,5 +1,6 @@ import 'package:flutter_ics_homescreen/export.dart'; -import 'package:protos/protos.dart'; +import 'package:protos/applauncher-api.dart'; +import 'package:protos/agl-shell-api.dart'; class AppLauncher { final Ref ref; @@ -9,20 +10,18 @@ class AppLauncher { late ClientChannel appLauncherChannel; late AppLauncherClient appLauncher; - List<String> appStack = [ 'homescreen' ]; + List<String> appStack = ['homescreen']; AppLauncher({required this.ref}) { - aglShellChannel = - ClientChannel('localhost', - port: 14005, - options: ChannelOptions(credentials: ChannelCredentials.insecure())); + aglShellChannel = ClientChannel('localhost', + port: 14005, + options: ChannelOptions(credentials: ChannelCredentials.insecure())); aglShell = AglShellManagerServiceClient(aglShellChannel); - appLauncherChannel = - ClientChannel('localhost', - port: 50052, - options: ChannelOptions(credentials: ChannelCredentials.insecure())); + appLauncherChannel = ClientChannel('localhost', + port: 50052, + options: ChannelOptions(credentials: ChannelCredentials.insecure())); appLauncher = AppLauncherClient(appLauncherChannel); } @@ -58,13 +57,23 @@ class AppLauncher { debugPrint("Got app:"); debugPrint("$info"); // Existing icons are currently not usable, so leave blank for now - apps.add(AppLauncherInfo(id: info.id, name: info.name, icon: "", internal: false)); + apps.add(AppLauncherInfo( + id: info.id, name: info.name, icon: "", internal: false)); } apps.sort((a, b) => a.name.compareTo(b.name)); // Add built-in app widgets - apps.insert(0, AppLauncherInfo(id: "clock", name: "Clock", icon: "clock.svg", internal: true)); - apps.insert(0, AppLauncherInfo(id: "weather", name: "Weather", icon: "weather.svg", internal: true)); + apps.insert( + 0, + AppLauncherInfo( + id: "clock", name: "Clock", icon: "clock.svg", internal: true)); + apps.insert( + 0, + AppLauncherInfo( + id: "weather", + name: "Weather", + icon: "weather.svg", + internal: true)); ref.read(appLauncherListProvider.notifier).update(apps); } catch (e) { @@ -104,5 +113,4 @@ class AppLauncher { } } } - } diff --git a/lib/data/data_providers/app_provider.dart b/lib/data/data_providers/app_provider.dart index 1670eba..ad3dd22 100644 --- a/lib/data/data_providers/app_provider.dart +++ b/lib/data/data_providers/app_provider.dart @@ -4,8 +4,10 @@ import 'package:flutter_ics_homescreen/data/data_providers/time_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/units_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/audio_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/users_notifier.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/radio_notifier.dart'; import 'package:flutter_ics_homescreen/data/data_providers/val_client.dart'; import 'package:flutter_ics_homescreen/data/data_providers/app_launcher.dart'; +import 'package:flutter_ics_homescreen/data/data_providers/radio_client.dart'; import 'package:flutter_ics_homescreen/export.dart'; import '../models/users.dart'; @@ -16,7 +18,7 @@ enum AppState { dashboard, hvac, apps, - mediaPlayer, + media, settings, splash, dateTime, @@ -48,7 +50,14 @@ final appLauncherProvider = Provider((ref) { return AppLauncher(ref: ref); }); -final appLauncherListProvider = NotifierProvider<AppLauncherList, List<AppLauncherInfo>>(AppLauncherList.new); +final appLauncherListProvider = + NotifierProvider<AppLauncherList, List<AppLauncherInfo>>( + AppLauncherList.new); + +final radioClientProvider = Provider((ref) { + RadioConfig config = ref.watch(appConfigProvider).radioConfig; + return RadioClient(config: config, ref: ref); +}); final vehicleProvider = NotifierProvider<VehicleNotifier, Vehicle>(VehicleNotifier.new); @@ -62,7 +71,10 @@ final unitStateProvider = StateNotifierProvider<UnitsNotifier, Units>((ref) { }); final audioStateProvider = - NotifierProvider<AudioNotifier, Audio>(AudioNotifier.new); + NotifierProvider<AudioStateNotifier, AudioState>(AudioStateNotifier.new); + +final radioStateProvider = + NotifierProvider<RadioStateNotifier, RadioState>(RadioStateNotifier.new); final usersProvider = StateNotifierProvider<UsersNotifier, Users>((ref) { return UsersNotifier(Users.initial()); @@ -77,4 +89,3 @@ final currentTimeProvider = StateNotifierProvider<CurrentTimeNotifier, DateTime>((ref) { return CurrentTimeNotifier(); }); - diff --git a/lib/data/data_providers/audio_notifier.dart b/lib/data/data_providers/audio_notifier.dart index 32ab409..a601095 100644 --- a/lib/data/data_providers/audio_notifier.dart +++ b/lib/data/data_providers/audio_notifier.dart @@ -1,10 +1,10 @@ import 'package:flutter_ics_homescreen/export.dart'; -import 'package:protos/protos.dart'; +import 'package:protos/val-api.dart'; -class AudioNotifier extends Notifier<Audio> { +class AudioStateNotifier extends Notifier<AudioState> { @override - Audio build() { - return Audio.initial(); + AudioState build() { + return AudioState.initial(); } void resetToDefaults() { diff --git a/lib/data/data_providers/radio_client.dart b/lib/data/data_providers/radio_client.dart new file mode 100644 index 0000000..2cde65e --- /dev/null +++ b/lib/data/data_providers/radio_client.dart @@ -0,0 +1,161 @@ +import 'package:flutter_ics_homescreen/export.dart'; +import 'package:protos/radio-api.dart' as api; + +class RadioClient { + final RadioConfig config; + final Ref ref; + late api.ClientChannel channel; + late api.RadioClient stub; + + RadioClient({required this.config, required this.ref}) { + debugPrint( + "Connecting to radio service at ${config.hostname}:${config.port}"); + api.ChannelCredentials creds = const api.ChannelCredentials.insecure(); + channel = api.ClientChannel(config.hostname, + port: config.port, options: api.ChannelOptions(credentials: creds)); + stub = api.RadioClient(channel); + } + + void run() async { + getBandParameters(); + + try { + var responseStream = stub.getStatusEvents(api.StatusRequest()); + await for (var event in responseStream) { + handleStatusEvent(event); + } + } catch (e) { + print(e); + } + } + + void getBandParameters() async { + try { + var response = await stub.getBandParameters( + api.GetBandParametersRequest(band: api.Band.BAND_FM)); + ref.read(radioStateProvider.notifier).updateBandParameters( + freqMin: response.min, + freqMax: response.max, + freqStep: response.step); + + // Get initial frequency + var freqResponse = await stub.getFrequency(api.GetFrequencyRequest()); + ref + .read(radioStateProvider.notifier) + .updateFrequency(freqResponse.frequency); + } catch (e) { + print(e); + } + } + + void handleStatusEvent(api.StatusResponse response) { + switch (response.whichStatus()) { + case api.StatusResponse_Status.frequency: + var status = response.frequency; + ref.read(radioStateProvider.notifier).updateFrequency(status.frequency); + break; + case api.StatusResponse_Status.play: + var status = response.play; + ref.read(radioStateProvider.notifier).updatePlaying(status.playing); + break; + case api.StatusResponse_Status.scan: + var status = response.scan; + if (status.stationFound) { + ref.read(radioStateProvider.notifier).updateScanning(false); + } + break; + default: + break; + } + } + + void start() async { + try { + var response = await stub.start(api.StartRequest()); + } catch (e) { + print(e); + } + } + + void stop() async { + try { + var response = await stub.stop(api.StopRequest()); + } catch (e) { + print(e); + } + } + + void setFrequency(int frequency) async { + var radioState = ref.read(radioStateProvider); + if ((frequency < radioState.freqMin) || + (frequency > radioState.freqMax) || + ((frequency - radioState.freqMin) % radioState.freqStep) != 0) { + debugPrint("setFrequency: invalid frequency $frequency!"); + return; + } + try { + var response = await stub + .setFrequency(api.SetFrequencyRequest(frequency: frequency)); + } catch (e) { + print(e); + } + } + + void tuneForward() async { + var radioState = ref.read(radioStateProvider); + if (radioState.freqCurrent < radioState.freqMax) { + int frequency = radioState.freqCurrent + radioState.freqStep; + if (frequency > radioState.freqMax) { + frequency = radioState.freqMax; + } + try { + var response = await stub + .setFrequency(api.SetFrequencyRequest(frequency: frequency)); + } catch (e) { + print(e); + } + } + } + + void tuneBackward() async { + var radioState = ref.read(radioStateProvider); + if (radioState.freqCurrent > radioState.freqMin) { + int frequency = radioState.freqCurrent - radioState.freqStep; + if (frequency < radioState.freqMin) { + frequency = radioState.freqMin; + } + try { + var response = await stub + .setFrequency(api.SetFrequencyRequest(frequency: frequency)); + } catch (e) { + print(e); + } + } + } + + void scanForward() async { + try { + var response = await stub.scanStart(api.ScanStartRequest( + direction: api.ScanDirection.SCAN_DIRECTION_FORWARD)); + } catch (e) { + print(e); + } + } + + void scanBackward() async { + try { + var response = await stub.scanStart(api.ScanStartRequest( + direction: api.ScanDirection.SCAN_DIRECTION_BACKWARD)); + } catch (e) { + print(e); + } + } + + void scanStop() async { + try { + var response = await stub.scanStop(api.ScanStopRequest()); + } catch (e) { + print(e); + } + } +} diff --git a/lib/data/data_providers/radio_notifier.dart b/lib/data/data_providers/radio_notifier.dart new file mode 100644 index 0000000..90e0df5 --- /dev/null +++ b/lib/data/data_providers/radio_notifier.dart @@ -0,0 +1,31 @@ +import 'package:flutter_ics_homescreen/export.dart'; + +class RadioStateNotifier extends Notifier<RadioState> { + @override + RadioState build() { + return RadioState.initial(); + } + + void updateBandParameters( + {required int freqMin, required freqMax, required freqStep}) { + state = + state.copyWith(freqMin: freqMin, freqMax: freqMax, freqStep: freqStep); + } + + void updateFrequency(int frequency) { + state = state.copyWith(freqCurrent: frequency); + } + + void setFrequency(int frequency) { + state = state.copyWith(freqCurrent: frequency); + ref.read(radioClientProvider).setFrequency(frequency); + } + + void updatePlaying(bool playing) { + state = state.copyWith(playing: playing); + } + + void updateScanning(bool scanning) { + state = state.copyWith(scanning: scanning); + } +} diff --git a/lib/data/data_providers/radio_presets_provider.dart b/lib/data/data_providers/radio_presets_provider.dart new file mode 100644 index 0000000..9ee68ac --- /dev/null +++ b/lib/data/data_providers/radio_presets_provider.dart @@ -0,0 +1,49 @@ +import 'dart:io'; +import 'package:flutter_ics_homescreen/export.dart'; +import 'package:yaml/yaml.dart'; + +class RadioPreset { + final int frequency; + final String name; + + RadioPreset({required this.frequency, required this.name}); +} + +class RadioPresets { + final List<RadioPreset> fmPresets; + + RadioPresets({required this.fmPresets}); +} + +final radioPresetsProvider = Provider((ref) { + final presetsFilename = ref.read(appConfigProvider).radioConfig.presets; + if (presetsFilename.isEmpty) { + return RadioPresets(fmPresets: []); + } + try { + print("Reading radio presets $presetsFilename"); + var presetsFile = File(presetsFilename); + String content = presetsFile.readAsStringSync(); + final dynamic yamlMap = loadYaml(content); + + List<RadioPreset> presets = []; + if (yamlMap.containsKey('fm')) { + dynamic list = yamlMap['fm']; + if (list is YamlList) { + for (var element in list) { + if ((element is YamlMap) && + element.containsKey('frequency') && + element.containsKey('name')) { + presets.add(RadioPreset( + frequency: element['frequency'].toInt(), + name: element['name'].toString())); + } + } + } + } + return RadioPresets(fmPresets: presets); + } catch (_) { + debugPrint("Exception reading presets!"); + return RadioPresets(fmPresets: []); + } +}); diff --git a/lib/data/data_providers/val_client.dart b/lib/data/data_providers/val_client.dart index 28bb480..db962ee 100644 --- a/lib/data/data_providers/val_client.dart +++ b/lib/data/data_providers/val_client.dart @@ -1,5 +1,5 @@ import 'package:flutter_ics_homescreen/export.dart'; -import 'package:protos/protos.dart'; +import 'package:protos/val-api.dart'; class ValClient { final KuksaConfig config; @@ -9,7 +9,7 @@ class ValClient { late String authorization; ValClient({required this.config, required this.ref}) { - debugPrint("Using ${config.hostname}:${config.port}"); + debugPrint("Connecting to KUKSA.val at ${config.hostname}:${config.port}"); ChannelCredentials creds; if (config.use_tls && config.ca_certificate.isNotEmpty) { print("Using TLS"); @@ -25,11 +25,10 @@ class ValClient { } channel = ClientChannel(config.hostname, port: config.port, options: ChannelOptions(credentials: creds)); - debugPrint('Start Listen on port: ${config.port}'); stub = VALClient(channel); } - void startListen() async { + void run() async { List<String> fewSignals = VSSPath().getSignalsList(); var request = SubscribeRequest(); Map<String, String> metadata = {}; diff --git a/lib/data/data_providers/vehicle_notifier.dart b/lib/data/data_providers/vehicle_notifier.dart index 78c5328..6fafb8c 100644 --- a/lib/data/data_providers/vehicle_notifier.dart +++ b/lib/data/data_providers/vehicle_notifier.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter_ics_homescreen/export.dart'; -import 'package:protos/protos.dart'; +import 'package:protos/val-api.dart'; class VehicleNotifier extends Notifier<Vehicle> { @override |