Private/OpenTag3DGeneric.ps1

# Building an OpenTag3D payload from hand-entered values, with no server lookup.
#
# The GUI's "New (generic vendor)" source calls this. It is also the seam a public
# New-OpenTag3DTag cmdlet would plug into: Get-OpenTag3DFieldMap gives the parameter
# name for every spec field, and ConvertTo-OpenTag3DValueTable turns a $PSBoundParameters
# hashtable into the value table New-OpenTag3DGenericPayload expects.
#
# Everything here takes a -SpecVersion. Sizes, field sets and section names all differ
# between 1.003 and 2.000, so nothing may assume one layout.

# Only the tag version is fixed on a hand-built tag: it describes the format, not the
# filament. The serial is the vendor's own batch id here, so unlike a Polar lookup - where
# it is the key the data came from - it is free to edit.
$script:OpenTag3DGenericReadOnly = @('tag_version')

# Cmdlet parameter names are derived from the field id (color_name -> ColorName); these
# would read badly derived, so they are named explicitly.
$script:OpenTag3DParamOverride = @{
    'td'              = 'TransmissionDistance'
    'data_url'        = 'DataUrl'
    'mfg_date'        = 'ManufactureDate'
    'mfg_time'        = 'ManufactureTime'
    'min_vso'         = 'MinVolumetricSpeed'
    'max_vso'         = 'MaxVolumetricSpeed'
    'target_vso'      = 'TargetVolumetricSpeed'
    'mfi_temp'        = 'MfiTemp'
    'mfi_load'        = 'MfiLoad'
    'mfi_value'       = 'MfiValue'
    'sku'             = 'Sku'
    'nozzle_diameter' = 'NozzleDiameter'
}

function ConvertTo-OpenTag3DParamName {
    <#
    .SYNOPSIS
        Parameter name for a spec field id: 'color_name' -> 'ColorName'.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)] [string]$Id)

    if ($script:OpenTag3DParamOverride.ContainsKey($Id)) { return $script:OpenTag3DParamOverride[$Id] }

    return (($Id -split '_' | ForEach-Object {
        if ($_.Length -eq 0) { '' } else { $_.Substring(0,1).ToUpperInvariant() + $_.Substring(1) }
    }) -join '')
}

function Get-OpenTag3DFieldMap {
    <#
    .SYNOPSIS
        Every field of one spec version as parameter name, id, section and type.
    .DESCRIPTION
        The bridge between a cmdlet's named parameters and the payload encoder. Ordered as
        the spec lays the fields out, so it also drives generated help.
    #>

    [CmdletBinding()]
    param([Parameter()] [string]$SpecVersion)

    foreach ($f in (Get-OpenTag3DFieldTable -SpecVersion $SpecVersion)) {
        [pscustomobject]@{
            Parameter = ConvertTo-OpenTag3DParamName -Id $f.Id
            Id        = $f.Id
            Name      = $f.Name
            Type      = $f.Type
            Unit      = if ($f.Unit) { $f.Unit } else { '' }
            Section   = Get-OpenTag3DFieldSection -Field $f
            Required  = [bool]$f.Required
            ReadOnly  = ($f.Id -in $script:OpenTag3DGenericReadOnly)
        }
    }
}

function ConvertTo-OpenTag3DValueTable {
    <#
    .SYNOPSIS
        Turns bound cmdlet parameters into a value table keyed by spec field id.
    .DESCRIPTION
        Pass $PSBoundParameters. Anything that is not a field parameter - TagType, Format,
        OutputDir and so on - is ignored, so the caller does not have to filter first.
    .PARAMETER Parameter
        Parameter name to value, e.g. @{ PrintTemp = '215 C'; Manufacturer = 'Acme' }.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable]$Parameter,
        [Parameter()] [string]$SpecVersion
    )

    $byName = @{}
    foreach ($f in (Get-OpenTag3DFieldMap -SpecVersion $SpecVersion)) { $byName[$f.Parameter] = $f.Id }

    $values = @{}
    foreach ($key in $Parameter.Keys) {
        if (-not $byName.ContainsKey($key)) { continue }
        $v = $Parameter[$key]
        if ($null -eq $v) { continue }
        $values[$byName[$key]] = "$v"
    }
    return $values
}

function Get-OpenTag3DPayloadSize {
    <#
    .SYNOPSIS
        Payload length for a spec version, and for 1.003 a mode.
    .DESCRIPTION
        1.003 has a Core block (0x00-0x6F) and an Extended block on top (to 0xBA). 2.000 is
        one flat block, so -Mode is ignored there.
    #>

    [CmdletBinding()]
    param(
        [Parameter()] [ValidateSet('Core','Extended')] [string]$Mode = 'Extended',
        [Parameter()] [string]$SpecVersion
    )

    $spec = Get-OpenTag3DSpec -SpecVersion $SpecVersion
    if ($spec.HasModes -and $Mode -eq 'Core') { return $spec.CoreSize }
    return $spec.FullSize
}

function New-OpenTag3DBlankPayload {
    <#
    .SYNOPSIS
        A zero-filled payload of the right size, with the tag version stamped.
    .DESCRIPTION
        Every other field reads back as empty or zero, which the spec treats as "not
        supplied" - so a blank payload is a valid starting point to overwrite selectively.
    #>

    [CmdletBinding()]
    param(
        [Parameter()] [ValidateSet('Core','Extended')] [string]$Mode = 'Extended',
        [Parameter()] [string]$SpecVersion
    )

    $spec    = Get-OpenTag3DSpec -SpecVersion $SpecVersion
    $payload = [byte[]]::new((Get-OpenTag3DPayloadSize -Mode $Mode -SpecVersion $spec.Version))
    $payload[0] = [byte](([int]$spec.Raw -shr 8) -band 0xFF)
    $payload[1] = [byte]($spec.Raw -band 0xFF)
    return ,[byte[]]$payload
}

function New-OpenTag3DGenericPayload {
    <#
    .SYNOPSIS
        Builds an OpenTag3D payload from hand-entered values, with no lookup.
    .DESCRIPTION
        Values are keyed by spec field id and given in the same display form the parser
        produces: '1.75 mm', '215 C', '#14ADDB', '2026-04-03', '10:19:33'. Anything not
        supplied is left zeroed.
 
        The tag version is always stamped by the module, so a value supplied for it is
        ignored - it describes the format, not the filament.
    .PARAMETER Values
        Field id to value, e.g. @{ manufacturer = 'Acme'; material = 'PLA' }.
    .PARAMETER Mode
        1.003 only: Core (0x00-0x6F) or Extended (0x00-0xBA). Ignored for 2.000.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable]$Values,
        [Parameter()] [ValidateSet('Core','Extended')] [string]$Mode = 'Extended',
        [Parameter()] [string]$SpecVersion
    )

    $spec = Get-OpenTag3DSpec -SpecVersion $SpecVersion

    $clean = @{}
    foreach ($key in $Values.Keys) {
        if ($key -in $script:OpenTag3DGenericReadOnly) { continue }
        $v = $Values[$key]
        if ($null -eq $v -or "$v".Trim().Length -eq 0) { continue }   # unset stays zeroed
        $clean[$key] = "$v"
    }

    $base = New-OpenTag3DBlankPayload -Mode $Mode -SpecVersion $spec.Version
    return ,[byte[]](ConvertTo-OpenTag3DPayload -BasePayload $base -Values $clean -SpecVersion $spec.Version)
}

function Get-OpenTag3DEditableField {
    <#
    .SYNOPSIS
        Turns a payload into the field list the GUI editor renders.
    .DESCRIPTION
        Shared by every source the editor loads from - a serial lookup, a tag, a blank form
        or a saved profile - so all sources render and validate identically. The layout comes
        from the payload's own version bytes unless -SpecVersion overrides it.
 
        Fields past the end of the payload are left out rather than shown as empty: a Core
        payload has nowhere to put them, and an editable box that silently discards what you
        type is worse than no box at all.
 
        Rows come back grouped in the order the version wants them - Core then Extended for
        1.003, Display / Inventory / Operational for 2.000.
    .PARAMETER AllowSerial
        Let the serial be edited. True for a hand-built tag, where the serial is the vendor's
        own batch id; false for a Polar lookup, where it is the key the data came from.
    .PARAMETER BlankUnset
        Show an all-zero number or time as empty rather than '0' or '00:00:00'. On a hand-built
        tag every unset field reads back that way, and a form pre-filled with meaningless zeros
        invites writing them to the tag. Zero and empty encode identically, so nothing is lost.
        Off for data read from a lookup or a tag, where a stored zero is what the tag says.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Payload,
        [Parameter()] [string]$SpecVersion,
        [switch]$AllowSerial,
        [switch]$BlankUnset
    )

    if (-not $SpecVersion) {
        $SpecVersion = Get-OpenTag3DPayloadVersion -Payload $Payload
        if (-not $SpecVersion) { $SpecVersion = $script:OpenTag3DDefaultSpecVersion }
    }
    $spec = Get-OpenTag3DSpec -SpecVersion $SpecVersion

    $decoded  = ConvertFrom-OpenTag3DPayload -Payload $Payload -SpecVersion $spec.Version
    $readOnly = if ($AllowSerial) { $script:OpenTag3DGenericReadOnly } else { $script:OpenTag3DReadOnly }

    $rows = foreach ($f in $spec.Fields) {
        if ($f.Start + $f.Length -gt $Payload.Length) { continue }

        $row   = $decoded.Fields | Where-Object Id -eq $f.Id
        $value = if ($row) { "$($row.Value)" } else { '' }
        if ($BlankUnset) {
            $raw = $decoded.($f.Id)
            if ($f.Type -eq 'int'  -and $raw -eq 0)          { $value = '' }
            if ($f.Type -eq 'time' -and $raw -eq '00:00:00') { $value = '' }
        }

        $section = Get-OpenTag3DFieldSection -Field $f
        @{
            id       = $f.Id
            name     = $f.Name
            value    = $value
            section  = $section
            type     = $f.Type
            unit     = if ($f.Unit) { $f.Unit } else { '' }
            required = [bool]$f.Required
            readonly = ($f.Id -in $readOnly)
        }
    }

    # Group in the version's own order; 1.003 is already in address order, 2.000 interleaves
    # its usage groups so they need gathering. Done by hand rather than with Sort-Object,
    # which is not a stable sort on Windows PowerShell and would scramble address order
    # inside each group.
    $all = @($rows)
    $ordered = foreach ($g in $spec.GroupOrder) { $all | Where-Object { $_.section -eq $g } }
    $ordered += $all | Where-Object { $_.section -notin $spec.GroupOrder }
    return @($ordered)
}