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;