Class/AwtrixApp.ps1

# Base class shared by AwtrixApp and AwtrixNotification.
# Classes defined in ScriptsToProcess run outside the module scope, so they
# cannot call InvokeAwtrixApi directly. The module wires up the static delegate
# [AwtrixAppBase]::InvokeApi during module load (see awtrix.psm1).
class AwtrixAppBase {

    # ── Shared API properties ────────────────────────────────────────────────

    # Text to display. String or array of colored fragment hashtables.
    $Text

    # 0 = global setting, 1 = force uppercase, 2 = show as sent.
    [ValidateRange(0, 2)]
    [System.Nullable[int]] $TextCase

    # Draw text on top of display.
    [System.Nullable[bool]] $TopText

    # X-axis offset for the starting text position.
    [System.Nullable[int]] $TextOffset

    # Centers a short, non-scrollable text.
    [System.Nullable[bool]] $Center

    # Text/bar/line color. Hex string or RGB array.
    $Color

    # Two-color gradient for text. Array of two color values.
    $Gradient

    # Blink interval in milliseconds. Not compatible with gradient/rainbow.
    [System.Nullable[int]] $BlinkTextMilliseconds

    # Fade-on/off interval in milliseconds. Not compatible with gradient/rainbow.
    [System.Nullable[int]] $FadeTextMilliseconds

    # Background color. Hex string or RGB array.
    $Background

    # Fade each letter through the full RGB spectrum.
    [System.Nullable[bool]] $Rainbow

    # Icon ID, filename (no extension), or Base64-encoded 8×8 JPG.
    [string] $Icon

    # 0 = static, 1 = moves with text once, 2 = moves with text repeatedly.
    [ValidateRange(0, 2)]
    [System.Nullable[int]] $PushIcon

    # Scroll count before app/notification ends. -1 = indefinite.
    [System.Nullable[int]] $Repeat

    # Display duration in seconds.
    [System.Nullable[int]] $DurationSeconds

    # Disables text scrolling.
    [System.Nullable[bool]] $NoScroll

    # Scroll speed as a percentage of normal speed (e.g. 200 = 2×).
    [System.Nullable[int]] $ScrollSpeed

    # Background effect name. Empty string clears an existing effect.
    [string] $Effect

    # Color/speed overrides for the background effect.
    [hashtable] $EffectSettings

    # Bar chart data. Max 16 values without icon, 11 with icon.
    [int[]] $Bar

    # Line chart data. Max 16 values without icon, 11 with icon.
    [int[]] $Line

    # Auto-scale bar/line chart axes.
    [System.Nullable[bool]] $Autoscale

    # Background color of bar chart bars.
    $BarBackgroundColor

    # Progress bar value 0-100. -1 disables.
    [ValidateRange(-1, 100)]
    [System.Nullable[int]] $Progress

    # Progress bar foreground color.
    $ProgressColor

    # Progress bar background color.
    $ProgressBackgroundColor

    # Drawing instructions. Use New-AwtrixDrawing to build entries.
    [array] $Draw

    # Effect overlay: clear, snow, rain, drizzle, storm, thunder, frost.
    [ValidateSet('', 'clear', 'snow', 'rain', 'drizzle', 'storm', 'thunder', 'frost')]
    [string] $Overlay

    # ── Module API delegate (set by awtrix.psm1 during module load) ──────────
    static [scriptblock] $InvokeApi

    # Per-object BaseUri override. $null = use module-level connection.
    hidden [string] $_baseUri

    # Payload snapshot taken after last Push/Send, used for dirty tracking.
    hidden [hashtable] $_lastPushed

    # ── Canonical property → API-key mapping ────────────────────────────────
    static [hashtable] GetPropertyMap() {
        return @{
            Text = 'text'
            TextCase = 'textCase'
            TopText = 'topText'
            TextOffset = 'textOffset'
            Center = 'center'
            Color = 'color'
            Gradient = 'gradient'
            BlinkTextMilliseconds = 'blinkText'
            FadeTextMilliseconds = 'fadeText'
            Background = 'background'
            Rainbow = 'rainbow'
            Icon = 'icon'
            PushIcon = 'pushIcon'
            Repeat = 'repeat'
            DurationSeconds = 'duration'
            NoScroll = 'noScroll'
            ScrollSpeed = 'scrollSpeed'
            Effect = 'effect'
            EffectSettings = 'effectSettings'
            Bar = 'bar'
            Line = 'line'
            Autoscale = 'autoscale'
            BarBackgroundColor = 'barBC'
            Progress = 'progress'
            ProgressColor = 'progressC'
            ProgressBackgroundColor = 'progressBC'
            Draw = 'draw'
            Overlay = 'overlay'
        }
    }

    # ── ToPayload ────────────────────────────────────────────────────────────
    # Returns an API-ready hashtable containing only properties with non-null,
    # non-empty values so we never send noise to the device.
    [hashtable] ToPayload() {
        $map = [AwtrixAppBase]::GetPropertyMap()
        $body = @{}
        foreach ($prop in $map.Keys) {
            $val = $this.$prop
            if ($null -eq $val) { continue }
            # Skip empty strings unless the property is Effect (empty string is
            # a documented signal meaning "remove the current effect").
            if ($val -is [string] -and $val -eq '' -and $prop -ne 'Effect') { continue }
            # Skip arrays and hashtables that are empty.
            if ($val -is [array] -and $val.Count -eq 0) { continue }
            if ($val -is [hashtable] -and $val.Count -eq 0) { continue }
            $body[$map[$prop]] = $val
        }
        return $body
    }

    # ── ToJson ───────────────────────────────────────────────────────────────
    [string] ToJson() {
        return $this.ToPayload() | ConvertTo-Json -Depth 10 -Compress
    }

    # ── Dirty Tracking ───────────────────────────────────────────────────────
    # Snapshot current payload, marking it as "clean".
    [void] ResetDirtyState() {
        $this._lastPushed = $this.ToPayload()
    }

    # Returns only the keys whose values differ from the last push snapshot.
    # On first call (no snapshot), returns the full payload.
    [hashtable] GetDirtyPayload() {
        if ($null -eq $this._lastPushed) {
            return $this.ToPayload()
        }
        $current = $this.ToPayload()
        $dirty = @{}
        # Keys present in current that are new or changed.
        foreach ($key in $current.Keys) {
            $prev = $this._lastPushed[$key]
            $curr = $current[$key]
            if ($null -eq $prev) {
                $dirty[$key] = $curr
            } elseif ($curr -is [array] -or $curr -is [hashtable]) {
                # Deep compare via JSON serialization.
                if (($curr | ConvertTo-Json -Depth 10 -Compress) -ne
                    ($prev | ConvertTo-Json -Depth 10 -Compress)) {
                    $dirty[$key] = $curr
                }
            } elseif ($curr -ne $prev) {
                $dirty[$key] = $curr
            }
        }
        # Keys that existed before but are now absent (set to null/empty) — send
        # the current (absent) value so callers can detect the removal if needed.
        # For simplicity we leave removed keys out; callers can always do a full push.
        return $dirty
    }

    # ── Clone (base - subclasses override to carry their extra properties) ──
    [AwtrixAppBase] CloneBase() {
        $copy = [AwtrixAppBase]::new()
        $copy._baseUri = $this._baseUri
        foreach ($prop in [AwtrixAppBase]::GetPropertyMap().Keys) {
            # Guard against null to avoid [ValidateRange] rejecting null assignments.
            if ($null -ne $this.$prop) { $copy.$prop = $this.$prop }
        }
        return $copy
    }
}

# ── AwtrixApp ────────────────────────────────────────────────────────────────
# Represents a persistent custom app in the AWTRIX display loop.
class AwtrixApp : AwtrixAppBase {

    # Unique app name used to identify and update the app.
    [string] $Name

    # Remove app after no update within this many seconds. 0 = disabled.
    [System.Nullable[int]] $LifetimeSeconds

    # 0 = delete app on expiry, 1 = mark as stale with red border.
    [ValidateRange(0, 1)]
    [System.Nullable[int]] $LifetimeMode

    # Loop position (0-based). Only applied on first push. Experimental.
    [System.Nullable[int]] $Position

    # Persist app across reboots. Avoid for high-frequency updates.
    [System.Nullable[bool]] $Save

    # ── Constructors ─────────────────────────────────────────────────────────
    AwtrixApp() {}

    AwtrixApp([string]$name) {
        $this.Name = $name
    }

    # ── ToPayload override — merges app-only fields ──────────────────────────
    [hashtable] ToPayload() {
        $body = ([AwtrixAppBase]$this).ToPayload()
        if ($null -ne $this.LifetimeSeconds) { $body['lifetime'] = $this.LifetimeSeconds }
        if ($null -ne $this.LifetimeMode) { $body['lifetimeMode'] = $this.LifetimeMode }
        if ($null -ne $this.Position) { $body['pos'] = $this.Position }
        if ($null -ne $this.Save) { $body['save'] = $this.Save }
        return $body
    }

    # ── Push — sends the full payload (or only dirty keys) to the device ─────
    [void] Push() {
        $this.Push($false)
    }

    [void] Push([bool]$dirtyOnly) {
        if ([string]::IsNullOrWhiteSpace($this.Name)) {
            throw 'AwtrixApp.Name must be set before calling Push().'
        }
        $body = if ($dirtyOnly) { $this.GetDirtyPayload() } else { $this.ToPayload() }
        & ([AwtrixAppBase]::InvokeApi) -Endpoint 'custom' -Method POST -Body $body `
            -QueryString "name=$($this.Name)" -BaseUri $this._baseUri
        $this.ResetDirtyState()
    }

    # ── Remove — deletes the app from the device ─────────────────────────────
    [void] Remove() {
        if ([string]::IsNullOrWhiteSpace($this.Name)) {
            throw 'AwtrixApp.Name must be set before calling Remove().'
        }
        & ([AwtrixAppBase]::InvokeApi) -Endpoint 'custom' -Method POST `
            -QueryString "name=$($this.Name)" -BaseUri $this._baseUri
    }

    # ── SwitchTo — navigates the device to this app ──────────────────────────
    [void] SwitchTo() {
        if ([string]::IsNullOrWhiteSpace($this.Name)) {
            throw 'AwtrixApp.Name must be set before calling SwitchTo().'
        }
        & ([AwtrixAppBase]::InvokeApi) -Endpoint 'switch' -Method POST `
            -Body @{ name = $this.Name } -BaseUri $this._baseUri
    }

    # ── Clone ────────────────────────────────────────────────────────────────
    [AwtrixApp] Clone() {
        return $this.Clone($this.Name)
    }

    [AwtrixApp] Clone([string]$newName) {
        $copy = [AwtrixApp]::new($newName)
        $copy._baseUri = $this._baseUri
        # Guard against null to avoid [ValidateRange] rejecting null assignments.
        if ($null -ne $this.LifetimeSeconds) { $copy.LifetimeSeconds = $this.LifetimeSeconds }
        if ($null -ne $this.LifetimeMode) { $copy.LifetimeMode = $this.LifetimeMode }
        if ($null -ne $this.Position) { $copy.Position = $this.Position }
        if ($null -ne $this.Save) { $copy.Save = $this.Save }
        foreach ($prop in [AwtrixAppBase]::GetPropertyMap().Keys) {
            if ($null -ne $this.$prop) { $copy.$prop = $this.$prop }
        }
        return $copy
    }

    # ── Serialization ────────────────────────────────────────────────────────
    [string] ToJson() {
        $payload = $this.ToPayload()
        $payload['_name'] = $this.Name
        if ($null -ne $this._baseUri) { $payload['_baseUri'] = $this._baseUri }
        return $payload | ConvertTo-Json -Depth 10 -Compress
    }

    static [AwtrixApp] FromJson([string]$json) {
        $data = $json | ConvertFrom-Json -AsHashtable
        $reverseMap = @{}
        foreach ($kv in [AwtrixAppBase]::GetPropertyMap().GetEnumerator()) {
            $reverseMap[$kv.Value] = $kv.Key
        }
        # App-only reverse mapping
        $appKeys = @{ lifetime = 'LifetimeSeconds'; lifetimeMode = 'LifetimeMode'
            pos = 'Position'; save = 'Save'
        }

        $app = [AwtrixApp]::new()
        foreach ($key in $data.Keys) {
            if ($key -eq '_name') { $app.Name = $data[$key]; continue }
            if ($key -eq '_baseUri') { $app._baseUri = $data[$key]; continue }
            if ($reverseMap.ContainsKey($key)) { $app.($reverseMap[$key]) = $data[$key]; continue }
            if ($appKeys.ContainsKey($key)) { $app.($appKeys[$key]) = $data[$key]; continue }
        }
        return $app
    }
}

# ── AwtrixNotification ───────────────────────────────────────────────────────
# Represents a one-time notification that interrupts the app loop.
class AwtrixNotification : AwtrixAppBase {

    # Hold notification until middle-button press or API dismiss.
    [System.Nullable[bool]] $Hold

    # RTTTL ringtone filename (no extension) or DFplayer 4-digit MP3 number.
    [string] $Sound

    # Inline RTTTL sound string played with the notification.
    [string] $Rtttl

    # Loop sound/RTTTL for the duration of the notification.
    [System.Nullable[bool]] $LoopSound

    # Stack notification (true) or replace current notification (false).
    [System.Nullable[bool]] $Stack

    # Wake the matrix for this notification if the display is off.
    [System.Nullable[bool]] $Wakeup

    # Forward notification to additional AWTRIX devices by IP.
    [string[]] $Clients

    # ── Constructors ─────────────────────────────────────────────────────────
    AwtrixNotification() {}

    AwtrixNotification([string]$text) {
        $this.Text = $text
    }

    # ── ToPayload override — merges notification-only fields ─────────────────
    [hashtable] ToPayload() {
        $body = ([AwtrixAppBase]$this).ToPayload()
        if ($null -ne $this.Hold) { $body['hold'] = $this.Hold }
        if ($null -ne $this.LoopSound) { $body['loopSound'] = $this.LoopSound }
        if ($null -ne $this.Stack) { $body['stack'] = $this.Stack }
        if ($null -ne $this.Wakeup) { $body['wakeup'] = $this.Wakeup }
        if (-not [string]::IsNullOrEmpty($this.Sound)) { $body['sound'] = $this.Sound }
        if (-not [string]::IsNullOrEmpty($this.Rtttl)) { $body['rtttl'] = $this.Rtttl }
        if ($null -ne $this.Clients -and $this.Clients.Count -gt 0) {
            $body['clients'] = $this.Clients
        }
        return $body
    }

    # ── Send — dispatches the notification to the device ─────────────────────
    [void] Send() {
        $body = $this.ToPayload()
        & ([AwtrixAppBase]::InvokeApi) -Endpoint 'notify' -Method POST `
            -Body $body -BaseUri $this._baseUri
        $this.ResetDirtyState()
    }

    # ── Clone ────────────────────────────────────────────────────────────────
    [AwtrixNotification] Clone() {
        $copy = [AwtrixNotification]::new()
        $copy._baseUri = $this._baseUri
        $copy.Hold = $this.Hold
        $copy.Sound = $this.Sound
        $copy.Rtttl = $this.Rtttl
        $copy.LoopSound = $this.LoopSound
        $copy.Stack = $this.Stack
        $copy.Wakeup = $this.Wakeup
        $copy.Clients = $this.Clients
        foreach ($prop in [AwtrixAppBase]::GetPropertyMap().Keys) {
            if ($this.$prop -ne $null) {
                $copy.$prop = $this.$prop
            }
        }
        return $copy
    }

    # ── Serialization ────────────────────────────────────────────────────────
    [string] ToJson() {
        $payload = $this.ToPayload()
        if ($null -ne $this._baseUri) { $payload['_baseUri'] = $this._baseUri }
        return $payload | ConvertTo-Json -Depth 10 -Compress
    }

    static [AwtrixNotification] FromJson([string]$json) {
        $data = $json | ConvertFrom-Json -AsHashtable
        $reverseMap = @{}
        foreach ($kv in [AwtrixAppBase]::GetPropertyMap().GetEnumerator()) {
            $reverseMap[$kv.Value] = $kv.Key
        }
        $notifKeys = @{ hold = 'Hold'; sound = 'Sound'; rtttl = 'Rtttl'
            loopSound = 'LoopSound'; stack = 'Stack'
            wakeup = 'Wakeup'; clients = 'Clients'
        }

        $notif = [AwtrixNotification]::new()
        foreach ($key in $data.Keys) {
            if ($key -eq '_baseUri') { $notif._baseUri = $data[$key]; continue }
            if ($reverseMap.ContainsKey($key)) { $notif.($reverseMap[$key]) = $data[$key]; continue }
            if ($notifKeys.ContainsKey($key)) { $notif.($notifKeys[$key]) = $data[$key]; continue }
        }
        return $notif
    }
}