2
0
mirror of https://github.com/9ParsonsB/Pulsar.git synced 2025-04-05 17:39:39 -04:00

observatory herald (#30)

* WIP: initial commit for observatory herald

* Plugin error handling refactor

* make error window non-modal

* tidy up plugin error handling

* first pass for basic herald functionality

* corrections for linux env

* Use FNV hash directly instead of managing through dictionary/index file

* resolve audio queuing issue, switch to personal NetCoreAudio fork

* merge cleanup

* add enable setting, populate defaults

* framework xml doc update

* Adjust settings, add style selection, replace locale with demonym in dropdown list.

* Test is position is on screen before saving/loading.

* use a default that's actually in the list
This commit is contained in:
Xjph 2021-11-15 10:57:46 -03:30 committed by GitHub
parent 9ad3f77bb8
commit 554948534e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1144 additions and 61 deletions

View File

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Observatory
{
public static class ErrorReporter
{
public static void ShowErrorPopup(string title, string message)
{
if (Avalonia.Application.Current.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
{
var errorMessage = MessageBox.Avalonia.MessageBoxManager
.GetMessageBoxStandardWindow(new MessageBox.Avalonia.DTO.MessageBoxStandardParams
{
ContentTitle = title,
ContentMessage = message,
Topmost = true
});
errorMessage.Show();
}
}
}
}

View File

@ -318,18 +318,7 @@ namespace Observatory
}
}
if (Avalonia.Application.Current.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop)
{
var errorMessage = MessageBox.Avalonia.MessageBoxManager
.GetMessageBoxStandardWindow(new MessageBox.Avalonia.DTO.MessageBoxStandardParams
{
ContentTitle = $"Journal Read Error{(readErrors.Count > 1 ? "s" : "")}",
ContentMessage = errorContent.ToString()
});
errorMessage.ShowDialog(desktop.MainWindow);
}
ErrorReporter.ShowErrorPopup($"Journal Read Error{(readErrors.Count > 1 ? "s" : "")}", errorContent.ToString());
}
}

View File

@ -85,36 +85,12 @@ namespace Observatory.NativeNotification
XmlNamespaceManager ssmlNs = new(ssmlDoc.NameTable);
ssmlNs.AddNamespace("ssml", ssmlNamespace);
//If the SSML already has a voice element leave it alone.
if (ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs) == null)
{
//Preserve existing content to place it in new voice element
string speakContent = ssmlDoc.DocumentElement.InnerXml;
speakContent = speakContent.Replace($"xmlns=\"{ssmlNs.LookupNamespace("ssml")}\"", string.Empty);
//Crete new voice element and name attribute objects
var voiceElement = ssmlDoc.CreateElement("voice", ssmlNs.LookupNamespace("ssml"));
var voiceAttribute = ssmlDoc.CreateAttribute("name");
var voiceNode = ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs);
//Update content of new element
voiceAttribute.Value = voiceName;
voiceElement.Attributes.Append(voiceAttribute);
voiceElement.InnerXml = speakContent;
//Clear existing content and insert new element
ssmlDoc.DocumentElement.InnerText = string.Empty;
ssmlDoc.DocumentElement.AppendChild(voiceElement);
voiceNode.Attributes.GetNamedItem("name").Value = voiceName;
ssml = ssmlDoc.OuterXml;
}
//If I leave the namespace in speakContent above it's left behind as a redundant
//attribute which breaks the speech generation.
//If I remove it then the XmlDoc explicitly adds an empty namespace which *also*
//breaks speech generation.
//The empty one is easier to remove later, so that's what I'm doing, but if someone
//has a better suggestion I'm all for it.
return ssml.Replace("xmlns=\"\"", string.Empty);
return ssmlDoc.OuterXml;
}
}
}

View File

@ -14,18 +14,32 @@ namespace Observatory.PluginManagement
{
private IEnumerable<IObservatoryWorker> observatoryWorkers;
private IEnumerable<IObservatoryNotifier> observatoryNotifiers;
private List<string> errorList;
public PluginEventHandler(IEnumerable<IObservatoryWorker> observatoryWorkers, IEnumerable<IObservatoryNotifier> observatoryNotifiers)
{
this.observatoryWorkers = observatoryWorkers;
this.observatoryNotifiers = observatoryNotifiers;
errorList = new();
}
public void OnJournalEvent(object source, JournalEventArgs journalEventArgs)
{
foreach (var worker in observatoryWorkers)
{
worker.JournalEvent((JournalBase)journalEventArgs.journalEvent);
try
{
worker.JournalEvent((JournalBase)journalEventArgs.journalEvent);
}
catch (PluginException ex)
{
RecordError(ex);
}
catch (Exception ex)
{
RecordError(ex, worker.Name, journalEventArgs.journalType.Name);
}
ReportErrorsIfAny();
}
}
@ -33,7 +47,19 @@ namespace Observatory.PluginManagement
{
foreach (var worker in observatoryWorkers)
{
worker.StatusChange((Status)journalEventArgs.journalEvent);
try
{
worker.StatusChange((Status)journalEventArgs.journalEvent);
}
catch (PluginException ex)
{
RecordError(ex);
}
catch (Exception ex)
{
RecordError(ex, worker.Name, journalEventArgs.journalType.Name);
}
ReportErrorsIfAny();
}
}
@ -41,7 +67,39 @@ namespace Observatory.PluginManagement
{
foreach (var notifier in observatoryNotifiers)
{
notifier.OnNotificationEvent(notificationArgs);
try
{
notifier.OnNotificationEvent(notificationArgs);
}
catch (PluginException ex)
{
RecordError(ex);
}
catch (Exception ex)
{
RecordError(ex, notifier.Name, notificationArgs.Title);
}
ReportErrorsIfAny();
}
}
private void RecordError(PluginException ex)
{
errorList.Add($"Error in {ex.PluginName}: {ex.Message}");
}
private void RecordError(Exception ex, string plugin, string eventType)
{
errorList.Add($"Error in {plugin} while handling {eventType}: {ex.Message}");
}
private void ReportErrorsIfAny()
{
if (errorList.Any())
{
ErrorReporter.ShowErrorPopup($"Plugin Error{(errorList.Count > 1 ? "s" : "")}", string.Join(Environment.NewLine, errorList));
errorList.Clear();
}
}
}

View File

@ -6,7 +6,7 @@ using System.Reflection;
using System.Data;
using Observatory.Framework.Interfaces;
using System.IO;
using System.Configuration;
using Observatory.Framework;
using System.Text.Json;
namespace Observatory.PluginManagement
@ -39,11 +39,6 @@ namespace Observatory.PluginManagement
{
errorList = LoadPlugins(out workerPlugins, out notifyPlugins);
foreach (var error in errorList)
{
Console.WriteLine(error);
}
var pluginHandler = new PluginEventHandler(workerPlugins.Select(p => p.plugin), notifyPlugins.Select(p => p.plugin));
var logMonitor = LogMonitor.GetInstance;
pluginPanels = new();
@ -54,24 +49,53 @@ namespace Observatory.PluginManagement
var core = new PluginCore();
List<IObservatoryPlugin> errorPlugins = new();
foreach (var plugin in workerPlugins.Select(p => p.plugin))
{
LoadSettings(plugin);
plugin.Load(core);
try
{
LoadSettings(plugin);
plugin.Load(core);
}
catch (PluginException ex)
{
errorList.Add(FormatErrorMessage(ex));
errorPlugins.Add(plugin);
}
}
workerPlugins.RemoveAll(w => errorPlugins.Contains(w.plugin));
errorPlugins.Clear();
foreach (var plugin in notifyPlugins.Select(p => p.plugin))
{
// Notifiers which are also workers need not be loaded again (they are the same instance).
if (!plugin.GetType().IsAssignableTo(typeof(IObservatoryWorker)))
{
LoadSettings(plugin);
plugin.Load(core);
try
{
LoadSettings(plugin);
plugin.Load(core);
}
catch (PluginException ex)
{
errorList.Add(FormatErrorMessage(ex));
errorPlugins.Add(plugin);
}
}
}
notifyPlugins.RemoveAll(n => errorPlugins.Contains(n.plugin));
core.Notification += pluginHandler.OnNotificationEvent;
}
private static string FormatErrorMessage(PluginException ex)
{
return $"{ex.PluginName}: {ex.UserMessage}";
}
private void LoadSettings(IObservatoryPlugin plugin)
{
string savedSettings = Properties.Core.Default.PluginSettings;

View File

@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Linq;
namespace Observatory.UI.ViewModels
{
@ -9,6 +9,9 @@ namespace Observatory.UI.ViewModels
public MainWindowViewModel(PluginManagement.PluginManager pluginManager)
{
core = new CoreViewModel(pluginManager.workerPlugins, pluginManager.notifyPlugins);
if (pluginManager.errorList.Any())
ErrorReporter.ShowErrorPopup("Plugin Load Error", string.Join(Environment.NewLine, pluginManager.errorList));
}
public CoreViewModel core { get; }

View File

@ -535,7 +535,7 @@ namespace Observatory.UI.Views
NotificationArgs args = new()
{
Title = "Speech Synthesis Test",
TitleSsml = "<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\">Speech Synthesis Test</speak>",
TitleSsml = "<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">Speech Synthesis Test</voice></speak>",
Detail = harvardSentences.OrderBy(s => new Random().NextDouble()).First()
};
@ -943,6 +943,46 @@ namespace Observatory.UI.Views
settingsGrid.AddControl(actionButton, settingsGrid.RowDefinitions.Count - 1, 0);
break;
case Dictionary<string, object> dictSetting:
var backingValueName = (SettingBackingValue)Attribute.GetCustomAttribute(setting.Key, typeof(SettingBackingValue));
var backingValue = from s in displayedSettings
where s.Value == backingValueName.BackingProperty
select s.Key;
if (backingValue.Count() != 1)
throw new($"{plugin.ShortName}: Dictionary settings must have exactly one backing value.");
label.Text += ": ";
ComboBox selectionDropDown = new()
{
MinWidth = 200
};
selectionDropDown.Items = from s in dictSetting
orderby s.Key
select s.Key;
string currentSelection = backingValue.First().GetValue(plugin.Settings)?.ToString();
if (currentSelection?.Length > 0)
{
selectionDropDown.SelectedItem = currentSelection;
}
selectionDropDown.SelectionChanged += (object sender, SelectionChangedEventArgs e) =>
{
var comboBox = (ComboBox)sender;
backingValue.First().SetValue(plugin.Settings, comboBox.SelectedItem.ToString());
PluginManagement.PluginManager.GetInstance.SaveSettings(plugin, plugin.Settings);
};
settingsGrid.AddControl(label, settingsGrid.RowDefinitions.Count - 1, 0);
settingsGrid.AddControl(selectionDropDown, settingsGrid.RowDefinitions.Count - 1, 1);
break;
}
}

View File

@ -15,17 +15,43 @@ namespace Observatory.UI.Views
#endif
Height = Properties.Core.Default.MainWindowSize.Height;
Width = Properties.Core.Default.MainWindowSize.Width;
Position = new PixelPoint(Properties.Core.Default.MainWindowPosition.X, Properties.Core.Default.MainWindowPosition.Y);
var savedPosition = new System.Drawing.Point(Properties.Core.Default.MainWindowPosition.X, Properties.Core.Default.MainWindowPosition.Y);
if (PointWithinDesktopWorkingArea(savedPosition))
Position = new PixelPoint(Properties.Core.Default.MainWindowPosition.X, Properties.Core.Default.MainWindowPosition.Y);
Closing += (object sender, System.ComponentModel.CancelEventArgs e) =>
{
var size = new System.Drawing.Size((int)System.Math.Round(Width), (int)System.Math.Round(Height));
var position = new System.Drawing.Point(Position.X, Position.Y);
Properties.Core.Default.MainWindowSize = size;
Properties.Core.Default.MainWindowPosition = position;
var position = new System.Drawing.Point(Position.X, Position.Y);
if (PointWithinDesktopWorkingArea(position))
Properties.Core.Default.MainWindowPosition = position;
Properties.Core.Default.Save();
};
}
private bool PointWithinDesktopWorkingArea(System.Drawing.Point position)
{
bool inBounds = false;
foreach (var screen in Screens.All)
{
if (screen.WorkingArea.TopLeft.X <= position.X
&& screen.WorkingArea.TopLeft.Y <= position.Y
&& screen.WorkingArea.BottomRight.X > position.X
&& screen.WorkingArea.BottomRight.Y > position.Y)
{
inBounds = true;
break;
}
}
return inBounds;
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);

View File

@ -239,7 +239,7 @@ namespace Observatory.Explorer
NotificationArgs args = new()
{
Title = bodyLabel + bodyAffix,
TitleSsml = $"<speak version=\"1.0\" xmlns=\"\" xml:lang=\"en-US\">{bodyLabel} {spokenAffix}</speak>",
TitleSsml = $"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">{bodyLabel} {spokenAffix}</voice></speak>",
Detail = notificationDetail.ToString()
};

View File

@ -6,15 +6,26 @@ using System.Threading.Tasks;
namespace Observatory.Framework
{
/// <summary>
/// Specifies text to display as the name of the setting in the UI instead of the property name.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class SettingDisplayName : Attribute
{
private string name;
/// <summary>
/// Specifies text to display as the name of the setting in the UI instead of the property name.
/// </summary>
/// <param name="name">Name to display</param>
public SettingDisplayName(string name)
{
this.name = name;
}
/// <summary>
/// Accessor to get/set displayed name.
/// </summary>
public string DisplayName
{
get => name;
@ -22,18 +33,63 @@ namespace Observatory.Framework
}
}
/// <summary>
/// Indicates that the property should not be displayed to the user in the UI.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class SettingIgnore : Attribute
{ }
/// <summary>
/// Indicates numeric properly should use a slider control instead of a numeric textbox with roller.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class SettingNumericUseSlider : Attribute
{ }
/// <summary>
/// Specify backing value used by Dictionary&lt;string, object&gt; to indicate selected option.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class SettingBackingValue : Attribute
{
private string property;
/// <summary>
/// Specify backing value used by Dictionary&lt;string, object&gt; to indicate selected option.
/// </summary>
/// <param name="property">Property name for backing value.</param>
public SettingBackingValue(string property)
{
this.property = property;
}
/// <summary>
/// Accessor to get/set backing value property name.
/// </summary>
public string BackingProperty
{
get => property;
set => property = value;
}
}
/// <summary>
/// Specify bounds for numeric inputs.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class SettingNumericBounds : Attribute
{
private double minimum;
private double maximum;
private double increment;
/// <summary>
/// Specify bounds for numeric inputs.
/// </summary>
/// <param name="minimum">Minimum allowed value.</param>
/// <param name="maximum">Maximum allowed value.</param>
/// <param name="increment">Increment between allowed values in slider/roller inputs.</param>
public SettingNumericBounds(double minimum, double maximum, double increment = 1.0)
{
this.minimum = minimum;
@ -41,16 +97,27 @@ namespace Observatory.Framework
this.increment = increment;
}
/// <summary>
/// Minimum allowed value.
/// </summary>
public double Minimum
{
get => minimum;
set => minimum = value;
}
/// <summary>
/// Maxunyn allowed value.
/// </summary>
public double Maximum
{
get => maximum;
set => maximum = value;
}
/// <summary>
/// Increment between allowed values in slider/roller inputs.
/// </summary>
public double Increment
{
get => increment;

View File

@ -0,0 +1,34 @@
using System;
namespace Observatory.Framework
{
/// <summary>
/// Container for exceptions within plugins which cannot be gracefully handled in context,
/// but benefit from having a context-specific user message.
/// </summary>
public class PluginException : Exception
{
/// <summary>
/// Initialze new PluginException with details of the originating plugin and a specific user-facing message for display.
/// </summary>
/// <param name="pluginName"></param>
/// <param name="userMessage"></param>
/// <param name="innerException"></param>
public PluginException(string pluginName, string userMessage, Exception innerException) : base(innerException.Message, innerException)
{
PluginName = pluginName;
UserMessage = userMessage;
}
/// <summary>
/// Name of plugin from which the exception was thrown.
/// </summary>
public string PluginName { get; }
/// <summary>
/// Message to be displayed to user.
/// </summary>
public string UserMessage { get; }
}
}

View File

@ -301,7 +301,8 @@ namespace Observatory.Framework.Files.ParameterTypes
ActiveFighter,
JumpImminent,
RestrictedAccess,
NoReason
NoReason,
DockOffline
}
public enum ScanOrganicType

View File

@ -3,13 +3,35 @@ using System.Collections.Immutable;
namespace Observatory.Framework.Files
{
/// <summary>
/// Elite Dangerous shipyard.json file.
/// </summary>
public class ShipyardFile : Journal.JournalBase
{
/// <summary>
/// Unique ID of market.
/// </summary>
public long MarketID { get; init; }
/// <summary>
/// Name of station where shipyard is located.
/// </summary>
public string StationName { get; init; }
/// <summary>
/// Starsystem where shipyard is located.
/// </summary>
public string StarSystem { get; init; }
/// <summary>
/// Whether player has access to Horizons content.
/// </summary>
public bool Horizons { get; init; }
/// <summary>
/// <para>Whether player has access to the Cobra MkIV.</para>
/// <para>Will never be set to true for CMDR Nuse.</para>
/// </summary>
public bool AllowCobraMkIV { get; init; }
/// <summary>
/// List of all ships and prices for them at the current shipyard.
/// </summary>
public ImmutableList<ShipyardPrice> PriceList { get; init; }
}
}

View File

@ -4,30 +4,98 @@ using Observatory.Framework.Files.ParameterTypes;
namespace Observatory.Framework.Files
{
/// <summary>
/// Elite Dangerous status.json file.
/// </summary>
public class Status : Journal.JournalBase
{
/// <summary>
/// Set of flags representing current player state.
/// </summary>
public StatusFlags Flags { get; init; }
/// <summary>
/// Additional set of flags representing current player state.
/// Added in later versions of Elite Dangerous.
/// </summary>
public StatusFlags2 Flags2 { get; init; }
/// <summary>
/// Current allocation of power distribution (pips) between SYS, ENG, and WEP, in "half pip" increments.
/// </summary>
[JsonConverter(typeof(PipConverter))]
public (int Sys, int Eng, int Wep) Pips { get; init; }
/// <summary>
/// Currently selected fire group.
/// </summary>
public int Firegroup { get; init; }
/// <summary>
/// UI component currently focused by the player.
/// </summary>
public FocusStatus GuiFocus { get; init; }
/// <summary>
/// Fuel remaining in the current ship.
/// </summary>
public FuelType Fuel { get; init; }
/// <summary>
/// Amount of cargo currently carried.
/// </summary>
public float Cargo { get; init; }
/// <summary>
/// Legal status in the current jurisdiction.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public LegalStatus LegalState { get; init; }
/// <summary>
/// <para>Current altitude.</para>
/// <para>Check if RadialAltitude is set in StatusFlags to determine if altitude is based on planetary radius (set) or raycast to ground (unset).</para>
/// </summary>
public int Altitude { get; init; }
/// <summary>
/// Latitude of current surface location.
/// </summary>
public double Latitude { get; init; }
/// <summary>
/// Longitude of current surface location.
/// </summary>
public double Longitude { get; init; }
/// <summary>
/// Current heading for surface direction.
/// </summary>
public int Heading { get; init; }
/// <summary>
/// Body name of current location.
/// </summary>
public string BodyName { get; init; }
/// <summary>
/// Radius of current planet.
/// </summary>
public double PlanetRadius { get; init; }
/// <summary>
/// Oxygen remaining on foot, range from 0.0 - 1.0.
/// </summary>
public float Oxygen { get; init; }
/// <summary>
/// Health remaining on foot, range from 0.0 - 1.0.
/// </summary>
public float Health { get; init; }
/// <summary>
/// Current environmental temperature in K while on foot.
/// </summary>
public float Temperature { get; init; }
/// <summary>
/// Name of currently selected personal weapon.
/// </summary>
public string SelectedWeapon { get; init; }
/// <summary>
/// Current strength of gravity while on foot, in g.
/// </summary>
public float Gravity { get; init; }
/// <summary>
/// Current credit balance of player.
/// </summary>
public long Balance { get; init; }
/// <summary>
/// Currently set destination.
/// </summary>
public Destination Destination { get; init; }
}
}

View File

@ -41,7 +41,7 @@ namespace Observatory.Framework.Interfaces
public PluginUI PluginUI { get; }
/// <summary>
/// <para>Accessors for plugin settings object. Should be initialized in a default state.</para>
/// <para>Accessors for plugin settings object. Should be initialized with a default state during the plugin constructor.</para>
/// <para>Saving and loading of settings is handled by Observatory Core, and any previously saved settings will be set after plugin instantiation, but before Load() is called.</para>
/// <para>A plugin's settings class is expected to consist of properties with public getters and setters. The settings UI will be automatically generated based on each property type.<br/>
/// The [SettingDisplayName(string name)] attribute can be used to specify a display name, otherwise the name of the property will be used.<br/>

View File

@ -4,6 +4,76 @@
<name>ObservatoryFramework</name>
</assembly>
<members>
<member name="T:Observatory.Framework.SettingDisplayName">
<summary>
Specifies text to display as the name of the setting in the UI instead of the property name.
</summary>
</member>
<member name="M:Observatory.Framework.SettingDisplayName.#ctor(System.String)">
<summary>
Specifies text to display as the name of the setting in the UI instead of the property name.
</summary>
<param name="name">Name to display</param>
</member>
<member name="P:Observatory.Framework.SettingDisplayName.DisplayName">
<summary>
Accessor to get/set displayed name.
</summary>
</member>
<member name="T:Observatory.Framework.SettingIgnore">
<summary>
Indicates that the property should not be displayed to the user in the UI.
</summary>
</member>
<member name="T:Observatory.Framework.SettingNumericUseSlider">
<summary>
Indicates numeric properly should use a slider control instead of a numeric textbox with roller.
</summary>
</member>
<member name="T:Observatory.Framework.SettingBackingValue">
<summary>
Specify backing value used by Dictionary&lt;string, object&gt; to indicate selected option.
</summary>
</member>
<member name="M:Observatory.Framework.SettingBackingValue.#ctor(System.String)">
<summary>
Specify backing value used by Dictionary&lt;string, object&gt; to indicate selected option.
</summary>
<param name="property">Property name for backing value.</param>
</member>
<member name="P:Observatory.Framework.SettingBackingValue.BackingProperty">
<summary>
Accessor to get/set backing value property name.
</summary>
</member>
<member name="T:Observatory.Framework.SettingNumericBounds">
<summary>
Specify bounds for numeric inputs.
</summary>
</member>
<member name="M:Observatory.Framework.SettingNumericBounds.#ctor(System.Double,System.Double,System.Double)">
<summary>
Specify bounds for numeric inputs.
</summary>
<param name="minimum">Minimum allowed value.</param>
<param name="maximum">Maximum allowed value.</param>
<param name="increment">Increment between allowed values in slider/roller inputs.</param>
</member>
<member name="P:Observatory.Framework.SettingNumericBounds.Minimum">
<summary>
Minimum allowed value.
</summary>
</member>
<member name="P:Observatory.Framework.SettingNumericBounds.Maximum">
<summary>
Maxunyn allowed value.
</summary>
</member>
<member name="P:Observatory.Framework.SettingNumericBounds.Increment">
<summary>
Increment between allowed values in slider/roller inputs.
</summary>
</member>
<member name="T:Observatory.Framework.JournalEventArgs">
<summary>
Provides data for Elite Dangerous journal events.
@ -63,6 +133,30 @@
Specify window Y position as a percentage from upper left corner (overrides Core setting). Default -1.0 (use Core setting).
</summary>
</member>
<member name="T:Observatory.Framework.PluginException">
<summary>
Container for exceptions within plugins which cannot be gracefully handled in context,
but benefit from having a context-specific user message.
</summary>
</member>
<member name="M:Observatory.Framework.PluginException.#ctor(System.String,System.String,System.Exception)">
<summary>
Initialze new PluginException with details of the originating plugin and a specific user-facing message for display.
</summary>
<param name="pluginName"></param>
<param name="userMessage"></param>
<param name="innerException"></param>
</member>
<member name="P:Observatory.Framework.PluginException.PluginName">
<summary>
Name of plugin from which the exception was thrown.
</summary>
</member>
<member name="P:Observatory.Framework.PluginException.UserMessage">
<summary>
Message to be displayed to user.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Journal.SAAScanComplete.Discoverers">
<summary>
This property is indicated with strikethrough in Frontier's documentation and may be deprecated.
@ -320,6 +414,154 @@
Altitude above average radius (sea level) when set. Altitude raycast to ground when unset.
</summary>
</member>
<member name="T:Observatory.Framework.Files.ShipyardFile">
<summary>
Elite Dangerous shipyard.json file.
</summary>
</member>
<member name="P:Observatory.Framework.Files.ShipyardFile.MarketID">
<summary>
Unique ID of market.
</summary>
</member>
<member name="P:Observatory.Framework.Files.ShipyardFile.StationName">
<summary>
Name of station where shipyard is located.
</summary>
</member>
<member name="P:Observatory.Framework.Files.ShipyardFile.StarSystem">
<summary>
Starsystem where shipyard is located.
</summary>
</member>
<member name="P:Observatory.Framework.Files.ShipyardFile.Horizons">
<summary>
Whether player has access to Horizons content.
</summary>
</member>
<member name="P:Observatory.Framework.Files.ShipyardFile.AllowCobraMkIV">
<summary>
<para>Whether player has access to the Cobra MkIV.</para>
<para>Will never be set to true for CMDR Nuse.</para>
</summary>
</member>
<member name="P:Observatory.Framework.Files.ShipyardFile.PriceList">
<summary>
List of all ships and prices for them at the current shipyard.
</summary>
</member>
<member name="T:Observatory.Framework.Files.Status">
<summary>
Elite Dangerous status.json file.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Flags">
<summary>
Set of flags representing current player state.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Flags2">
<summary>
Additional set of flags representing current player state.
Added in later versions of Elite Dangerous.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Pips">
<summary>
Current allocation of power distribution (pips) between SYS, ENG, and WEP, in "half pip" increments.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Firegroup">
<summary>
Currently selected fire group.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.GuiFocus">
<summary>
UI component currently focused by the player.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Fuel">
<summary>
Fuel remaining in the current ship.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Cargo">
<summary>
Amount of cargo currently carried.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.LegalState">
<summary>
Legal status in the current jurisdiction.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Altitude">
<summary>
<para>Current altitude.</para>
<para>Check if RadialAltitude is set in StatusFlags to determine if altitude is based on planetary radius (set) or raycast to ground (unset).</para>
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Latitude">
<summary>
Latitude of current surface location.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Longitude">
<summary>
Longitude of current surface location.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Heading">
<summary>
Current heading for surface direction.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.BodyName">
<summary>
Body name of current location.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.PlanetRadius">
<summary>
Radius of current planet.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Oxygen">
<summary>
Oxygen remaining on foot, range from 0.0 - 1.0.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Health">
<summary>
Health remaining on foot, range from 0.0 - 1.0.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Temperature">
<summary>
Current environmental temperature in K while on foot.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.SelectedWeapon">
<summary>
Name of currently selected personal weapon.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Gravity">
<summary>
Current strength of gravity while on foot, in g.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Balance">
<summary>
Current credit balance of player.
</summary>
</member>
<member name="P:Observatory.Framework.Files.Status.Destination">
<summary>
Currently set destination.
</summary>
</member>
<member name="T:Observatory.Framework.Interfaces.IObservatoryPlugin">
<summary>
<para>Base plugin interface containing methods common to both notifiers and workers.</para>
@ -357,7 +599,7 @@
</member>
<member name="P:Observatory.Framework.Interfaces.IObservatoryPlugin.Settings">
<summary>
<para>Accessors for plugin settings object. Should be initialized in a default state.</para>
<para>Accessors for plugin settings object. Should be initialized with a default state during the plugin constructor.</para>
<para>Saving and loading of settings is handled by Observatory Core, and any previously saved settings will be set after plugin instantiation, but before Load() is called.</para>
<para>A plugin's settings class is expected to consist of properties with public getters and setters. The settings UI will be automatically generated based on each property type.<br/>
The [SettingDisplayName(string name)] attribute can be used to specify a display name, otherwise the name of the property will be used.<br/>

View File

@ -0,0 +1,242 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Net.Http;
using System.Xml;
using Microsoft.CognitiveServices.Speech;
using System.Collections.ObjectModel;
using Observatory.Framework;
namespace Observatory.Herald
{
internal class VoiceSpeechManager
{
private string azureKey;
private DirectoryInfo cacheLocation;
private SpeechConfig speechConfig;
private SpeechSynthesizer speech;
internal VoiceSpeechManager(HeraldSettings settings, HttpClient httpClient)
{
cacheLocation = new(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
+ $"{Path.DirectorySeparatorChar}ObservatoryCore{Path.DirectorySeparatorChar}ObservatoryHerald{Path.DirectorySeparatorChar}");
if (!Directory.Exists(cacheLocation.FullName))
{
Directory.CreateDirectory(cacheLocation.FullName);
}
try
{
azureKey = GetAzureKey(settings, httpClient);
}
catch (Exception ex)
{
throw new PluginException("Herald", "Unable to retrieve Azure API key.", ex);
}
try
{
speechConfig = SpeechConfig.FromSubscription(azureKey, "eastus");
}
catch (Exception ex)
{
throw new PluginException("Herald", "Error retrieving Azure account details.", ex);
}
speech = new(speechConfig, null);
settings.Voices = PopulateVoiceSettingOptions();
}
private Dictionary<string, object> PopulateVoiceSettingOptions()
{
ReadOnlyCollection<VoiceInfo> voices;
try
{
voices = speech.GetVoicesAsync().Result.Voices;
}
catch (Exception ex)
{
throw new PluginException("Herald", "Unable to retrieve voice list from Azure.", ex);
}
var voiceOptions = new Dictionary<string, object>();
var englishSpeakingVoices = from v in voices
where v.Locale.StartsWith("en-")
select v;
foreach (var voice in englishSpeakingVoices)
{
string demonym = GetDemonymFromLocale(voice.Locale);
if (voice.StyleList.Length > 1)
{
foreach (var style in voice.StyleList)
{
voiceOptions.Add(
$"{demonym} - {voice.LocalName} - {style}",
voice);
}
}
else
voiceOptions.Add(
$"{demonym} - {voice.LocalName}",
voice);
}
return voiceOptions;
}
private static string GetDemonymFromLocale(string locale)
{
string demonym;
switch (locale)
{
case "en-AU":
demonym = "Australian";
break;
case "en-CA":
demonym = "Canadian";
break;
case "en-GB":
demonym = "British";
break;
case "en-HK":
demonym = "Hong Konger";
break;
case "en-IE":
demonym = "Irish";
break;
case "en-IN":
demonym = "Indian";
break;
case "en-KE":
demonym = "Kenyan";
break;
case "en-NG":
demonym = "Nigerian";
break;
case "en-NZ":
demonym = "Kiwi";
break;
case "en-PH":
demonym = "Filipino";
break;
case "en-SG":
demonym = "Singaporean";
break;
case "en-TZ":
demonym = "Tanzanian";
break;
case "en-US":
demonym = "American";
break;
case "en-ZA":
demonym = "South African";
break;
default:
demonym = locale;
break;
}
return demonym;
}
internal string GetAudioFileFromSsml(string ssml, string voice, string style)
{
ssml = AddVoiceToSsml(ssml, voice, style);
string ssmlHash = FNV64(ssml).ToString("X");
string audioFile = cacheLocation + ssmlHash + ".wav";
if (!File.Exists(audioFile))
{
using var stream = RequestFromAzure(ssml);
stream.SaveToWaveFileAsync(audioFile).Wait();
}
return audioFile;
}
private static ulong FNV64(string data)
{
string lower_data = data.ToLower();
ulong hash = 0xcbf29ce484222325uL;
for (int i = 0; i < lower_data.Length; i++)
{
byte b = (byte)lower_data[i];
hash *= 1099511628211uL;
hash ^= b;
}
return hash;
}
private AudioDataStream RequestFromAzure(string ssml)
{
try
{
var result = speech.SpeakSsmlAsync(ssml).Result;
return AudioDataStream.FromResult(result);
}
catch (Exception ex)
{
throw new PluginException("Herald", "Unable to retrieve audio from Azure.", ex);
}
}
private static string AddVoiceToSsml(string ssml, string voiceName, string styleName)
{
XmlDocument ssmlDoc = new();
ssmlDoc.LoadXml(ssml);
var ssmlNamespace = ssmlDoc.DocumentElement.NamespaceURI;
XmlNamespaceManager ssmlNs = new(ssmlDoc.NameTable);
ssmlNs.AddNamespace("ssml", ssmlNamespace);
var voiceNode = ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs);
voiceNode.Attributes.GetNamedItem("name").Value = voiceName;
string ssmlResult;
if (!string.IsNullOrWhiteSpace(styleName))
{
voiceNode.InnerText = $"<mstts:express-as style=\"{styleName}\">" + voiceNode.InnerText + "</mstts:express-as>";
// This is a kludge but I don't feel like dealing with System.Xml and namespaces
ssmlResult = ssmlDoc.OuterXml
.Replace(" xmlns=", " xmlns:mstts=\"https://www.w3.org/2001/mstts\" xmlns=")
.Replace($"&lt;mstts:express-as style=\"{styleName}\"&gt;", $"<mstts:express-as style=\"{styleName}\">")
.Replace("&lt;/mstts:express-as&gt;", "</mstts:express-as>");
}
else
{
ssmlResult = ssmlDoc.OuterXml;
}
return ssmlResult;
}
private static string GetAzureKey(HeraldSettings settings, HttpClient httpClient)
{
string azureKey;
if (string.IsNullOrWhiteSpace(settings.AzureAPIKeyOverride))
{
azureKey = httpClient.GetStringAsync("https://xjph.net/Observatory/ObservatoryHeraldAzureKey").Result;
}
else
{
azureKey = settings.AzureAPIKeyOverride;
}
return azureKey;
}
}
}

View File

@ -0,0 +1,77 @@
using Microsoft.CognitiveServices.Speech;
using Observatory.Framework;
using Observatory.Framework.Interfaces;
using System;
namespace Observatory.Herald
{
public class HeraldNotifier : IObservatoryNotifier
{
public HeraldNotifier()
{
heraldSettings = new()
{
SelectedVoice = "American - Christopher",
AzureAPIKeyOverride = string.Empty,
Enabled = true
};
}
public string Name => "Observatory Herald";
public string ShortName => "Herald";
public string Version => typeof(HeraldNotifier).Assembly.GetName().Version.ToString();
public PluginUI PluginUI => new (PluginUI.UIType.None, null);
public object Settings { get => heraldSettings; set => heraldSettings = (HeraldSettings)value; }
public void Load(IObservatoryCore observatoryCore)
{
var azureManager = new VoiceSpeechManager(heraldSettings, observatoryCore.HttpClient);
heraldSpeech = new HeraldQueue(azureManager);
heraldSettings.Test = TestVoice;
}
private void TestVoice()
{
heraldSpeech.Enqueue(
new NotificationArgs()
{
Title = "Herald voice testing",
Detail = $"This is {heraldSettings.SelectedVoice.Split(" - ")[1]}."
},
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
}
public void OnNotificationEvent(NotificationArgs notificationEventArgs)
{
if (heraldSettings.Enabled)
heraldSpeech.Enqueue(
notificationEventArgs,
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
}
private string GetAzureNameFromSetting(string settingName)
{
var voiceInfo = (VoiceInfo)heraldSettings.Voices[settingName];
return voiceInfo.Name;
}
private string GetAzureStyleNameFromSetting(string settingName)
{
string[] settingParts = settingName.Split(" - ");
if (settingParts.Length == 3)
return settingParts[2];
else
return string.Empty;
}
private HeraldSettings heraldSettings;
private HeraldQueue heraldSpeech;
}
}

View File

@ -0,0 +1,94 @@
using Observatory.Framework;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using NetCoreAudio;
using System.Threading;
namespace Observatory.Herald
{
class HeraldQueue
{
private Queue<NotificationArgs> notifications;
private bool processing;
private string voice;
private string style;
private VoiceSpeechManager azureCacheManager;
private Player audioPlayer;
public HeraldQueue(VoiceSpeechManager azureCacheManager)
{
this.azureCacheManager = azureCacheManager;
processing = false;
notifications = new();
audioPlayer = new();
}
internal void Enqueue(NotificationArgs notification, string selectedVoice, string selectedStyle = "")
{
voice = selectedVoice;
style = selectedStyle;
notifications.Enqueue(notification);
if (!processing)
{
processing = true;
ProcessQueueAsync();
}
}
private async void ProcessQueueAsync()
{
await Task.Factory.StartNew(ProcessQueue);
}
private void ProcessQueue()
{
while (notifications.Any())
{
var notification = notifications.Dequeue();
if (string.IsNullOrWhiteSpace(notification.TitleSsml))
{
Speak(notification.Title);
}
else
{
SpeakSsml(notification.TitleSsml);
}
if (string.IsNullOrWhiteSpace(notification.DetailSsml))
{
Speak(notification.Detail);
}
else
{
SpeakSsml(notification.DetailSsml);
}
}
processing = false;
}
private void Speak(string text)
{
SpeakSsml($"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">{text}</voice></speak>");
}
private void SpeakSsml(string ssml)
{
string file = azureCacheManager.GetAudioFileFromSsml(ssml, voice, style);
// For some reason .Wait() concludes before audio playback is complete.
audioPlayer.Play(file);
while (audioPlayer.Playing)
{
Thread.Sleep(20);
}
}
}
}

View File

@ -0,0 +1,29 @@
using Observatory.Framework;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Observatory.Herald
{
public class HeraldSettings
{
[SettingDisplayName("API Key Override: ")]
public string AzureAPIKeyOverride { get; set; }
[SettingDisplayName("Voice")]
[SettingBackingValue("SelectedVoice")]
[System.Text.Json.Serialization.JsonIgnore]
public Dictionary<string, object> Voices { get; internal set; }
[SettingIgnore]
public string SelectedVoice { get; set; }
[System.Text.Json.Serialization.JsonIgnore]
public Action Test { get; internal set; }
[SettingDisplayName("Enabled")]
public bool Enabled { get; set; }
}
}

View File

@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<PropertyGroup>
<VersionSuffix>0.0.$([System.DateTime]::UtcNow.DayOfYear.ToString()).$([System.DateTime]::UtcNow.ToString(HHmm))</VersionSuffix>
<AssemblyVersion Condition=" '$(VersionSuffix)' == '' ">0.0.0.1</AssemblyVersion>
<AssemblyVersion Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</AssemblyVersion>
<Version Condition=" '$(VersionSuffix)' == '' ">0.0.1.0</Version>
<Version Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.18.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="NetCoreAudio">
<HintPath>..\..\NetCoreAudio\NetCoreAudio\bin\Release\netstandard2.0\NetCoreAudio.dll</HintPath>
</Reference>
<Reference Include="ObservatoryFramework">
<HintPath>..\ObservatoryFramework\bin\Release\net5.0\ObservatoryFramework.dll</HintPath>
</Reference>
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetPath)&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\&quot; /y" />
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetDir)Microsoft.CognitiveServices.Speech.csharp.dll&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\&quot; /y" />
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetDir)NetCoreAudio.dll&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\&quot; /y" />
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetDir)\runtimes\win-x64\native\*.*&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\&quot; /y" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="[ ! -d &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps&quot; ] &amp;&amp; mkdir -p &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps&quot; || echo Directory already exists" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp &quot;$(TargetPath)&quot; &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/&quot; -f" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp &quot;$(TargetDir)runtimes/linux-x64/lib/netstandard2.0/Microsoft.CognitiveServices.Speech.csharp.dll&quot; &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps/&quot; -f" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp &quot;$(TargetDir)NetCoreAudio.dll&quot; &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps/&quot; -f" />
</Target>
</Project>

View File

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31205.134
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObservatoryHerald", "ObservatoryHerald.csproj", "{BC57225F-D89B-4853-A816-9AB4865E7AC5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3466BC38-6B4F-459C-9292-DD2D77F8B8E4}
EndGlobalSection
EndGlobal