Skip to content

Commit

Permalink
✨ Add live charts to the sensors UI.
Browse files Browse the repository at this point in the history
  • Loading branch information
hexawyz committed Apr 14, 2024
1 parent 6e47732 commit 3d8a125
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 0 deletions.
1 change: 1 addition & 0 deletions Exo.Settings.Ui/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<local:StringFormatConverter x:Key="StringFormatConverter" />
<local:BatteryStateToGlyphConverter x:Key="BatteryStateToGlyphConverter" />
<local:ByteToDoubleConverter x:Key="ByteToDoubleConverter" />
<local:HistoryDataToPathGeometryConverter x:Key="HistoryDataToPathGeometryConverter" />

<local:RgbLightingDefaultPalette x:Key="RgbLightingDefaultPalette" />

Expand Down
1 change: 1 addition & 0 deletions Exo.Settings.Ui/ChangedProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ internal static class ChangedProperty
public static readonly PropertyChangedEventArgs Unit = new(nameof(Unit));
public static readonly PropertyChangedEventArgs LiveDetails = new(nameof(LiveDetails));
public static readonly PropertyChangedEventArgs CurrentValue = new(nameof(CurrentValue));
public static readonly PropertyChangedEventArgs History = new(nameof(History));
}
59 changes: 59 additions & 0 deletions Exo.Settings.Ui/HistoryDataToPathGeometryConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Exo.Settings.Ui.ViewModels;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;

namespace Exo.Settings.Ui;

internal sealed class HistoryDataToPathGeometryConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, string language)
=> value is LiveSensorDetailsViewModel.HistoryData history ? Convert(history) : null;

private PathGeometry Convert(LiveSensorDetailsViewModel.HistoryData history)
{
// NB: This is very rough and WIP.
// It should probably be ported to a dedicated chart drawing component afterwards.

double minValue = double.PositiveInfinity;
double maxValue = double.NegativeInfinity;

for (int i = 0; i < history.Length; i++)
{
double value = history[i];
minValue = Math.Min(value, minValue);
maxValue = Math.Max(value, maxValue);
}

double scaleMin = Math.Round(minValue, 0, MidpointRounding.ToNegativeInfinity);
double scaleMax = Math.Round(maxValue, 0, MidpointRounding.ToPositiveInfinity);

// Anchor the scale to zero if necessary.
if (maxValue < 0) maxValue = 0;
if (minValue > 0) minValue = 0;

// Force the chart to not be fully empty if the min and max are both zero. (result of previous adjustments)
if (minValue == maxValue) maxValue = 1;

double scaleAmplitudeX = history.Length - 1;
double scaleAmplitudeY = maxValue - minValue;
double outputAmplitudeX = 100;
double outputAmplitudeY = 100;

var figure = new PathFigure();

double firstValue = history[0];

figure.StartPoint = new(0, outputAmplitudeY - (firstValue - minValue) * outputAmplitudeY / scaleAmplitudeY);
for (int i = 1; i < history.Length; i++)
{
double value = history[i];
double x = i * outputAmplitudeX / scaleAmplitudeX;
double y = outputAmplitudeY - (value - minValue) * outputAmplitudeY / scaleAmplitudeY;
figure.Segments.Add(new LineSegment() { Point = new(x, y) });
}

return new PathGeometry() { Figures = { figure } };
}

public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException();
}
2 changes: 2 additions & 0 deletions Exo.Settings.Ui/SensorsPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding DisplayName}" />
<TextBlock Grid.Row="1" Text="{Binding LiveDetails.CurrentValue}" />
<Path Grid.Row="2" Stroke="SteelBlue" StrokeThickness="1" Data="{Binding LiveDetails.History, Converter={StaticResource HistoryDataToPathGeometryConverter}}" />
</Grid>
</ItemContainer>
</DataTemplate>
Expand Down
81 changes: 81 additions & 0 deletions Exo.Settings.Ui/ViewModels/SensorsViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using Exo.Contracts.Ui.Settings;
using Exo.Ui;
Expand Down Expand Up @@ -265,14 +266,49 @@ public async void SetOffline()

internal sealed class LiveSensorDetailsViewModel : BindableObject, IAsyncDisposable
{
// This is a public wrapper that is used to expose the data and allow it to be rendered into a chart.
public sealed class HistoryData
{
private readonly LiveSensorDetailsViewModel _viewModel;

public HistoryData(LiveSensorDetailsViewModel viewModel) => _viewModel = viewModel;

public DateTime StartTime => _viewModel._currentValueTime;

public int Length => _viewModel._dataPoints.Length;

public double this[int index]
{
get
{
var vm = _viewModel;
if ((uint)index >= (uint)vm._dataPoints.Length) throw new ArgumentOutOfRangeException(nameof(index));
index += vm._currentPointIndex + 1;
int roundTrippedIndex = index - vm._dataPoints.Length;
return vm._dataPoints[roundTrippedIndex < 0 ? index : roundTrippedIndex];
}
}
}

private const int WindowSizeInSeconds = 1 * 60;

private double _currentValue;
private DateTime _currentValueTime;
private ulong _currentTimestampInSeconds;
private int _currentPointIndex;
private readonly double[] _dataPoints;
private readonly SensorViewModel _sensor;
private readonly HistoryData _historyData;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly Task _watchTask;

public LiveSensorDetailsViewModel(SensorViewModel sensor)
{
_currentValueTime = DateTime.UtcNow;
_currentTimestampInSeconds = GetTimestamp();
_dataPoints = new double[WindowSizeInSeconds];
_sensor = sensor;
_historyData = new(this);
_cancellationTokenSource = new();
_watchTask = WatchAsync(_cancellationTokenSource.Token);
}
Expand All @@ -284,12 +320,57 @@ public async ValueTask DisposeAsync()
}

public NumberWithUnit CurrentValue => new(_currentValue, _sensor.Unit);
public HistoryData History => _historyData;

private static ulong GetTimestamp() => (ulong)Stopwatch.GetTimestamp() / (ulong)Stopwatch.Frequency;

private async Task WatchAsync(CancellationToken cancellationToken)
{
await foreach (var dataPoint in _sensor.Device.SensorsViewModel.WatchValuesAsync(_sensor.Device.Id, _sensor.Id, cancellationToken))
{
// Get the timestamp for the current data point.
var now = DateTime.UtcNow;
var currentTimestamp = GetTimestamp();
ulong delta = currentTimestamp - _currentTimestampInSeconds;
// Backfill any missing data points using the previous value.
if (delta > 1)
{
// If we are late for longer than the window size, we can just clear up the whole window and restart at index 0.
// NB: In the very unlikely occasion where the timer would wrap-around, it would be handled by this condition. We'd end up resetting the history, which is not that terrible.
if (delta > (ulong)_dataPoints.Length)
{
Array.Fill(_dataPoints, _currentValue, 0, _dataPoints.Length);
_currentPointIndex = 0;
}
else
{
// If we are late for less than the window size, the operation might need to be split in two.
int endIndex = _currentPointIndex + (int)delta;
if (++_currentPointIndex < _dataPoints.Length)
{
Array.Fill(_dataPoints, _currentValue, _currentPointIndex, Math.Min(_dataPoints.Length, endIndex) - _currentPointIndex);
}
if (endIndex >= _dataPoints.Length)
{
_currentPointIndex = endIndex - _dataPoints.Length;
Array.Fill(_dataPoints, _currentValue, 0, _currentPointIndex - 1);
}
else
{
_currentPointIndex = endIndex;
}
}
}
else if (delta == 1)
{
// Increase the index
if (++_currentPointIndex == _dataPoints.Length) _currentPointIndex = 0;
}
_currentValueTime = now;
_currentTimestampInSeconds = currentTimestamp;
_dataPoints[_currentPointIndex] = dataPoint.Value;
SetValue(ref _currentValue, dataPoint.Value, ChangedProperty.CurrentValue);
NotifyPropertyChanged(ChangedProperty.History);
}
}
}
Expand Down

0 comments on commit 3d8a125

Please sign in to comment.