Classes/PsGadgetSsd1306.ps1
|
# Classes/PsGadgetSsd1306.ps1 # SSD1306 OLED Display Class class PsGadgetSsd1306 : PsGadgetI2CDevice { [int]$Width # Physical pixel width (always 128) [int]$Height # Physical pixel height (32 or 64) [int]$Pages # Physical pages = Height/8 [int]$Rotation # 0, 90, 180, or 270 degrees [int]$LogicalWidth # Canvas width seen by callers (swapped for 90/270) [int]$LogicalHeight # Canvas height seen by callers (swapped for 90/270) [int]$LogicalPages # LogicalHeight / 8 [byte[]]$FrameBuffer # Width * Pages bytes; index = page*Width + col; bit0 = top of page [hashtable]$Glyphs [hashtable]$Symbols PsGadgetSsd1306() { $this.Logger.WriteInfo("Creating PsGadgetSsd1306 instance") $this.I2CAddress = 0x3C $this.Width = 128 $this.Height = 64 $this.Pages = 8 $this.Rotation = 0 $this.UpdateLogicalDimensions() $this.FrameBuffer = New-Object byte[] ($this.Width * $this.Pages) $this.IsInitialized = $false $this.InitializeGlyphs() $this.InitializeSymbols() } PsGadgetSsd1306([System.Object]$ftdiDevice) { $this.Logger.WriteInfo("Creating PsGadgetSsd1306 instance with FTDI device") $this.FtdiDevice = $ftdiDevice $this.I2CAddress = 0x3C $this.Width = 128 $this.Height = 64 $this.Pages = 8 $this.Rotation = 0 $this.UpdateLogicalDimensions() $this.FrameBuffer = New-Object byte[] ($this.Width * $this.Pages) $this.IsInitialized = $false $this.InitializeGlyphs() $this.InitializeSymbols() } PsGadgetSsd1306([System.Object]$ftdiDevice, [byte]$address) { $this.Logger.WriteInfo("Creating PsGadgetSsd1306 instance with FTDI device and address 0x$($address.ToString('X2'))") $this.FtdiDevice = $ftdiDevice $this.I2CAddress = $address $this.Width = 128 $this.Height = 64 $this.Pages = 8 $this.Rotation = 0 $this.UpdateLogicalDimensions() $this.FrameBuffer = New-Object byte[] ($this.Width * $this.Pages) $this.IsInitialized = $false $this.InitializeGlyphs() $this.InitializeSymbols() } PsGadgetSsd1306([System.Object]$ftdiDevice, [byte]$address, [int]$height) { $this.Logger.WriteInfo("Creating PsGadgetSsd1306 instance: address 0x$($address.ToString('X2')), height $height") $this.FtdiDevice = $ftdiDevice $this.I2CAddress = $address $this.Width = 128 $this.Height = $height $this.Pages = [int]($height / 8) $this.Rotation = 0 $this.UpdateLogicalDimensions() $this.FrameBuffer = New-Object byte[] ($this.Width * $this.Pages) $this.IsInitialized = $false $this.InitializeGlyphs() $this.InitializeSymbols() } PsGadgetSsd1306([System.Object]$ftdiDevice, [byte]$address, [int]$height, [int]$rotation) { $this.Logger.WriteInfo("Creating PsGadgetSsd1306 instance: address 0x$($address.ToString('X2')), height $height, rotation $rotation") $this.FtdiDevice = $ftdiDevice $this.I2CAddress = $address $this.Width = 128 $this.Height = $height $this.Pages = [int]($height / 8) $this.Rotation = $rotation $this.UpdateLogicalDimensions() $this.FrameBuffer = New-Object byte[] ($this.Width * $this.Pages) $this.IsInitialized = $false $this.InitializeGlyphs() $this.InitializeSymbols() } [void] InitializeGlyphs() { $this.Logger.WriteDebug("Initializing SSD1306 glyph font table") # Create case-sensitive hashtable for character glyphs $this.Glyphs = [hashtable]::new([System.StringComparer]::Ordinal) # Load 6x8 ASCII font based on reference implementation try { $this.Glyphs.Add('0', @( 0x00, 0x3E, 0x51, 0x49, 0x45, 0x3E )) $this.Glyphs.Add('1', @( 0x00, 0x00, 0x42, 0x7F, 0x40, 0x00 )) $this.Glyphs.Add('2', @( 0x00, 0x42, 0x61, 0x51, 0x49, 0x46 )) $this.Glyphs.Add('3', @( 0x00, 0x21, 0x41, 0x45, 0x4B, 0x31 )) $this.Glyphs.Add('4', @( 0x00, 0x18, 0x14, 0x12, 0x7F, 0x10 )) $this.Glyphs.Add('5', @( 0x00, 0x27, 0x45, 0x45, 0x45, 0x39 )) $this.Glyphs.Add('6', @( 0x00, 0x3C, 0x4A, 0x49, 0x49, 0x30 )) $this.Glyphs.Add('7', @( 0x00, 0x01, 0x71, 0x09, 0x05, 0x03 )) $this.Glyphs.Add('8', @( 0x00, 0x36, 0x49, 0x49, 0x49, 0x36 )) $this.Glyphs.Add('9', @( 0x00, 0x06, 0x49, 0x49, 0x29, 0x1E )) $this.Glyphs.Add('A', @( 0x00, 0x7C, 0x12, 0x11, 0x12, 0x7C )) $this.Glyphs.Add('B', @( 0x00, 0x7F, 0x49, 0x49, 0x49, 0x36 )) $this.Glyphs.Add('C', @( 0x00, 0x3E, 0x41, 0x41, 0x41, 0x22 )) $this.Glyphs.Add('D', @( 0x00, 0x7F, 0x41, 0x41, 0x22, 0x1C )) $this.Glyphs.Add('E', @( 0x00, 0x7F, 0x49, 0x49, 0x49, 0x41 )) $this.Glyphs.Add('F', @( 0x00, 0x7F, 0x09, 0x09, 0x09, 0x01 )) $this.Glyphs.Add('G', @( 0x00, 0x3E, 0x41, 0x49, 0x49, 0x7A )) $this.Glyphs.Add('H', @( 0x00, 0x7F, 0x08, 0x08, 0x08, 0x7F )) $this.Glyphs.Add('I', @( 0x00, 0x00, 0x41, 0x7F, 0x41, 0x00 )) $this.Glyphs.Add('J', @( 0x00, 0x20, 0x40, 0x41, 0x3F, 0x01 )) $this.Glyphs.Add('K', @( 0x00, 0x7F, 0x08, 0x14, 0x22, 0x41 )) $this.Glyphs.Add('L', @( 0x00, 0x7F, 0x40, 0x40, 0x40, 0x40 )) $this.Glyphs.Add('M', @( 0x00, 0x7F, 0x02, 0x0C, 0x02, 0x7F )) $this.Glyphs.Add('N', @( 0x00, 0x7F, 0x04, 0x08, 0x10, 0x7F )) $this.Glyphs.Add('O', @( 0x00, 0x3E, 0x41, 0x41, 0x41, 0x3E )) $this.Glyphs.Add('P', @( 0x00, 0x7F, 0x09, 0x09, 0x09, 0x06 )) $this.Glyphs.Add('Q', @( 0x00, 0x3E, 0x41, 0x51, 0x21, 0x5E )) $this.Glyphs.Add('R', @( 0x00, 0x7F, 0x09, 0x19, 0x29, 0x46 )) $this.Glyphs.Add('S', @( 0x00, 0x46, 0x49, 0x49, 0x49, 0x31 )) $this.Glyphs.Add('T', @( 0x00, 0x01, 0x01, 0x7F, 0x01, 0x01 )) $this.Glyphs.Add('U', @( 0x00, 0x3F, 0x40, 0x40, 0x40, 0x3F )) $this.Glyphs.Add('V', @( 0x00, 0x1F, 0x20, 0x40, 0x20, 0x1F )) $this.Glyphs.Add('W', @( 0x00, 0x3F, 0x40, 0x38, 0x40, 0x3F )) $this.Glyphs.Add('X', @( 0x00, 0x63, 0x14, 0x08, 0x14, 0x63 )) $this.Glyphs.Add('Y', @( 0x00, 0x07, 0x08, 0x70, 0x08, 0x07 )) $this.Glyphs.Add('Z', @( 0x00, 0x61, 0x51, 0x49, 0x45, 0x43 )) $this.Glyphs.Add('a', @( 0x00, 0x20, 0x54, 0x54, 0x54, 0x78 )) $this.Glyphs.Add('b', @( 0x00, 0x7F, 0x48, 0x44, 0x44, 0x38 )) $this.Glyphs.Add('c', @( 0x00, 0x38, 0x44, 0x44, 0x44, 0x20 )) $this.Glyphs.Add('d', @( 0x00, 0x38, 0x44, 0x44, 0x48, 0x7F )) $this.Glyphs.Add('e', @( 0x00, 0x38, 0x54, 0x54, 0x54, 0x18 )) $this.Glyphs.Add('f', @( 0x00, 0x08, 0x7E, 0x09, 0x01, 0x02 )) $this.Glyphs.Add('g', @( 0x00, 0x18, 0xA4, 0xA4, 0xA4, 0x7C )) $this.Glyphs.Add('h', @( 0x00, 0x7F, 0x08, 0x04, 0x04, 0x78 )) $this.Glyphs.Add('i', @( 0x00, 0x00, 0x44, 0x7D, 0x40, 0x00 )) $this.Glyphs.Add('j', @( 0x00, 0x40, 0x80, 0x84, 0x7D, 0x00 )) $this.Glyphs.Add('k', @( 0x00, 0x7F, 0x10, 0x28, 0x44, 0x00 )) $this.Glyphs.Add('l', @( 0x00, 0x00, 0x41, 0x7F, 0x40, 0x00 )) $this.Glyphs.Add('m', @( 0x00, 0x7C, 0x04, 0x18, 0x04, 0x78 )) $this.Glyphs.Add('n', @( 0x00, 0x7C, 0x08, 0x04, 0x04, 0x78 )) $this.Glyphs.Add('o', @( 0x00, 0x38, 0x44, 0x44, 0x44, 0x38 )) $this.Glyphs.Add('p', @( 0x00, 0xFC, 0x24, 0x24, 0x24, 0x18 )) $this.Glyphs.Add('q', @( 0x00, 0x18, 0x24, 0x24, 0x18, 0xFC )) $this.Glyphs.Add('r', @( 0x00, 0x7C, 0x08, 0x04, 0x04, 0x08 )) $this.Glyphs.Add('s', @( 0x00, 0x48, 0x54, 0x54, 0x54, 0x20 )) $this.Glyphs.Add('t', @( 0x00, 0x04, 0x3F, 0x44, 0x40, 0x20 )) $this.Glyphs.Add('u', @( 0x00, 0x3C, 0x40, 0x40, 0x20, 0x7C )) $this.Glyphs.Add('v', @( 0x00, 0x1C, 0x20, 0x40, 0x20, 0x1C )) $this.Glyphs.Add('w', @( 0x00, 0x3C, 0x40, 0x30, 0x40, 0x3C )) $this.Glyphs.Add('x', @( 0x00, 0x44, 0x28, 0x10, 0x28, 0x44 )) $this.Glyphs.Add('y', @( 0x00, 0x1C, 0xA0, 0xA0, 0xA0, 0x7C )) $this.Glyphs.Add('z', @( 0x00, 0x44, 0x64, 0x54, 0x4C, 0x44 )) $this.Glyphs.Add(' ', @( 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 )) $this.Glyphs.Add('!', @( 0x00, 0x00, 0x6F, 0x00, 0x00, 0x00 )) $this.Glyphs.Add('"', @( 0x00, 0x00, 0x07, 0x00, 0x07, 0x00 )) $this.Glyphs.Add('#', @( 0x00, 0x14, 0x7f, 0x14, 0x7f, 0x14 )) $this.Glyphs.Add('$', @( 0x00, 0x24, 0x2a, 0x7f, 0x2a, 0x12 )) $this.Glyphs.Add('%', @( 0x00, 0x23, 0x13, 0x08, 0x64, 0x62 )) $this.Glyphs.Add('&', @( 0x00, 0x36, 0x49, 0x55, 0x22, 0x50 )) $this.Glyphs.Add("'", @( 0x00, 0x00, 0x05, 0x03, 0x00, 0x00 )) $this.Glyphs.Add('(', @( 0x00, 0x00, 0x1c, 0x22, 0x41, 0x00 )) $this.Glyphs.Add(')', @( 0x00, 0x00, 0x41, 0x22, 0x1c, 0x00 )) $this.Glyphs.Add('*', @( 0x00, 0x0a, 0x04, 0x1f, 0x04, 0x0a )) $this.Glyphs.Add('+', @( 0x00, 0x08, 0x08, 0x3e, 0x08, 0x08 )) $this.Glyphs.Add(',', @( 0x00, 0x00, 0x50, 0x30, 0x00, 0x00 )) $this.Glyphs.Add('-', @( 0x00, 0x08, 0x08, 0x08, 0x08, 0x08 )) $this.Glyphs.Add('.', @( 0x00, 0x00, 0x60, 0x60, 0x00, 0x00 )) $this.Glyphs.Add('/', @( 0x00, 0x20, 0x10, 0x08, 0x04, 0x02 )) $this.Glyphs.Add(':', @( 0x00, 0x00, 0x36, 0x36, 0x00, 0x00 )) $this.Glyphs.Add(';', @( 0x00, 0x00, 0x56, 0x36, 0x00, 0x00 )) $this.Glyphs.Add('<', @( 0x00, 0x08, 0x14, 0x22, 0x41, 0x00 )) $this.Glyphs.Add('=', @( 0x00, 0x14, 0x14, 0x14, 0x14, 0x14 )) $this.Glyphs.Add('>', @( 0x00, 0x00, 0x41, 0x22, 0x14, 0x08 )) $this.Glyphs.Add('?', @( 0x00, 0x02, 0x01, 0x51, 0x09, 0x06 )) $this.Glyphs.Add('@', @( 0x00, 0x32, 0x49, 0x79, 0x41, 0x3e )) $this.Glyphs.Add('[', @( 0x00, 0x00, 0x7F, 0x41, 0x41, 0x00 )) $this.Glyphs.Add('\', @( 0x00, 0x02, 0x04, 0x08, 0x10, 0x20 )) $this.Glyphs.Add(']', @( 0x00, 0x00, 0x41, 0x41, 0x7F, 0x00 )) $this.Glyphs.Add('^', @( 0x00, 0x04, 0x02, 0x01, 0x02, 0x04 )) $this.Glyphs.Add('_', @( 0x00, 0x40, 0x40, 0x40, 0x40, 0x40 )) $this.Glyphs.Add('`', @( 0x00, 0x00, 0x01, 0x02, 0x04, 0x00 )) $this.Glyphs.Add('{', @( 0x00, 0x00, 0x08, 0x77, 0x00, 0x00 )) $this.Glyphs.Add('|', @( 0x00, 0x00, 0x00, 0x7F, 0x00, 0x00 )) $this.Glyphs.Add('}', @( 0x00, 0x00, 0x77, 0x08, 0x00, 0x00 )) $this.Glyphs.Add('~', @( 0x00, 0x10, 0x08, 0x10, 0x08, 0x00 )) $this.Logger.WriteDebug("Loaded $($this.Glyphs.Count) character glyphs") } catch { $this.Logger.WriteError("Failed to initialize glyphs: $_") throw } } [bool] Initialize([bool]$force) { if (-not $this.BeginInitialize($force)) { return $this.IsInitialized } $this.Logger.WriteInfo("Initializing SSD1306 display at address 0x$($this.I2CAddress.ToString('X2'))") try { # Height-dependent init values: # 128x64: mux=0x3F (63), COM pins=0x12 (alt config, left/right remap) # 128x32: mux=0x1F (31), COM pins=0x02 (sequential, no remap) [byte]$muxRatio = [byte]($this.Height - 1) [byte]$comPins = if ($this.Height -eq 32) { 0x02 } else { 0x12 } # Send the full initialization sequence as one batched I2C transaction. # PAGE addressing mode (0x20 0x02): page-mode cursor commands (0xB0+page, 0x00, 0x10) # remain valid and FlushPhysPage continues to work without change. Initialize-Ssd1306 -device $this -height $this.Height -rotation $this.Rotation | Out-Null $this.IsInitialized = $true $this.FrameBuffer = New-Object byte[] ($this.Width * $this.Pages) # Clear the hardware GDDRAM to match the zeroed framebuffer. # Reference implementation always clears after init; some displays retain # GDDRAM across power cycles and will show stale data without this. Write-Ssd1306Display -device $this -frameBuffer $this.FrameBuffer -pages $this.Pages | Out-Null $this.Logger.WriteInfo("SSD1306 initialization completed successfully") return $true } catch { $this.Logger.WriteError("SSD1306 initialization failed: $_") $this.IsInitialized = $false return $false } } [bool] Clear() { if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } $this.Logger.WriteInfo("Clearing SSD1306 display") try { Clear-Ssd1306Display -device $this -frameBuffer $this.FrameBuffer -pages $this.Pages | Out-Null return $true } catch { $this.Logger.WriteError("Failed to clear display: $_") return $false } } [bool] ClearPage([int]$page) { if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } if ($page -lt 0 -or $page -ge $this.LogicalPages) { $this.Logger.WriteError("Invalid page: $page (valid: 0-$($this.LogicalPages - 1))") return $false } try { [int]$yStart = $page * 8 $this.ClearLogicalRows($yStart, $yStart + 7) $result = $this.FlushLogicalRows($yStart, $yStart + 7) $this.Logger.WriteTrace("Cleared logical page $page") return $result } catch { $this.Logger.WriteError("Failed to clear page $page : $_") return $false } } [bool] SetCursor([int]$column, [int]$page) { if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } if ($column -lt 0 -or $column -ge $this.Width) { $this.Logger.WriteError("Invalid column: $column (valid range: 0-$($this.Width - 1))") return $false } if ($page -lt 0 -or $page -ge $this.Pages) { $this.Logger.WriteError("Invalid page: $page (valid range: 0-$($this.Pages - 1))") return $false } try { # Set page address [byte[]]$pageCmd = @(0x00, [byte](0xB0 + $page)) if (-not $this.I2CWrite($pageCmd)) { throw "Failed to set page address" } # Set column address (lower nibble) [byte[]]$colLowCmd = @(0x00, [byte](0x00 + ($column -band 0x0F))) if (-not $this.I2CWrite($colLowCmd)) { throw "Failed to set column low address" } # Set column address (upper nibble) [byte[]]$colHighCmd = @(0x00, [byte](0x10 + (($column -shr 4) -band 0x0F))) if (-not $this.I2CWrite($colHighCmd)) { throw "Failed to set column high address" } $this.Logger.WriteTrace("Set cursor to column $column, page $page") return $true } catch { $this.Logger.WriteError("Failed to set cursor: $_") return $false } } [bool] WriteText([string]$text, [int]$page) { return $this.WriteText($text, $page, 'left', 1, $false) } [bool] WriteText([string]$text, [int]$page, [string]$align) { return $this.WriteText($text, $page, $align, 1, $false) } [bool] WriteText([string]$text, [int]$page, [string]$align, [int]$fontSize, [bool]$invert) { if ($fontSize -eq 2) { if ($this.Rotation -eq 90 -or $this.Rotation -eq 270) { $this.Logger.WriteError("FontSize 2 is not supported in portrait (90/270) orientation") return $false } return $this.WriteTextTall($text, $page, $align, $invert) } if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } if ($page -lt 0 -or $page -ge $this.LogicalPages) { $this.Logger.WriteError("Invalid page: $page (valid: 0-$($this.LogicalPages - 1))") return $false } if ([string]::IsNullOrEmpty($text)) { $this.Logger.WriteDebug("Empty text string, nothing to write") return $true } $this.Logger.WriteInfo("WriteText '$text' -> logical page $page (align: $align, rotation: $($this.Rotation))") try { # Build per-character glyph list and total pixel width [System.Collections.Generic.List[byte[]]]$glyphList = [System.Collections.Generic.List[byte[]]]::new() [int]$totalWidth = 0 foreach ($char in $text.ToCharArray()) { $key = [string]$char [byte[]]$g = if ($this.Glyphs.ContainsKey($key)) { [byte[]]$this.Glyphs[$key] } else { [byte[]]$this.Glyphs[' '] } $glyphList.Add($g) $totalWidth += $g.Count } # Compute logical start X based on alignment [int]$startX = switch ($align.ToLower()) { 'center' { [math]::Max(0, [math]::Floor(($this.LogicalWidth - $totalWidth) / 2)) } 'right' { [math]::Max(0, $this.LogicalWidth - $totalWidth) } default { 0 } } [int]$startY = $page * 8 # Clear target logical rows before rendering (eliminates stray pixels) $this.ClearLogicalRows($startY, $startY + 7) # Render each glyph column-by-column into the framebuffer via SetLogicalPixel [int]$lx = $startX foreach ($g in $glyphList) { if ($lx -ge $this.LogicalWidth) { break } for ($col = 0; $col -lt $g.Count; $col++) { [byte]$colByte = $g[$col] if ($invert) { $colByte = [byte]($colByte -bxor 0xFF) } for ($bit = 0; $bit -lt 8; $bit++) { if ($colByte -band (1 -shl $bit)) { $this.SetLogicalPixel($lx + $col, $startY + $bit, $true) } } } $lx += $g.Count } # Flush modified physical pages to the display return $this.FlushLogicalRows($startY, $startY + 7) } catch { $this.Logger.WriteError("WriteText failed: $_") return $false } } # --------------------------------------------------------------------------- # Symbols support # --------------------------------------------------------------------------- [void] InitializeSymbols() { $this.Logger.WriteDebug("Initializing SSD1306 symbol table") $this.Symbols = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) # All symbols: 8-byte arrays, column-major, bit0=top pixel (row 0), bit7=bottom (row 7). # 8x8 is the Small (1-page) form. DrawSymbol auto-scales to 16x16 (2-page) using # ExpandNibble when page <= 6, falling back to 8x8 when page == 7. # Warning: upward-pointing triangle with ! in center column # col: 0 1 2 3 4 5 6 7 # R0: . . . * . . . . peak # R1: . . * * * . . . # R2: . . * * * . . . # R3: . * * . * * . . # R4: . * * * * * . . # R5: * * * * * * * . base fill # R6: * * * * * * * . base # R7: . . . . . . . . $this.Symbols['Warning'] = [byte[]]@(0x60, 0x78, 0x7E, 0x17, 0x7E, 0x78, 0x60, 0x00) # Alert: rectangle box with ! inside # col: 0 1 2 3 4 5 6 7 # R0: * * * * * * * . top border # R1: * . . * . . * . # R2: * . . * . . * . # R3: * . . * . . * . ! stem # R4: * . . . . . * . ! gap # R5: * . . * . . * . ! dot # R6: * * * * * * * . bottom border # R7: . . . . . . . . $this.Symbols['Alert'] = [byte[]]@(0x7F, 0x41, 0x41, 0x6F, 0x41, 0x41, 0x7F, 0x00) # Checkmark: tick/check mark shape # col: 0 1 2 3 4 5 6 7 # R0: . . . . . . . . # R1: . . . . . . * . # R2: . . . . . * * . # R3: . . . . * * . . # R4: * . . * * . . . # R5: * * * * . . . . # R6: . * * . . . . . # R7: . . . . . . . . $this.Symbols['Checkmark'] = [byte[]]@(0x30, 0x60, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x00) # Error: X inside a circle # col: 0 1 2 3 4 5 6 7 # R0: . . * * * * . . circle top # R1: . * . . . . * . # R2: * . * . . * . * X mark # R3: * . . * * . . * # R4: * . . * * . . * # R5: * . * . . * . * X mark # R6: . * . . . . * . # R7: . . * * * * . . circle bottom $this.Symbols['Error'] = [byte[]]@(0x3C, 0x42, 0xA5, 0x99, 0x99, 0xA5, 0x42, 0x3C) # Info: circle with "i" indicator (dot then bar) # Outer circle: cols 0,7=sides rows 2-5; cols 1,6=rows 1,6; cols 2,5=rows 0,7 # Center (cols 3,4): circle boundary + i dot at R2 + i bar at R4-R6 $this.Symbols['Info'] = [byte[]]@(0x3C, 0x42, 0x81, 0xF5, 0xF5, 0x81, 0x42, 0x3C) # Lock: closed padlock # R0-R2: arc (shackle top) # R3: gap # R4-R7: rectangular body with keyhole $this.Symbols['Lock'] = [byte[]]@(0xF0, 0x92, 0x91, 0xF1, 0x91, 0x92, 0x60, 0x00) # Unlock: open padlock (shackle released to right side) $this.Symbols['Unlock'] = [byte[]]@(0xF0, 0x90, 0x90, 0xF1, 0x91, 0x96, 0x60, 0x00) # Network: diamond / hub shape (connected nodes) # col: 0 1 2 3 4 5 6 7 # R0: . . . * . . . . # R1: . . * * * . . . # R2: . * * . * * . . # R3: * * . . . * * . # R4: . * * . * * . . # R5: . . * * * . . . # R6: . . . * . . . . # R7: . . . . . . . . $this.Symbols['Network'] = [byte[]]@(0x08, 0x1C, 0x36, 0x63, 0x36, 0x1C, 0x08, 0x00) $this.Logger.WriteDebug("Loaded $($this.Symbols.Count) symbols") } # Expand a 4-bit nibble to 8 bits by doubling each bit. # Used for 2x vertical scaling: each pixel row becomes 2 consecutive rows. # bit0 -> bits 0,1 (row 0 -> rows 0,1) # bit1 -> bits 2,3 (row 1 -> rows 2,3) # bit2 -> bits 4,5 (row 2 -> rows 4,5) # bit3 -> bits 6,7 (row 3 -> rows 6,7) hidden [byte] ExpandNibble([byte]$nibble) { [byte]$result = 0 if ($nibble -band 0x01) { $result = $result -bor 0x03 } if ($nibble -band 0x02) { $result = $result -bor 0x0C } if ($nibble -band 0x04) { $result = $result -bor 0x30 } if ($nibble -band 0x08) { $result = $result -bor 0xC0 } return $result } # Draw a named symbol at the given logical page and column. # page < (LogicalPages-1): renders 16x16 (2-page) via ExpandNibble. # page == (LogicalPages-1): renders 8x8 (1-page) as-is. [bool] DrawSymbol([string]$name, [int]$page, [int]$column) { if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } if (-not $this.Symbols.ContainsKey($name)) { $this.Logger.WriteError("Unknown symbol '$name'. Valid symbols: $($this.Symbols.Keys -join ', ')") return $false } if ($page -lt 0 -or $page -ge $this.LogicalPages) { $this.Logger.WriteError("Invalid page: $page (valid: 0-$($this.LogicalPages - 1))") return $false } $sym = [byte[]]$this.Symbols[$name] $this.Logger.WriteInfo("Drawing symbol '$name' at logical page $page, col $column (rotation: $($this.Rotation))") try { [int]$startY = $page * 8 [bool]$use16x16 = ($page -lt ($this.LogicalPages - 1)) if ($use16x16) { $this.ClearLogicalRows($startY, $startY + 15) for ($col = 0; $col -lt $sym.Count; $col++) { [byte]$b = $sym[$col] [byte]$topByte = $this.ExpandNibble($b -band 0x0F) [byte]$botByte = $this.ExpandNibble(($b -shr 4) -band 0x0F) for ($bit = 0; $bit -lt 8; $bit++) { if ($topByte -band (1 -shl $bit)) { $this.SetLogicalPixel($column + $col, $startY + $bit, $true) } if ($botByte -band (1 -shl $bit)) { $this.SetLogicalPixel($column + $col, $startY + 8 + $bit, $true) } } } return $this.FlushLogicalRows($startY, $startY + 15) } else { $this.ClearLogicalRows($startY, $startY + 7) for ($col = 0; $col -lt $sym.Count; $col++) { [byte]$b = $sym[$col] for ($bit = 0; $bit -lt 8; $bit++) { if ($b -band (1 -shl $bit)) { $this.SetLogicalPixel($column + $col, $startY + $bit, $true) } } } return $this.FlushLogicalRows($startY, $startY + 7) } } catch { $this.Logger.WriteError("DrawSymbol '$name' failed: $_") return $false } } # Write text spanning 2 logical pages (double height, 16 logical rows tall). # Only supported in landscape (0deg/180deg) orientation. # Requires page in range 0 to (LogicalPages-2) so that page+1 is valid. [bool] WriteTextTall([string]$text, [int]$page, [string]$align, [bool]$invert) { if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } if ($this.Rotation -eq 90 -or $this.Rotation -eq 270) { $this.Logger.WriteError("WriteTextTall is not supported in portrait (90/270) orientation") return $false } [int]$maxPage = $this.LogicalPages - 2 if ($page -lt 0 -or $page -gt $maxPage) { $this.Logger.WriteError("WriteTextTall: page must be 0-$maxPage (needs page+1). Got: $page") return $false } if ([string]::IsNullOrEmpty($text)) { $this.Logger.WriteDebug("WriteTextTall: empty text, nothing to write") return $true } $this.Logger.WriteInfo("WriteTextTall '$text' pages $page/$($page+1) align=$align rotation=$($this.Rotation)") try { # Build glyph list [System.Collections.Generic.List[byte[]]]$glyphList = [System.Collections.Generic.List[byte[]]]::new() [int]$totalWidth = 0 foreach ($char in $text.ToCharArray()) { $key = [string]$char [byte[]]$g = if ($this.Glyphs.ContainsKey($key)) { [byte[]]$this.Glyphs[$key] } else { [byte[]]$this.Glyphs[' '] } $glyphList.Add($g) $totalWidth += $g.Count } [int]$startX = switch ($align.ToLower()) { 'center' { [math]::Max(0, [math]::Floor(($this.LogicalWidth - $totalWidth) / 2)) } 'right' { [math]::Max(0, $this.LogicalWidth - $totalWidth) } default { 0 } } [int]$startY = $page * 8 # Clear 2 logical pages (16 rows) before rendering $this.ClearLogicalRows($startY, $startY + 15) # Render with 2x vertical scale via ExpandNibble [int]$lx = $startX foreach ($g in $glyphList) { if ($lx -ge $this.LogicalWidth) { break } for ($col = 0; $col -lt $g.Count; $col++) { [byte]$bval = $g[$col] if ($invert) { $bval = [byte]($bval -bxor 0xFF) } [byte]$topByte = $this.ExpandNibble($bval -band 0x0F) [byte]$botByte = $this.ExpandNibble(($bval -shr 4) -band 0x0F) for ($bit = 0; $bit -lt 8; $bit++) { if ($topByte -band (1 -shl $bit)) { $this.SetLogicalPixel($lx + $col, $startY + $bit, $true) } if ($botByte -band (1 -shl $bit)) { $this.SetLogicalPixel($lx + $col, $startY + 8 + $bit, $true) } } } $lx += $g.Count } return $this.FlushLogicalRows($startY, $startY + 15) } catch { $this.Logger.WriteError("WriteTextTall failed: $_") return $false } } # Draw a 2-pixel border around the entire display, render "PsGadget" centered, # flush once, hold for 3 seconds, then clear. [bool] ShowSplash() { if (-not $this.IsInitialized) { $this.Logger.WriteError("SSD1306 not initialized") return $false } $this.Logger.WriteInfo("ShowSplash: 2-px border + 'PsGadget' label") try { # Start clean [System.Array]::Clear($this.FrameBuffer, 0, $this.FrameBuffer.Length) # 2-pixel border - top and bottom rows for ($x = 0; $x -lt $this.LogicalWidth; $x++) { $this.SetLogicalPixel($x, 0, $true) $this.SetLogicalPixel($x, 1, $true) $this.SetLogicalPixel($x, $this.LogicalHeight - 2, $true) $this.SetLogicalPixel($x, $this.LogicalHeight - 1, $true) } # 2-pixel border - left and right columns (corners already set above) for ($y = 2; $y -lt ($this.LogicalHeight - 2); $y++) { $this.SetLogicalPixel(0, $y, $true) $this.SetLogicalPixel(1, $y, $true) $this.SetLogicalPixel($this.LogicalWidth - 2, $y, $true) $this.SetLogicalPixel($this.LogicalWidth - 1, $y, $true) } # "PsGadget" - always font size 2: double width (each column repeated twice) # + double height (ExpandNibble: each pixel row becomes 2 rows, 16 px tall total). # Size is independent of display height - works the same on 128x32 and 128x64. [string]$label = 'PsGadget' # textWidth at 2x: each glyph column is rendered twice side-by-side [int]$textWidth = 0 foreach ($c in $label.ToCharArray()) { $k = [string]$c [byte[]]$gw = if ($this.Glyphs.ContainsKey($k)) { [byte[]]$this.Glyphs[$k] } else { [byte[]]$this.Glyphs[' '] } $textWidth += $gw.Count * 2 } [int]$startX = [math]::Max(0, [math]::Floor(($this.LogicalWidth - $textWidth) / 2)) [int]$startY = [math]::Floor(($this.LogicalHeight - 16) / 2) [int]$lx = $startX foreach ($c in $label.ToCharArray()) { $k = [string]$c [byte[]]$g = if ($this.Glyphs.ContainsKey($k)) { [byte[]]$this.Glyphs[$k] } else { [byte[]]$this.Glyphs[' '] } for ($col = 0; $col -lt $g.Count; $col++) { [byte]$colByte = $g[$col] [byte]$topByte = $this.ExpandNibble($colByte -band 0x0F) [byte]$botByte = $this.ExpandNibble(($colByte -shr 4) -band 0x0F) # Render each column twice (double width) for ($dx = 0; $dx -lt 2; $dx++) { for ($bit = 0; $bit -lt 8; $bit++) { if ($topByte -band (1 -shl $bit)) { $this.SetLogicalPixel($lx + $col * 2 + $dx, $startY + $bit, $true) } if ($botByte -band (1 -shl $bit)) { $this.SetLogicalPixel($lx + $col * 2 + $dx, $startY + 8 + $bit, $true) } } } } $lx += $g.Count * 2 } # One bulk push - border + text in a single display update $this.FlushAll() | Out-Null Start-Sleep -Seconds 3 $this.Clear() | Out-Null return $true } catch { $this.Logger.WriteError("ShowSplash failed: $_") return $false } } # --------------------------------------------------------------------------- # Rotation management # --------------------------------------------------------------------------- # Set display rotation to 0, 90, 180, or 270 degrees. # 0/180 = landscape; 90/270 = portrait (swaps logical width and height). # Re-runs hardware initialization when the rotation actually changes. # FontSize 2 and WriteTextTall are only supported in 0/180 orientation. [void] SetRotation([int]$degrees) { if ($degrees -notin @(0, 90, 180, 270)) { throw [System.ArgumentException]::new("Invalid rotation: $degrees. Must be 0, 90, 180, or 270.") } if ($this.Rotation -eq $degrees -and $this.IsInitialized) { $this.Logger.WriteTrace("SetRotation: already at $degrees deg, no-op") return } $this.Logger.WriteInfo("Setting rotation from $($this.Rotation) to $degrees degrees") $this.Rotation = $degrees $this.UpdateLogicalDimensions() if ($this.FtdiDevice) { $this.Initialize($true) } } # --------------------------------------------------------------------------- # Private rendering infrastructure # --------------------------------------------------------------------------- # Recompute LogicalWidth, LogicalHeight, LogicalPages from Width/Height/Rotation. hidden [void] UpdateLogicalDimensions() { if ($this.Rotation -eq 90 -or $this.Rotation -eq 270) { $this.LogicalWidth = $this.Height $this.LogicalHeight = $this.Width } else { $this.LogicalWidth = $this.Width $this.LogicalHeight = $this.Height } $this.LogicalPages = [int]($this.LogicalHeight / 8) $this.Logger.WriteDebug("Logical dims: $($this.LogicalWidth)x$($this.LogicalHeight), $($this.LogicalPages) pages") } # Set or clear one physical pixel in the framebuffer. # Clips silently when px/py are outside physical bounds. hidden [void] SetPixel([int]$px, [int]$py, [bool]$on) { if ($px -lt 0 -or $px -ge $this.Width -or $py -lt 0 -or $py -ge $this.Height) { return } [int]$idx = ($py -shr 3) * $this.Width + $px [byte]$mask = [byte](1 -shl ($py -band 7)) if ($on) { $this.FrameBuffer[$idx] = [byte]($this.FrameBuffer[$idx] -bor $mask) } else { $this.FrameBuffer[$idx] = [byte]($this.FrameBuffer[$idx] -band ([byte]0xFF -bxor $mask)) } } # Map a logical (canvas) pixel to physical coordinates and write it. # Rotation mappings (phys Width=128, phys Height=64 or 32): # 0deg: px=lx, py=ly # 90deg: px=Width-1-ly, py=lx (portrait: top = original right edge) # 180deg: px=Width-1-lx, py=Height-1-ly (both axes flipped) # 270deg: px=ly, py=Height-1-lx (portrait: top = original left edge) hidden [void] SetLogicalPixel([int]$lx, [int]$ly, [bool]$on) { [int]$px = 0 [int]$py = 0 switch ($this.Rotation) { 0 { $px = $lx; $py = $ly } 90 { $px = $this.Width - 1 - $ly; $py = $lx } 180 { $px = $this.Width - 1 - $lx; $py = $this.Height - 1 - $ly } 270 { $px = $ly; $py = $this.Height - 1 - $lx } } $this.SetPixel($px, $py, $on) } # Zero framebuffer bytes that correspond to logical rows yStart..yEnd. # Fast path for 0deg/180deg (whole physical pages); column-range path for 90deg/270deg. hidden [void] ClearLogicalRows([int]$yStart, [int]$yEnd) { switch ($this.Rotation) { 0 { [int]$p0 = $yStart -shr 3 [int]$p1 = $yEnd -shr 3 for ($p = $p0; $p -le $p1; $p++) { [System.Array]::Clear($this.FrameBuffer, $p * $this.Width, $this.Width) } } 180 { # Physical pages are mirrored (logical page 0 = physical page Pages-1) [int]$p0 = $this.Pages - 1 - ($yEnd -shr 3) [int]$p1 = $this.Pages - 1 - ($yStart -shr 3) for ($p = $p0; $p -le $p1; $p++) { [System.Array]::Clear($this.FrameBuffer, $p * $this.Width, $this.Width) } } 90 { # Logical ly -> physical x = (Width-1-ly); all physical pages touched [int]$colLow = $this.Width - 1 - $yEnd [int]$colHigh = $this.Width - 1 - $yStart for ($p = 0; $p -lt $this.Pages; $p++) { for ($col = $colLow; $col -le $colHigh; $col++) { $this.FrameBuffer[$p * $this.Width + $col] = 0 } } } 270 { # Logical ly -> physical x = ly; all physical pages touched [int]$colLow = $yStart [int]$colHigh = $yEnd for ($p = 0; $p -lt $this.Pages; $p++) { for ($col = $colLow; $col -le $colHigh; $col++) { $this.FrameBuffer[$p * $this.Width + $col] = 0 } } } } } # Send one physical page from the framebuffer to the hardware. hidden [bool] FlushPhysPage([int]$physPage) { try { Write-Ssd1306Page -device $this -physPage $physPage -frameBuffer $this.FrameBuffer -width $this.Width | Out-Null return $true } catch { $this.Logger.WriteError("FlushPhysPage $physPage failed: $_") return $false } } # Send all physical pages to the hardware as a single bulk transfer. hidden [bool] FlushAll() { try { Write-Ssd1306Display -device $this -frameBuffer $this.FrameBuffer -pages $this.Pages | Out-Null return $true } catch { $this.Logger.WriteError("FlushAll failed: $_") return $false } } # Send the physical pages affected by logical rows yStart..yEnd. # 0deg/180deg: only the 1-2 physical pages that overlap; 90deg/270deg: all pages. hidden [bool] FlushLogicalRows([int]$yStart, [int]$yEnd) { switch ($this.Rotation) { 0 { [int]$p0 = $yStart -shr 3 [int]$p1 = $yEnd -shr 3 for ($p = $p0; $p -le $p1; $p++) { if (-not $this.FlushPhysPage($p)) { return $false } } } 180 { [int]$p0 = $this.Pages - 1 - ($yEnd -shr 3) [int]$p1 = $this.Pages - 1 - ($yStart -shr 3) for ($p = $p0; $p -le $p1; $p++) { if (-not $this.FlushPhysPage($p)) { return $false } } } { $_ -eq 90 -or $_ -eq 270 } { # Logical rows touch all physical pages in 90/270 orientation if (-not $this.FlushAll()) { return $false } } } return $true } } |