Public/Export-OpenTag3DPayload.ps1

function Export-OpenTag3DPayload {
<#
.SYNOPSIS
    Builds a full NTAG21x memory image from an OpenTag3D spool lookup.
 
.DESCRIPTION
    Fetches the OpenTag3D payload for a spool serial and wraps it in a complete tag dump:
    UID / BCC / static lock / capability container at the front, user memory in the middle,
    dynamic lock and configuration pages at the end.
 
    By default the payload is wrapped as an NDEF message (NDEF TLV containing a MIME record
    of type application/opentag3d, followed by a terminator TLV) so readers report the tag as
    NFC Forum Type 2 with a readable record. Use -Format Raw to place the bare payload at
    page 4 with no NDEF structure.
 
    The image is per-spool, not per-tag. Pages 0-2 carry a neutral placeholder UID: real UIDs
    are factory-programmed and read-only, and Write-OpenTag3DTag never writes them.
 
    Output is 180 bytes for NTAG213, 540 for NTAG215 and 924 for NTAG216.
 
    -Mode defaults per tag type: Core for NTAG213, Extended for NTAG215 and NTAG216. NTAG213
    has only 144 bytes of user memory, which the Extended payload cannot fit, so asking for
    Extended on an NTAG213 warns and falls back to Core.
 
.PARAMETER TagType
    Target chip. Determines user memory size, total dump size and capability container.
 
.PARAMETER Serial
    Spool serial number, e.g. 50017-FYG5. Normalised to upper case - the server rejects
    lower-case serials with HTTP 403. Accepts pipeline input.
 
.PARAMETER Mode
    Core (smaller payload) or Extended (full field set). Defaults to Core for NTAG213 and
    Extended for NTAG215/NTAG216. Extended is not possible on NTAG213. A 1.003 concept only:
    OpenTag3D 2.x is one flat block, so -Mode is ignored there.
 
.PARAMETER SpecVersion
    Which OpenTag3D layout to produce: 1.003, 2.000 or 2.001. Omit to keep whatever the lookup
    service returns, which is the lossless option and what earlier versions of this module
    did. Naming a version converts the payload if the service returned the other one; the
    conversion matches fields by id and warns about anything that cannot carry across
    (2.x's sku, barcode, nozzle_diameter and chamber_temp have no home in 1.003, and
    mfi_value is relabelled between 2.000 and 2.001).
 
    2.x cannot be written to an NTAG213 - 216 bytes of payload against 144 bytes of user
    memory - so that combination is refused.
 
.PARAMETER Format
    Ndef (default) wraps the payload in an NDEF TLV. Raw writes the unwrapped payload.
 
.PARAMETER OutputDir
    Destination folder. Defaults to the Downloads folder on Windows and the home directory on
    Linux and macOS, falling back to the current location if that path is unavailable.
 
.PARAMETER WriteToTag
    Write the image straight to a tag on a PC/SC reader instead of saving a .bin file.
    Cannot be combined with -OutputDir.
 
.PARAMETER PassThru
    Return the image bytes instead of saving or writing them. Nothing touches the disk or a
    reader. Useful for piping into Write-OpenTag3DTag -Bytes, or decoding in place.
 
.PARAMETER ReaderName
    With -WriteToTag: substring of the reader name. Defaults to the first 'ACR122' match.
 
 
.EXAMPLE
    Export-OpenTag3DPayload -TagType NTAG215 -Serial 50017-FYG5
 
    Saves 50017-FYG5-NTAG215-Extended-Ndef.bin in the current directory. -Mode defaults to
    Extended on an NTAG215, so the file holds the full field set wrapped as NDEF.
 
.EXAMPLE
    Export-OpenTag3DPayload -TagType NTAG213 -Serial 50017-FYG5 -WriteToTag
 
    Fetches the Core payload (the only one that fits an NTAG213) and writes it straight to the
    tag on the reader, with no .bin file on disk.
 
.EXAMPLE
    Export-OpenTag3DPayload -TagType NTAG216 -Serial 50017-FYG5 -Format Raw -OutputDir D:\tags
 
    Writes the unwrapped payload to page 4 instead of an NDEF message. Readers will not report
    an NDEF record; use this only for tools that parse the raw OpenTag3D bytes themselves.
 
.EXAMPLE
    '50017-FYG5','50018-ABC1' | ForEach-Object {
        Export-OpenTag3DPayload -TagType NTAG215 -Serial $_ -OutputDir D:\tags
        Start-Sleep -Seconds 3
    }
 
    Batches several spools to disk. The server rate limits back-to-back lookups with HTTP 429,
    so pace the requests; write the tags afterwards from the saved files.
 
.LINK
    https://opentag3d.info
#>

    [CmdletBinding(PositionalBinding = $false, SupportsShouldProcess, DefaultParameterSetName = 'ToFile')]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('NTAG213','NTAG215','NTAG216')]
        [string]$TagType,

        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$Serial,

        [Parameter()]
        [ValidateSet('Core','Extended')]
        [string]$Mode,

        [Parameter()]
        [ValidateSet('Ndef','Raw')]
        [string]$Format = 'Ndef',

        [Parameter()]
        [ValidateSet('1.003','2.000','2.001')]
        [string]$SpecVersion,

        [Parameter(ParameterSetName = 'ToFile')]
        [string]$OutputDir,

        [Parameter(Mandatory, ParameterSetName = 'ToTag')]
        [switch]$WriteToTag,

        [Parameter(Mandatory, ParameterSetName = 'PassThru')]
        [switch]$PassThru,

        [Parameter(ParameterSetName = 'ToTag')]
        [string]$ReaderName
    )
    process {
        # The server's checksum is case-sensitive; lower case is rejected with 403.
        $Serial = $Serial.ToUpperInvariant()

        # Default per tag type; NTAG213's 144 bytes of user memory cannot hold Extended (214).
        if (-not $PSBoundParameters.ContainsKey('Mode')) {
            $Mode = if ($TagType -eq 'NTAG213') { 'Core' } else { 'Extended' }
            Write-Verbose "Mode not specified; defaulting to $Mode for $TagType."
        }
        elseif ($TagType -eq 'NTAG213' -and $Mode -eq 'Extended') {
            Write-Warning "NTAG213 cannot hold the Extended payload; using Core instead."
            $Mode = 'Core'
        }

        $spec = Get-OpenTag3DTagSpec -TagType $TagType

        if ($PSCmdlet.ParameterSetName -eq 'ToFile') {
            if (-not $PSBoundParameters.ContainsKey('OutputDir')) {
                $OutputDir = Get-OpenTag3DDefaultOutputDir
                Write-Verbose "No -OutputDir given; using $OutputDir"
            }
            $OutputDir = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($OutputDir)
            if (-not (Test-Path -LiteralPath $OutputDir -PathType Container)) {
                throw "Output directory not found: $OutputDir"
            }
        }

        # --- fetch payload (ndef export = MIME record, bin export = bare payload) ---
        $srcFormat = if ($Format -eq 'Ndef') { 'ndef' } else { 'bin' }
        $url  = "https://pfil.us/opentag3d.php?id=$Serial&mode=$($Mode.ToLower())&format=$srcFormat"
        $temp = [IO.Path]::GetTempFileName()
        try {
            Write-Verbose "Fetching $url"
            Invoke-WebRequest $url -UseBasicParsing -OutFile $temp -ErrorAction Stop
            $data = [IO.File]::ReadAllBytes($temp)
        }
        catch {
            $detail = $_.Exception.Message
            if ($detail -match 'rate limit' -or $detail -match '429') {
                throw "Lookup for '$Serial' is rate limited by the server. Wait a few seconds and retry; add Start-Sleep between serials when batching."
            }
            if ($detail -match '403') {
                throw "Server rejected serial '$Serial' (invalid checksum). Check the serial is correct and complete."
            }
            throw "Lookup for '$Serial' failed: $detail"
        }
        finally { Remove-Item $temp -Force -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue }

        if ($data.Length -eq 0) { throw "Empty $Mode payload returned for serial '$Serial'." }

        # --- spec version: keep what the service sent, or convert to the one asked for ---
        $payload = if ($Format -eq 'Ndef') { Get-OpenTag3DRecordPayload -Record $data } else { ,[byte[]]$data }
        $served  = if ($payload) { Get-OpenTag3DPayloadVersion -Payload $payload } else { $null }
        if ($served) { Write-Verbose "Lookup returned an OpenTag3D $served payload." }

        if ($PSBoundParameters.ContainsKey('SpecVersion')) {
            if (-not $payload) {
                throw "Cannot convert to $SpecVersion - the payload the service returned could not be unwrapped."
            }
            if (-not $served) {
                throw "Cannot convert to $SpecVersion - the payload the service returned does not declare a version this module knows."
            }
            if ($served -ne $SpecVersion) {
                Write-Verbose "Converting the $served payload to $SpecVersion."
                $payload = Convert-OpenTag3DPayload -Payload $payload -ToSpecVersion $SpecVersion -FromSpecVersion $served
                $data    = if ($Format -eq 'Ndef') { New-OpenTag3DNdefRecord -Payload $payload } else { $payload }
                Write-Host "Converted the $served payload from the lookup to OpenTag3D $SpecVersion."
            }
        }

        $effective = if ($payload) { Get-OpenTag3DPayloadVersion -Payload $payload } else { $null }
        if ($TagType -eq 'NTAG213' -and $effective -and
            (Get-OpenTag3DSpec -SpecVersion $effective).Major -ge 2) {
            throw "OpenTag3D $effective cannot be written to an NTAG213 - the payload alone is $($payload.Length) bytes against 144 bytes of user memory. Use -TagType NTAG215 or NTAG216, or -SpecVersion 1.003."
        }

        $image = New-OpenTag3DImage -Data $data -TagType $TagType -Format $Format

        Write-Verbose "Payload $($data.Length) bytes -> $($image.Length)-byte $TagType image"

        if ($PSCmdlet.ParameterSetName -eq 'PassThru') {
            return ,[byte[]]$image
        }

        if ($PSCmdlet.ParameterSetName -eq 'ToTag') {
            $writeArgs = @{ Bytes = $image }
            if ($ReaderName) { $writeArgs.ReaderName = $ReaderName }
            Write-OpenTag3DTag @writeArgs
        }
        else {
            $path = Join-Path $OutputDir "$Serial-$TagType-$Mode-$Format.bin"
            if ($PSCmdlet.ShouldProcess($path, "Write $($spec.TotalSize)-byte $TagType image")) {
                Write-Host "Wrote $($spec.TotalSize) bytes to $path"
                [IO.File]::WriteAllBytes($path, $image)
            }
        }
    }
}