diff --git a/src/devices/SenseHat/SenseHat.cs b/src/devices/SenseHat/SenseHat.cs index e8ce937c71..b5d2bcebee 100644 --- a/src/devices/SenseHat/SenseHat.cs +++ b/src/devices/SenseHat/SenseHat.cs @@ -177,6 +177,9 @@ public SenseHat(I2cBus? i2cBus = null, bool shouldDispose = false) /// public void Dispose() { + // Terminate text animation timer if active + LedMatrix?.Terminate(); + if (_shouldDispose) { _i2cBus?.Dispose(); diff --git a/src/devices/SenseHat/SenseHatLedMatrix.cs b/src/devices/SenseHat/SenseHatLedMatrix.cs index 08ed70b3cd..d53465278c 100644 --- a/src/devices/SenseHat/SenseHatLedMatrix.cs +++ b/src/devices/SenseHat/SenseHatLedMatrix.cs @@ -7,12 +7,14 @@ using System.Diagnostics; using System.Drawing; using System.Runtime.CompilerServices; +using System.Device.Model; namespace Iot.Device.SenseHat { /// /// Base class for SenseHAT LED matrix /// + [Interface("SenseHat LED Matrix")] public abstract class SenseHatLedMatrix : IDisposable { /// @@ -32,6 +34,51 @@ public abstract class SenseHatLedMatrix : IDisposable /// protected const int NumberOfPixelsPerColumn = 8; + /// + /// Lazily intialized pixel font reference. Initialized when rendering text. + /// + protected SenseHatTextFont? _pixelFont = null; + + /// + /// Lazily initialized text render state. Initialized when rendering text. + /// + protected SenseHatTextRenderState? _textRenderState = null; + + /// + /// Text color when rendering text. + /// + protected Color _textColor = Color.DarkBlue; + + /// + /// Text background color when rendering text. + /// + protected Color _textBackgroundColor = Color.Black; + + /// + /// Text rotation, counterclockwise. + /// + protected SenseHatTextRotation _textRotation = SenseHatTextRotation.Rotate_0_Degrees; + + /// + /// Text scroll speed when the rendered text does not fit the 8x8 LED matrix + /// + protected double _textScrollPixelsPerSecond = 1; + + /// + /// Timer used for text animation (scrolling). Lazily initialized as required. + /// + protected System.Timers.Timer? _textAnimationTimer = null; + + // Lock object to maintain a consistent set of render parameters. + private object _lockTextRenderState = new(); + + // Lock object to prevent i2c disposal during render. + // "Terminate" must be called for clean termination of text animation. + private object _lockWrite = new(); + + // Flag set to true when no more rendering should take place because the LED matrix is about to be disposed. + private volatile bool _terminate = false; + /// /// Constructs SenseHatLedMatrix instance /// @@ -81,12 +128,14 @@ public static int PositionToIndex(int x, int y) /// Write colors to the device /// /// Array of colors + [Command] public abstract void Write(ReadOnlySpan colors); /// /// Fill LED matrix with a specific color /// /// Color to fill the device with + [Command] public abstract void Fill(Color color = default(Color)); /// @@ -95,9 +144,300 @@ public static int PositionToIndex(int x, int y) /// X coordinate /// Y coordinate /// Color to be set in the specified position + [Command] public abstract void SetPixel(int x, int y, Color color); + /// + /// Stop animation effects if active. + /// + public void Terminate() + { + lock (_lockTextRenderState) + { + _textRenderState = null; + StopTextAnimationTimer(); + } + + lock (_lockWrite) + { + // Prevent further access to the underlying device (i.e. i2c bus) + _terminate = true; + } + } + /// public abstract void Dispose(); + + /// + /// Renders text on the LED display. + /// + /// Text to render. Set to empty string to stop rendering text. + [Command] + public void SetText(string text) + { + if (string.IsNullOrEmpty(text)) + { + StopTextAnimationTimer(); + Fill(_textBackgroundColor); + lock (_lockTextRenderState) + { + _textRenderState = null; + } + + return; + } + + if (_pixelFont == null) + { + _pixelFont = new SenseHatTextFont(); + } + + var renderMatrix = _pixelFont.RenderText(text); + SenseHatTextRenderState renderState; + lock (_lockTextRenderState) + { + // Create a new render state containing the render matrix for the new text + _textRenderState = new SenseHatTextRenderState(renderMatrix, _textColor, _textBackgroundColor, _textRotation); + renderState = _textRenderState; + } + + RenderText(renderState); + + StartOrStopTextScrolling(); + } + + /// + /// Text color when rendering text. + /// + [Property] + public Color TextColor + { + get + { + return _textColor; + } + set + { + _textColor = value; + + // Apply new state. Lock because render state is nullable and assignment may not be atomic. + SenseHatTextRenderState? renderState; + lock (_lockTextRenderState) + { + if (_textRenderState != null) + { + _textRenderState = _textRenderState.ApplyTextColor(value); + } + + renderState = _textRenderState; + } + + RenderText(renderState); + } + } + + /// + /// Text background color when rendering text. + /// + [Property] + public Color TextBackgroundColor + { + get + { + return _textBackgroundColor; + } + set + { + _textBackgroundColor = value; + + // Apply new state. Lock because render state is nullable and assignment may not be atomic. + SenseHatTextRenderState? renderState; + lock (_lockTextRenderState) + { + if (_textRenderState != null) + { + _textRenderState = _textRenderState.ApplyTextBackgroundColor(value); + } + + renderState = _textRenderState; + } + + RenderText(renderState); + } + } + + /// + /// Text scroll speed in pixels per second. + /// + [Property] + public double TextScrollPixelsPerSecond + { + get + { + return _textScrollPixelsPerSecond; + } + set + { + _textScrollPixelsPerSecond = value; + StartOrStopTextScrolling(); + } + } + + /// + /// Text rotation, counterclockwise. + /// + [Property] + public SenseHatTextRotation TextRotation + { + get + { + return _textRotation; + } + set + { + _textRotation = value; + + // Apply new state. Lock because render state is nullable and assignment may not be atomic. + SenseHatTextRenderState? renderState; + lock (_lockTextRenderState) + { + if (_textRenderState != null) + { + _textRenderState = _textRenderState.ApplyTextRotation(value); + } + + renderState = _textRenderState; + } + + RenderText(renderState); + } + } + + private void StartOrStopTextScrolling() + { + SenseHatTextRenderState? renderState; + // Lock because render state is nullable and assignment may not be atomic + lock (_lockTextRenderState) + { + renderState = _textRenderState; + } + + int millisecondsPerPixel; + if (renderState == null || renderState.TextRenderMatrix.Text.Length == 0 || _textScrollPixelsPerSecond <= 0) + { + millisecondsPerPixel = 0; + } + else + { + millisecondsPerPixel = (int)(1000 / _textScrollPixelsPerSecond); + } + + if (millisecondsPerPixel <= 0) + { + StopTextAnimationTimer(); + } + else + { + // Calculate the number of milliseconds for one pixel shift + StartTextAnimationTimer((int)millisecondsPerPixel); + } + } + + private void StartTextAnimationTimer(int intervalMs) + { + StopTextAnimationTimer(); + _textAnimationTimer = new System.Timers.Timer(intervalMs); + _textAnimationTimer.Elapsed += TextAnimationTimer_Elapsed; + _textAnimationTimer.Start(); + } + + private void StopTextAnimationTimer() + { + if (_textAnimationTimer != null) + { + _textAnimationTimer.Stop(); + _textAnimationTimer.Elapsed -= TextAnimationTimer_Elapsed; + _textAnimationTimer.Dispose(); + _textAnimationTimer = null; + } + } + + private void TextAnimationTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + SenseHatTextRenderState? renderState; + // Lock because render state is nullable and assignment may not be atomic + lock (_lockTextRenderState) + { + renderState = _textRenderState; + } + + if (renderState != null) + { + var renderMatrix = renderState.TextRenderMatrix; + if (renderMatrix.Text.Length > 1) + { + renderMatrix.ScrollByOnePixel(); + RenderText(renderState); + } + } + } + + private void RenderText(SenseHatTextRenderState? textRenderState) + { + if (textRenderState == null) + { + return; + } + + // Allocate frame buffer to hold color values for one frame + var frameBuffer = new Color[NumberOfPixelsPerColumn * NumberOfPixelsPerRow]; + + // Render the 8x8 matrix + for (var x = 0; x < NumberOfPixelsPerColumn; x++) + { + for (var y = 0; y < NumberOfPixelsPerRow; y++) + { + int tx = x; + int ty = y; + switch (textRenderState.TextRotation) + { + case SenseHatTextRotation.Rotate_0_Degrees: + break; + case SenseHatTextRotation.Rotate_90_Degrees: + tx = y; + ty = NumberOfPixelsPerColumn - x - 1; + break; + case SenseHatTextRotation.Rotate_180_Degrees: + tx = NumberOfPixelsPerColumn - x - 1; + ty = NumberOfPixelsPerRow - y - 1; + break; + case SenseHatTextRotation.Rotate_270_Degrees: + tx = NumberOfPixelsPerColumn - y - 1; + ty = x; + break; + } + + var frameIndex = PositionToIndex(tx, ty); + + if (textRenderState.TextRenderMatrix.IsPixelSet(x, y)) + { + frameBuffer[frameIndex] = textRenderState.TextColor; + } + else + { + frameBuffer[frameIndex] = textRenderState.TextBackgroundColor; + } + } + } + + // Lock write so that SenseHat disposal does not interfere + lock (_lockWrite) + { + if (!_terminate) + { + Write(frameBuffer); + } + } + } } } diff --git a/src/devices/SenseHat/SenseHatTextFont.cs b/src/devices/SenseHat/SenseHatTextFont.cs new file mode 100644 index 0000000000..19d747d6bc --- /dev/null +++ b/src/devices/SenseHat/SenseHatTextFont.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; + +namespace Iot.Device.SenseHat +{ + /// + /// 5x8 font adaptor to render font glyphs into the text render matrix. + /// + public class SenseHatTextFont + { + private const int CharGap = 2; + private const int WordGap = 4; // Reduced space between words + private const int CharHeight = 8; + // Limit length of text when rendering + private const int MaxTextLength = 128; + // Using Font5x8 + private Graphics.Font5x8 _font = new Graphics.Font5x8(); + + /// + /// Generates a byte matrix containing the bit pattern for the rendered text. + /// + /// The text to render + /// The initial renderMatrix including matrix dimension. + public SenseHatTextRenderMatrix RenderText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + // nothing to render + return new SenseHatTextRenderMatrix(text, new byte[0], 0); + } + + if (text.Length > MaxTextLength) + { + text = "Text is too long"; + } + + // Calculate "bitmap" width for mono space font + var renderWidth = 0; + foreach (var c in text) + { + if (c == ' ') + { + renderWidth += WordGap; + } + else + { + renderWidth += _font.Width + CharGap; + } + } + + // remove last gap + renderWidth -= CharGap; + + // Reserve space for the rendered bitmap + var matrix = new byte[renderWidth * CharHeight]; + + var x = 0; + foreach (var c in text) + { + if (c == ' ') + { + x += WordGap; + continue; + } + + _font.GetCharData(c, out var glyph); + for (var cy = 0; cy < _font.Height; cy++) + { + var bitPattern = glyph[cy]; + // The letter is right-aligned within the 8x8 matrix; evaluate from bit 3 onward + byte flag = 0x80 >> 3; + for (var cx = 3; cx < 8; cx++) + { + if ((bitPattern & flag) != 0) + { + // Set value to 1 to indicate that a pixel should be "on". + matrix[x + cx - 3 + cy * renderWidth] = 1; + } + + flag >>= 1; + } + } + + x += _font.Width + CharGap; + } + + return new SenseHatTextRenderMatrix(text, matrix, renderWidth); + } + } +} diff --git a/src/devices/SenseHat/SenseHatTextRenderMatrix.cs b/src/devices/SenseHat/SenseHatTextRenderMatrix.cs new file mode 100644 index 0000000000..2e151d1f12 --- /dev/null +++ b/src/devices/SenseHat/SenseHatTextRenderMatrix.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; + +namespace Iot.Device.SenseHat +{ + /// + /// Matrix holding the rendered text. Pixels are "on" where positions in the matrix are not equal to zero. + /// + public class SenseHatTextRenderMatrix + { + private const int LedMatrixWidth = 8; + + /// + /// The x position within the PixelMatrix where rendering to the 8x8 LEDs should begin. + /// + private int _horizontalScrollPosition = 0; + + /// + /// Construct the initial render matrix. + /// + /// Rendered text + /// Render matrix containting glyph pixel flags + /// Width of the matrix for all glyphs + public SenseHatTextRenderMatrix(string text, byte[] pixelMatrix, int pixelMatrixWidth) + { + Text = text; + PixelMatrix = pixelMatrix; + PixelMatrixWidth = pixelMatrixWidth; + } + + /// + /// Rendered text + /// + public readonly string Text; + + /// + /// Matrix containing the bitmap of size 'PixelMatrixWidth * 8'. Values not equal zero indicate that a pixel should be set. + /// + public readonly byte[] PixelMatrix; + + /// + /// The width of the rendered matrix. + /// + public readonly int PixelMatrixWidth; + + /// + /// Determine whether a pixel is "on" + /// + /// x position within the 8x8 matrix + /// y position within the 8x8 matrix + /// + public bool IsPixelSet(int x, int y) + { + if (PixelMatrixWidth == 0) + { + return false; + } + + int effectiveX; + if (PixelMatrixWidth < LedMatrixWidth) + { + // Center letter within LED matrix + effectiveX = x - (LedMatrixWidth - PixelMatrixWidth) / 2; + if (effectiveX < 0 || effectiveX >= PixelMatrixWidth) + { + return false; + } + } + else + { + // _horizontalScrollPosition may be not-zero if text is scrolled. + effectiveX = (x + _horizontalScrollPosition) % PixelMatrixWidth; + } + + return PixelMatrix[effectiveX + y * PixelMatrixWidth] != 0; + } + + /// + /// Move the text by one pixel + /// + public void ScrollByOnePixel() + { + if (PixelMatrixWidth > LedMatrixWidth) + { + _horizontalScrollPosition = (_horizontalScrollPosition + 1) % PixelMatrixWidth; + } + } + } +} diff --git a/src/devices/SenseHat/SenseHatTextRenderState.cs b/src/devices/SenseHat/SenseHatTextRenderState.cs new file mode 100644 index 0000000000..1f2492f58f --- /dev/null +++ b/src/devices/SenseHat/SenseHatTextRenderState.cs @@ -0,0 +1,93 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Drawing; + +namespace Iot.Device.SenseHat +{ + /// + /// Render state containing parameters that should not change mid-frame when rendering + /// text. Any change in state requires construction of a new state object, either + /// by using the constructor or by calling one of the "Apply..." methods. + /// + public class SenseHatTextRenderState + { + /// + /// Construct the render state. + /// + /// Matrix containing the rendered text + /// Color of the text + /// Color of the text background + /// Text rotation + public SenseHatTextRenderState( + SenseHatTextRenderMatrix textRenderMatrix, + Color textColor, + Color textBackgroundColor, + SenseHatTextRotation textRotation) + { + TextRenderMatrix = textRenderMatrix; + TextColor = textColor; + TextBackgroundColor = textBackgroundColor; + TextRotation = textRotation; + } + + /// + /// Clone the render state and apply the new text color. + /// + /// The new text color to apply + public SenseHatTextRenderState ApplyTextColor(Color textColor) + { + return new SenseHatTextRenderState( + TextRenderMatrix, + textColor, + TextBackgroundColor, + TextRotation); + } + + /// + /// Clone the render state and apply the new text background color. + /// + /// The new text background color to apply + public SenseHatTextRenderState ApplyTextBackgroundColor(Color textBackgroundColor) + { + return new SenseHatTextRenderState( + TextRenderMatrix, + TextColor, + textBackgroundColor, + TextRotation); + } + + /// + /// Clone the render state and apply the new text rotation. + /// + /// The new text rotation to apply + public SenseHatTextRenderState ApplyTextRotation(SenseHatTextRotation textRotation) + { + return new SenseHatTextRenderState( + TextRenderMatrix, + TextColor, + TextBackgroundColor, + textRotation); + } + + /// + /// Matrix containing the rendered text. + /// + public readonly SenseHatTextRenderMatrix TextRenderMatrix; + + /// + /// Color of the text. + /// + public readonly Color TextColor; + + /// + /// Color of the text background. + /// + public readonly Color TextBackgroundColor; + + /// + /// Text rotation. + /// + public readonly SenseHatTextRotation TextRotation; + } +} \ No newline at end of file diff --git a/src/devices/SenseHat/SenseHatTextRotation.cs b/src/devices/SenseHat/SenseHatTextRotation.cs new file mode 100644 index 0000000000..35632783b1 --- /dev/null +++ b/src/devices/SenseHat/SenseHatTextRotation.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Iot.Device.SenseHat +{ + /// + /// Text rotation when rendering text, counterclockwise. + /// + public enum SenseHatTextRotation + { + /// + /// No rotation + /// + Rotate_0_Degrees, + + /// + /// Rotate by 90 degrees + /// + Rotate_90_Degrees, + + /// + /// Rotate by 180 degress + /// + Rotate_180_Degrees, + + /// + /// Rotate by 270 degrees + /// + Rotate_270_Degrees + } +} diff --git a/src/devices/SenseHat/samples/Program.cs b/src/devices/SenseHat/samples/Program.cs index de027a1a1c..e150f415e4 100644 --- a/src/devices/SenseHat/samples/Program.cs +++ b/src/devices/SenseHat/samples/Program.cs @@ -12,6 +12,15 @@ var defaultSeaLevelPressure = WeatherHelper.MeanSeaLevel; using SenseHat sh = new(); + +for (var i = 3; i > 0; --i) +{ + sh.LedMatrix.SetText(i.ToString()); + Thread.Sleep(1000); +} + +sh.LedMatrix.SetText(string.Empty); + int n = 0; int x = 3, y = 3;