diff --git a/src/Aether/Devices/Drivers/DisplayDriver.cs b/src/Aether/Devices/Drivers/DisplayDriver.cs index 6643f9a..deda05d 100644 --- a/src/Aether/Devices/Drivers/DisplayDriver.cs +++ b/src/Aether/Devices/Drivers/DisplayDriver.cs @@ -9,7 +9,19 @@ internal abstract class DisplayDriver : IDisposable public abstract float DpiX { get; } public abstract float DpiY { get; } - public abstract Image CreateImage(DrawOrientation orientation = DrawOrientation.Default); + public Image CreateImage(DrawOrientation orientation = DrawOrientation.Default) + { + (int width, int height) = orientation switch + { + DrawOrientation.Default => (Width, Height), + DrawOrientation.Rotate90 => (Height, Width), + _ => throw new ArgumentOutOfRangeException(nameof(orientation), $"{nameof(orientation)} is not a valid {nameof(DrawOrientation)} value.") + }; + + return CreateImageCore(width, height); + } + + protected abstract Image CreateImageCore(int width, int height); public abstract void DisplayImage(Image image, DrawOrientation orientation = DrawOrientation.Default); diff --git a/src/Aether/Devices/Drivers/WaveshareEPD2_9inV2.cs b/src/Aether/Devices/Drivers/WaveshareEPD2_9inV2.cs new file mode 100644 index 0000000..6670d41 --- /dev/null +++ b/src/Aether/Devices/Drivers/WaveshareEPD2_9inV2.cs @@ -0,0 +1,356 @@ +using SixLabors.ImageSharp; +using System.Buffers.Binary; +using System.Device.Gpio; +using System.Device.Spi; +using System.Diagnostics; +using System.Runtime.InteropServices; +using L8 = SixLabors.ImageSharp.PixelFormats.L8; + +namespace Aether.Devices.Drivers +{ + internal sealed class WaveshareEPD2_9inV2 : DisplayDriver + { + public const int DefaultDcPin = 25; + public const int DefaultRstPin = 17; + public const int DefaultBusyPin = 24; + + private static ReadOnlySpan s_LUT => new byte[] + { + 0x80, 0x66, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x40, 0x0, 0x0, 0x0, + 0x10, 0x66, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x20, 0x0, 0x0, 0x0, + 0x80, 0x66, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x40, 0x0, 0x0, 0x0, + 0x10, 0x66, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x20, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x14, 0x8, 0x0, 0x0, 0x0, 0x0, 0x1, + 0xA, 0xA, 0x0, 0xA, 0xA, 0x0, 0x1, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x14, 0x8, 0x0, 0x1, 0x0, 0x0, 0x1, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x0, 0x0, 0x0, + 0x22, 0x17, 0x41, 0x0, 0x32, 0x36 + }; + + private readonly SpiDevice _device; + private readonly GpioController _gpio; + private readonly int _dcPinId; + private readonly int _rstPinId; + private readonly int _busyPinId; + private readonly byte[] _imageBuffer; + private bool _disposed; + + public override int Width => 128; + public override int Height => 296; + private int BitsPerImage => Width * Height; + private int BytesPerImage => BitsPerImage / 8; + + public override float DpiX => 111.917383820998f; + + public override float DpiY => 112.399461802960f; + + public WaveshareEPD2_9inV2(SpiDevice device, GpioController gpio, int dcPinId = DefaultDcPin, int rstPinId = DefaultRstPin, int busyPinId = DefaultBusyPin) + { + _device = device; + _gpio = gpio; + _dcPinId = dcPinId; + _rstPinId = rstPinId; + _busyPinId = busyPinId; + _imageBuffer = new byte[BytesPerImage]; + + gpio.OpenPin(dcPinId, PinMode.Output); + gpio.OpenPin(rstPinId, PinMode.Output); + gpio.OpenPin(busyPinId, PinMode.InputPullUp); + + Reset(); + SoftReset(); + + SetDriverOutputControl(); + SetDataEntryMode(); + SetWindow(0, 0, (uint)Width - 1, (uint)Height - 1); + SetDisplayUpdateControl(); + SetCursor(0, 0); + SetLUTByHost(s_LUT); + } + + public override void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _device.Dispose(); + try + { + _gpio.Write(_dcPinId, PinValue.Low); + _gpio.Write(_rstPinId, PinValue.Low); + _gpio.Write(_busyPinId, PinValue.Low); + _gpio.ClosePin(_dcPinId); + _gpio.ClosePin(_rstPinId); + _gpio.ClosePin(_busyPinId); + } + finally + { + _gpio.Dispose(); + } + } + + protected override Image CreateImageCore(int width, int height) => + new Image(width, height); + + public override void DisplayImage(Image image, DrawOrientation orientation = DrawOrientation.Default) + { + if (image is not Image img) + { + throw new ArgumentException($"{nameof(image)} is of an invalid type; {nameof(DisplayImage)} must be called with images created from {nameof(CreateImage)}.", nameof(image)); + } + + switch (orientation) + { + case DrawOrientation.Default: + if (img.Width != Width || img.Height != Height) + { + throw new ArgumentException($"{nameof(image)} is of an invalid size for this orientation; {nameof(DisplayImage)} must be called with images created from {nameof(CreateImage)}.", nameof(image)); + } + ConvertTo1bpp(_imageBuffer, img); + break; + case DrawOrientation.Rotate90: + if (img.Width != Height || img.Height != Width) + { + throw new ArgumentException($"{nameof(image)} is of an invalid size for this orientation; {nameof(DisplayImage)} must be called with images created from {nameof(CreateImage)}.", nameof(image)); + } + ConvertTo1bppRotated(_imageBuffer, img); + break; + default: + throw new ArgumentOutOfRangeException(nameof(orientation), $"{nameof(orientation)} is not a valid {nameof(DrawOrientation)} value."); + } + + Debug.Assert(_imageBuffer.Length == 4736); + + SetImage(_imageBuffer); + SetPaintMode(DisplayUpdateMode.Full); // TODO: does this need to be set every draw? + SwapFrameBuffers(); + } + + private static void ConvertTo1bpp(Span dest, Image src) + { + int width = src.Width; + int height = src.Height; + int destIdx = 0; + + for (int y = 0; y < height; ++y) + { + Span row = src.GetPixelRowSpan(y); + + for (int x = 0; x < width; x += 8) + { + dest[destIdx++] = + (byte)( + (0b10000000 & ((sbyte)row[x].PackedValue >> 7)) | + (0b01000000 & ((sbyte)row[x + 1].PackedValue >> 7)) | + (0b00100000 & ((sbyte)row[x + 2].PackedValue >> 7)) | + (0b00010000 & ((sbyte)row[x + 3].PackedValue >> 7)) | + (0b00001000 & ((sbyte)row[x + 4].PackedValue >> 7)) | + (0b00000100 & ((sbyte)row[x + 5].PackedValue >> 7)) | + (0b00000010 & ((sbyte)row[x + 6].PackedValue >> 7)) | + (0b00000001 & ((sbyte)row[x + 7].PackedValue >> 7)) + ); + } + } + } + + private static void ConvertTo1bppRotated(Span dest, Image src) + { + int width = src.Width; + int height = src.Height; + int destIdx = 0; + + for (int y = 0; y < height; y += 8) + { + Span row0 = src.GetPixelRowSpan(y); + Span row1 = src.GetPixelRowSpan(y + 1); + Span row2 = src.GetPixelRowSpan(y + 2); + Span row3 = src.GetPixelRowSpan(y + 3); + Span row4 = src.GetPixelRowSpan(y + 4); + Span row5 = src.GetPixelRowSpan(y + 5); + Span row6 = src.GetPixelRowSpan(y + 6); + Span row7 = src.GetPixelRowSpan(y + 7); + + for (int x = 0; x < width; ++x) + { + dest[destIdx++] = + (byte)( + (0b10000000 & ((sbyte)row0[x].PackedValue >> 7)) | + (0b01000000 & ((sbyte)row1[x].PackedValue >> 7)) | + (0b00100000 & ((sbyte)row2[x].PackedValue >> 7)) | + (0b00010000 & ((sbyte)row3[x].PackedValue >> 7)) | + (0b00001000 & ((sbyte)row4[x].PackedValue >> 7)) | + (0b00000100 & ((sbyte)row5[x].PackedValue >> 7)) | + (0b00000010 & ((sbyte)row6[x].PackedValue >> 7)) | + (0b00000001 & ((sbyte)row7[x].PackedValue >> 7)) + ); + } + } + } + + private void SetPaintMode(DisplayUpdateMode mode) => + SendCommand(0x22, (byte)mode); + + private void SwapFrameBuffers() + { + SendCommand(0x20); + ReadBusy(); + } + + private void Reset() + { + _gpio.Write(_rstPinId, PinValue.High); + Thread.Sleep(10); + _gpio.Write(_rstPinId, PinValue.Low); + Thread.Sleep(2); + _gpio.Write(_rstPinId, PinValue.High); + Thread.Sleep(10); + ReadBusy(); + } + + private void SoftReset() + { + SendCommand(0x12); + ReadBusy(); + } + + private void SetDriverOutputControl() => + SendCommand(0x01, new byte[] { 0x27, 0x01, 0x00 }); + + private void SetDataEntryMode() => + SendCommand(0x11, 0x03); + + /// + /// Sets the window into the device's frame buffer to operate to. + /// + /// The starting X coordinate. Must be a multiple of 8. + /// The starting Y coordinate. + /// The ending X coordinate, inclusive. Must be a multiple of 8. + /// The ending Y coordinate, inclusive. + private void SetWindow(uint xStart, uint yStart, uint xEnd, uint yEnd) + { + Debug.Assert((xStart & 0b111) == 0, $"{nameof(xStart)} must be in multiples of 8."); + Debug.Assert(xStart < Width); + Debug.Assert(xEnd < Width); + Debug.Assert(yStart < Height); + Debug.Assert(yEnd < Height); + + Span buffer = stackalloc byte[4]; + + buffer[0] = (byte)(xStart >> 3); + buffer[1] = (byte)(xEnd >> 3); + SendCommand(0x44, buffer[..2]); + + BinaryPrimitives.WriteUInt16LittleEndian(buffer, (ushort)yStart); + BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..], (ushort)yEnd); + SendCommand(0x45, buffer); + } + + /// + /// Sets the cursor to write data to. + /// + /// The X coordinate. Must be a multiple of 8. + /// The Y coordinate. + private void SetCursor(uint x, uint y) + { + Debug.Assert((x & 0b111) == 0, $"{nameof(x)} must be in multiples of 8."); + Debug.Assert(x < Width); + Debug.Assert(y < Height); + + SendCommand(0x4E, (byte)(x >> 3)); + + Span buffer = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(buffer, (ushort)y); + SendCommand(0x4F, buffer); + + ReadBusy(); + } + + /// + /// Fills the current window with image data starting from the current cursor. + /// + private void SetImage(ReadOnlySpan buffer) + { + Debug.Assert(buffer.Length == BytesPerImage); + SendCommand(0x24, buffer); + } + + private void SetDisplayUpdateControl() => + SendCommand(0x21, new byte[] { 0x00, 0x80 }); + + private void SetLUTByHost(ReadOnlySpan lut) + { + SendCommand(0x32, lut[..153]); // LUT + ReadBusy(); + + SendCommand(0x3F, lut[153..154]); // Unknown. + SendCommand(0x03, lut[154..155]); // Gate voltage. + SendCommand(0x04, lut[155..158]); // Source voltage. (VSH, VSH2, VSL) + SendCommand(0x2C, lut[158..159]); // VCOM + } + + /// + /// Sends a command with no data. + /// + private void SendCommand(byte command) + { + _gpio.Write(_dcPinId, PinValue.Low); + _device.WriteByte(command); + } + + /// + /// Sends a command with data. + /// + private void SendCommand(byte command, byte data) => + SendCommand(command, MemoryMarshal.CreateReadOnlySpan(ref data, 1)); + + /// + /// Sends a command with data. + /// + private void SendCommand(byte command, ReadOnlySpan data) + { + Debug.Assert(data.Length != 0, "Call dataless overload instead."); + + SendCommand(command); + + _gpio.Write(_dcPinId, PinValue.High); + foreach(byte b in data) + { + _device.WriteByte(b); + } + } + + /// + /// Waits for the busy pin to go low. + /// + private void ReadBusy() + { + bool busy; + do + { + busy = _gpio.Read(_busyPinId) == PinValue.High; + Thread.Sleep(50); + } + while (busy); + } + + private enum DisplayUpdateMode : byte + { + Full = 0xC7, + Partial = 0x0F + } + } +} diff --git a/src/Aether/Devices/Simulated/SimulatedDisplayDriver.cs b/src/Aether/Devices/Simulated/SimulatedDisplayDriver.cs index f60070c..611774f 100644 --- a/src/Aether/Devices/Simulated/SimulatedDisplayDriver.cs +++ b/src/Aether/Devices/Simulated/SimulatedDisplayDriver.cs @@ -26,21 +26,13 @@ public SimulatedDisplayDriver(string imageDirectoryPath, int width, int height, DpiY = dpiY; } - public override Image CreateImage(DrawOrientation orientation = DrawOrientation.Default) - { - (int width, int height) = orientation switch - { - DrawOrientation.Default => (Width, Height), - DrawOrientation.Rotate90 => (Height, Width), - _ => throw new ArgumentOutOfRangeException(nameof(orientation), $"{nameof(orientation)} is not a valid {nameof(DrawOrientation)} value.") - }; - - return new Image(width, height); - } + protected override Image CreateImageCore(int width, int height) => + new Image(width, height); public override void DisplayImage(Image image, DrawOrientation orientation = DrawOrientation.Default) { - string filePath = "image-" + Interlocked.Increment(ref _counter).ToString(CultureInfo.InvariantCulture) + ".png"; + int imageId = Interlocked.Increment(ref _counter); + string filePath = string.Create(CultureInfo.InvariantCulture, $"image-{imageId}.png"); filePath = Path.Combine(_imageDirectoryPath, filePath); image.SaveAsPng(filePath); diff --git a/src/Aether/Program.cs b/src/Aether/Program.cs index 3a679f7..d00da86 100644 --- a/src/Aether/Program.cs +++ b/src/Aether/Program.cs @@ -1,14 +1,56 @@ -using Aether.Devices.Displays.Themes; +using Aether.Devices.Drivers; using Aether.Devices.Sensors; using Aether.Devices.Sensors.Metadata; using Aether.Devices.Simulated; using Aether.Reactive; +using Aether.Themes; using System.CommandLine; using System.CommandLine.Invocation; +using System.Device.Gpio; +using System.Device.I2c; +using System.Device.Spi; using System.Reactive.Linq; using System.Reactive.Subjects; using UnitsNet; +var runDeviceCommand = new Command("run-device", "Runs an Aether device"); +runDeviceCommand.Handler = CommandHandler.Create(async () => +{ + // Initialize display. + var spiConfig = new System.Device.Spi.SpiConnectionSettings(0, 0) + { + ClockFrequency = 10000000 + }; + using var gpio = new GpioController(PinNumberingScheme.Logical); + using SpiDevice displayDevice = SpiDevice.Create(spiConfig); + using var displayDriver = new WaveshareEPD2_9inV2(displayDevice, gpio, dcPinId: 25, rstPinId: 17, busyPinId: 24); + + // Initialize MS5637. + using I2cDevice ms5637Device = I2cDevice.Create(new I2cConnectionSettings(1, ObservableMs5637.DefaultAddress)); + await using ObservableSensor ms5637Driver = ObservableMs5637.OpenSensor(ms5637Device, dependencies: Observable.Empty()); + + // Initialize SCD4x, taking a dependency on MS5637 for calibration with barometric pressure. + using I2cDevice scd4xDevice = I2cDevice.Create(new I2cConnectionSettings(1, ObservableScd4x.DefaultAddress)); + await using ObservableSensor scdDriver = ObservableScd4x.OpenSensor(scd4xDevice, dependencies: ms5637Driver); + + // All the measurements funnel through here. + IObservable measurements = Observable.Merge(ms5637Driver, scdDriver); + + // Initialize the theme, which takes all the measurements and renders them to a display. + var lines = new[] { Measure.CO2, Measure.Humidity, Measure.BarometricPressure, Measure.Temperature }; + using IDisposable theme = MultiLineTheme.CreateTheme(displayDriver, lines, measurements); + + // Wait for Ctrl+C to exit. + var tcs = new TaskCompletionSource(); + Console.CancelKeyPress += (s, e) => + { + Console.WriteLine("Closing..."); + e.Cancel = true; + tcs.TrySetResult(); + }; + await tcs.Task; +}); + var listSensorCommand = new Command("list", "Lists available sensors") { Handler = CommandHandler.Create(() => @@ -75,8 +117,35 @@ sub.OnCompleted(); }); +// Temporary command to test the display. +// TODO: Make this more like a list/test format similar to sensor. +var displayTestCommand = new Command("display-test", "Tests a display."); +displayTestCommand.Handler = CommandHandler.Create(() => +{ + var spiConfig = new System.Device.Spi.SpiConnectionSettings(0, 0) + { + ClockFrequency = 10000000 + }; + + var lines = new[] { Measure.CO2, Measure.Humidity, Measure.BarometricPressure, Measure.Temperature }; + + using var gpio = new GpioController(PinNumberingScheme.Logical); + using SpiDevice device = SpiDevice.Create(spiConfig); + using var driver = new WaveshareEPD2_9inV2(device, gpio, dcPinId: 25, rstPinId: 17, busyPinId: 24); + + using var sub = new Subject(); + using IDisposable theme = MultiLineTheme.CreateTheme(driver, lines, sub); + + sub.OnNext(Measurement.FromCo2(VolumeConcentration.FromPartsPerMillion(4312.25))); + sub.OnNext(Measurement.FromRelativeHumidity(RelativeHumidity.FromPercent(59.1))); + sub.OnNext(Measurement.FromPressure(Pressure.FromAtmospheres(1.04))); + sub.OnNext(Measurement.FromTemperature(Temperature.FromDegreesFahrenheit(65.2))); + sub.OnCompleted(); +}); + var rootCommand = new RootCommand() { + runDeviceCommand, new Command("sensor", "Operates on sensors") { listSensorCommand, @@ -86,7 +155,8 @@ }, simulateSensorCommand }, - themeTestCommand + themeTestCommand, + displayTestCommand }; await rootCommand.InvokeAsync(Environment.CommandLine); diff --git a/src/Aether/Devices/Displays/Themes/MultiLineTheme.cs b/src/Aether/Themes/MultiLineTheme.cs similarity index 97% rename from src/Aether/Devices/Displays/Themes/MultiLineTheme.cs rename to src/Aether/Themes/MultiLineTheme.cs index 58abe37..eb5c579 100644 --- a/src/Aether/Devices/Displays/Themes/MultiLineTheme.cs +++ b/src/Aether/Themes/MultiLineTheme.cs @@ -5,7 +5,7 @@ using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Processing; -namespace Aether.Devices.Displays.Themes +namespace Aether.Themes { internal sealed class MultiLineTheme { @@ -13,7 +13,7 @@ internal sealed class MultiLineTheme public static IDisposable CreateTheme(DisplayDriver driver, IEnumerable lines, IObservable source) { - Image image = driver.CreateImage(DrawOrientation.Rotate90); + Image image = driver.CreateImage(); var fontCollection = new FontCollection(); fontCollection.Install("fonts/Manrope-Regular.ttf");