RadioCodeCalculator.psm1

################################################################################
#
# Radio Code Calculator Web API client for PowerShell.
# Generate radio unlocking codes for various car radio players.
#
# Version : PowerShell SDK v1.0.0
# PowerShell : Windows PowerShell 5.1 / PowerShell 7+
# Author : Bartosz Wójcik (support@pelock.com)
# Project : https://www.pelock.com/products/radio-code-calculator
# Homepage : https://www.pelock.com
#
################################################################################

Set-StrictMode -Version Latest

if ($PSVersionTable.PSVersion.Major -lt 6) {
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}

$script:RadioApiUrl = 'https://www.pelock.com/api/radio-code-calculator/v1'
$script:RadioUserAgent = 'PELock Radio Code Calculator'
$script:RadioTimeoutSec = 3600

class RadioErrors {
    static [int] $ERROR_CONNECTION = -1
    static [int] $SUCCESS = 0
    static [int] $INVALID_INPUT = 1
    static [int] $INVALID_COMMAND = 2
    static [int] $INVALID_RADIO_MODEL = 3
    static [int] $INVALID_SERIAL_LENGTH = 4
    static [int] $INVALID_SERIAL_PATTERN = 5
    static [int] $INVALID_SERIAL_NOT_SUPPORTED = 6
    static [int] $INVALID_EXTRA_LENGTH = 7
    static [int] $INVALID_EXTRA_PATTERN = 8
    static [int] $INVALID_LICENSE = 100
}

function ConvertTo-RadioRegex {
    param($Pattern)

    if ($null -eq $Pattern -or [string]::IsNullOrEmpty([string]$Pattern)) {
        return $null
    }

    $text = [string]$Pattern
    if ($text.Length -ge 2 -and $text[0] -eq [char]'/' -and $text.LastIndexOf('/') -gt 0) {
        $last = $text.LastIndexOf('/')
        return $text.Substring(1, $last - 1)
    }

    return $text
}

function ConvertTo-RadioFormBody {
    param([Parameter(Mandatory)] [hashtable]$Fields)

    $parts = foreach ($key in $Fields.Keys) {
        $name = [uri]::EscapeDataString([string]$key)
        $value = [uri]::EscapeDataString([string]$Fields[$key])
        '{0}={1}' -f $name, $value
    }

    return ($parts -join '&')
}

function ConvertFrom-RadioResult {
    param($Result)

    if ($null -eq $Result -or $Result -eq $false) {
        return $null
    }

    if ($Result -is [hashtable]) {
        return $Result
    }

    $table = @{}
    foreach ($property in $Result.PSObject.Properties) {
        $table[$property.Name] = $property.Value
    }

    return $table
}

class RadioModel {
    [string] $name = ''
    [int] $serial_max_len = 0
    hidden [hashtable] $_serial_regex_patterns = @{}
    [int] $extra_max_len = 0
    hidden [hashtable] $_extra_regex_patterns = $null
    [string] $default_programming_language = 'php'

    RadioModel([string]$Name, [int]$SerialMaxLen, $SerialRegexPattern) {
        $this.Initialize($Name, $SerialMaxLen, $SerialRegexPattern, 0, $null)
    }

    RadioModel([string]$Name, [int]$SerialMaxLen, $SerialRegexPattern, [int]$ExtraMaxLen) {
        $this.Initialize($Name, $SerialMaxLen, $SerialRegexPattern, $ExtraMaxLen, $null)
    }

    RadioModel([string]$Name, [int]$SerialMaxLen, $SerialRegexPattern, [int]$ExtraMaxLen, $ExtraRegexPattern) {
        $this.Initialize($Name, $SerialMaxLen, $SerialRegexPattern, $ExtraMaxLen, $ExtraRegexPattern)
    }

    hidden [void] Initialize([string]$Name, [int]$SerialMaxLen, $SerialRegexPattern, [int]$ExtraMaxLen, $ExtraRegexPattern) {
        $this.name = $Name
        $this.serial_max_len = $SerialMaxLen
        $this._serial_regex_patterns = @{}
        $this.default_programming_language = 'php'

        if ($SerialRegexPattern -is [string]) {
            $this._serial_regex_patterns[$this.default_programming_language] = $SerialRegexPattern
        }
        elseif ($SerialRegexPattern -is [hashtable]) {
            $this._serial_regex_patterns = $SerialRegexPattern
        }
        elseif ($null -ne $SerialRegexPattern) {
            $map = @{}
            foreach ($property in $SerialRegexPattern.PSObject.Properties) {
                $map[$property.Name] = $property.Value
            }
            $this._serial_regex_patterns = $map
        }

        $this.extra_max_len = $ExtraMaxLen
        $this._extra_regex_patterns = $null

        if ($ExtraMaxLen -ne 0 -and $null -ne $ExtraRegexPattern) {
            if ($ExtraRegexPattern -is [string]) {
                $this._extra_regex_patterns = @{ $this.default_programming_language = $ExtraRegexPattern }
            }
            elseif ($ExtraRegexPattern -is [hashtable]) {
                $this._extra_regex_patterns = $ExtraRegexPattern
            }
            else {
                $map = @{}
                foreach ($property in $ExtraRegexPattern.PSObject.Properties) {
                    $map[$property.Name] = $property.Value
                }
                $this._extra_regex_patterns = $map
            }
        }
    }

    [string] serial_regex_pattern() {
        if (-not $this._serial_regex_patterns.ContainsKey($this.default_programming_language)) {
            return ''
        }
        return [string]$this._serial_regex_patterns[$this.default_programming_language]
    }

    [string] extra_regex_pattern() {
        if ($null -eq $this._extra_regex_patterns) {
            return $null
        }
        if (-not $this._extra_regex_patterns.ContainsKey($this.default_programming_language)) {
            return $null
        }
        return [string]$this._extra_regex_patterns[$this.default_programming_language]
    }

    [int] validate([string]$Serial) {
        return $this.validate($Serial, $null)
    }

    [int] validate([string]$Serial, [string]$Extra) {
        if ($Serial.Length -ne $this.serial_max_len) {
            return [RadioErrors]::INVALID_SERIAL_LENGTH
        }

        $serialPattern = ConvertTo-RadioRegex -Pattern $this.serial_regex_pattern()
        if ($serialPattern -and $Serial -notmatch $serialPattern) {
            return [RadioErrors]::INVALID_SERIAL_PATTERN
        }

        if ($null -ne $Extra -and $Extra.Length -gt 0) {
            if ($Extra.Length -ne $this.extra_max_len) {
                return [RadioErrors]::INVALID_EXTRA_LENGTH
            }
            $extraPattern = ConvertTo-RadioRegex -Pattern $this.extra_regex_pattern()
            if ($extraPattern -and $Extra -notmatch $extraPattern) {
                return [RadioErrors]::INVALID_EXTRA_PATTERN
            }
        }

        return [RadioErrors]::SUCCESS
    }
}

class RadioModels {
    static [object] $RENAULT_DACIA
    static [object] $CHRYSLER_PANASONIC_TM9
    static [object] $CHRYSLER_DODGE_VP
    static [object] $FORD_M_SERIES
    static [object] $FORD_V_SERIES
    static [object] $FORD_TRAVELPILOT
    static [object] $FIAT_STILO_BRAVO_VISTEON
    static [object] $FIAT_DAIICHI
    static [object] $FIAT_VP
    static [object] $TOYOTA_ERC
    static [object] $JEEP_CHEROKEE
    static [object] $NISSAN_GLOVE_BOX
    static [object] $ECLIPSE_ESN
    static [object] $JAGUAR_ALPINE

    static [RadioModel] Get([object]$RadioModelParams) {
        $arr = @($RadioModelParams)
        $extraLen = 0
        $extraPat = $null
        if ($arr.Count -gt 3) {
            $extraLen = [int]$arr[3]
        }
        if ($arr.Count -gt 4) {
            $extraPat = $arr[4]
        }
        return [RadioModel]::new([string]$arr[0], [int]$arr[1], $arr[2], $extraLen, $extraPat)
    }
}

[RadioModels]::RENAULT_DACIA = @('renault-dacia', 4, '^([A-Z]{1}[0-9]{3})$')
[RadioModels]::CHRYSLER_PANASONIC_TM9 = @('chrysler-panasonic-tm9', 4, '^([0-9]{4})$')
[RadioModels]::CHRYSLER_DODGE_VP = @('chrysler-dodge-vp', 4, '^([a-zA-Z0-9]{4})$')
[RadioModels]::FORD_M_SERIES = @('ford-m-series', 6, '^([0-9]{6})$')
[RadioModels]::FORD_V_SERIES = @('ford-v-series', 6, '^([0-9]{6})$')
[RadioModels]::FORD_TRAVELPILOT = @('ford-travelpilot', 7, '^([0-9]{7})$')
[RadioModels]::FIAT_STILO_BRAVO_VISTEON = @('fiat-stilo-bravo-visteon', 6, '^([a-zA-Z0-9]{6})$')
[RadioModels]::FIAT_DAIICHI = @('fiat-daiichi', 4, '^([0-9]{4})$')
[RadioModels]::FIAT_VP = @('fiat-vp', 4, '^([0-9]{4})$')
[RadioModels]::TOYOTA_ERC = @('toyota-erc', 16, '^([a-zA-Z0-9]{16})$')
[RadioModels]::JEEP_CHEROKEE = @('jeep-cherokee', 14, '^([a-zA-Z0-9]{10}[0-9]{4})$')
[RadioModels]::NISSAN_GLOVE_BOX = @('nissan-glove-box', 12, '^([a-zA-Z0-9]{12})$')
[RadioModels]::ECLIPSE_ESN = @('toyota-erc', 6, '^([a-zA-Z0-9]{6})$')
[RadioModels]::JAGUAR_ALPINE = @('jaguar-alpine', 5, '^([0-9]{5})$')

class RadioCodeCalculator {
    static [string] $API_URL = 'https://www.pelock.com/api/radio-code-calculator/v1'

    [string] $_apiKey = $null

    RadioCodeCalculator() {
        $this._apiKey = $null
    }

    RadioCodeCalculator([string]$ApiKey) {
        $this._apiKey = $ApiKey
    }

    [object[]] Login() {
        $params = @{ command = 'login' }
        $result = $this.PostRequest($params)
        $errorCode = [RadioErrors]::ERROR_CONNECTION
        if ($result -and $result.ContainsKey('error')) {
            $errorCode = [int]$result['error']
        }
        return @($errorCode, $result)
    }

    [object[]] Calc($RadioModel, [string]$RadioSerialNumber) {
        return $this.Calc($RadioModel, $RadioSerialNumber, '')
    }

    [object[]] Calc($RadioModel, [string]$RadioSerialNumber, [string]$RadioExtraData) {
        $modelName = $RadioModel
        if ($RadioModel -is [RadioModel]) {
            $modelName = $RadioModel.name
        }

        $params = @{
            command     = 'calc'
            radio_model = [string]$modelName
            serial      = $RadioSerialNumber
            extra       = $RadioExtraData
        }

        $result = $this.PostRequest($params)
        $errorCode = [RadioErrors]::ERROR_CONNECTION
        if ($result -and $result.ContainsKey('error')) {
            $errorCode = [int]$result['error']
        }
        return @($errorCode, $result)
    }

    [object[]] Info($RadioModel) {
        $modelName = $RadioModel
        if ($RadioModel -is [RadioModel]) {
            $modelName = $RadioModel.name
        }

        $params = @{
            command     = 'info'
            radio_model = [string]$modelName
        }

        $result = $this.PostRequest($params)
        if ($null -eq $result -or [int]$result['error'] -ne [RadioErrors]::SUCCESS) {
            $errorCode = [RadioErrors]::ERROR_CONNECTION
            if ($result -and $result.ContainsKey('error')) {
                $errorCode = [int]$result['error']
            }
            return @($errorCode, $null)
        }

        $extraMax = 0
        $extraPat = $null
        if ($result.ContainsKey('extraMaxLen')) {
            $extraMax = [int]$result['extraMaxLen']
        }
        if ($result.ContainsKey('extraRegexPattern')) {
            $extraPat = $result['extraRegexPattern']
        }

        $model = [RadioModel]::new([string]$modelName, [int]$result['serialMaxLen'], $result['serialRegexPattern'], $extraMax, $extraPat)
        return @([int]$result['error'], $model)
    }

    [object[]] List() {
        $params = @{ command = 'list' }
        $result = $this.PostRequest($params)
        if ($null -eq $result -or [int]$result['error'] -ne [RadioErrors]::SUCCESS) {
            $errorCode = [RadioErrors]::ERROR_CONNECTION
            if ($result -and $result.ContainsKey('error')) {
                $errorCode = [int]$result['error']
            }
            return @($errorCode, $null)
        }

        $radioModels = New-Object System.Collections.Generic.List[RadioModel]
        $supported = $result['supportedRadioModels']
        foreach ($property in $supported.PSObject.Properties) {
            $radioModel = $property.Value
            $extraMax = 0
            $extraPat = $null
            if ($radioModel.PSObject.Properties['extraMaxLen']) {
                $extraMax = [int]$radioModel.extraMaxLen
            }
            if ($radioModel.PSObject.Properties['extraRegexPattern']) {
                $extraPat = $radioModel.extraRegexPattern
            }
            $model = [RadioModel]::new($property.Name, [int]$radioModel.serialMaxLen, $radioModel.serialRegexPattern, $extraMax, $extraPat)
            $radioModels.Add($model)
        }

        return @([int]$result['error'], $radioModels.ToArray())
    }

    [hashtable] PostRequest([hashtable]$ParamsArray) {
        if ($this._apiKey) {
            $ParamsArray['key'] = $this._apiKey
        }

        $defaultError = @{ error = [RadioErrors]::ERROR_CONNECTION }

        try {
            $body = ConvertTo-RadioFormBody -Fields $ParamsArray
            $headers = @{ 'User-Agent' = $script:RadioUserAgent }
            $result = Invoke-RestMethod -Uri $script:RadioApiUrl -Method Post -Body $body `
                -ContentType 'application/x-www-form-urlencoded' -Headers $headers `
                -TimeoutSec $script:RadioTimeoutSec
        }
        catch {
            return $defaultError
        }

        $table = ConvertFrom-RadioResult -Result $result
        if ($null -eq $table -or -not $table.ContainsKey('error')) {
            return $defaultError
        }

        return $table
    }

    [hashtable] post_request([hashtable]$params_array) {
        return $this.PostRequest($params_array)
    }
}

function New-RadioCodeCalculator {
    <#
    .SYNOPSIS
        Creates a Radio Code Calculator Web API client.

    .PARAMETER ApiKey
        Activation key from PELock.

    .EXAMPLE
        $client = New-RadioCodeCalculator -ApiKey 'ABCD-ABCD-ABCD-ABCD'
        $apiError, $result = $client.Calc([RadioModels]::Get([RadioModels]::FORD_M_SERIES), '123456')
    #>

    [CmdletBinding()]
    [OutputType([RadioCodeCalculator])]
    param(
        [string]$ApiKey
    )

    if ([string]::IsNullOrWhiteSpace($ApiKey)) {
        return [RadioCodeCalculator]::new($null)
    }

    return [RadioCodeCalculator]::new($ApiKey)
}

Export-ModuleMember -Function @('New-RadioCodeCalculator')