astra/pd-power-supply

USB-PD Power Supply with RP2040 controller

Version
1.1.6
License
unset
Stars
0

firmware/pd_control.py

"""CH224A/CH224Q PPS control. WCH CH224DS1 v2.1, registers 09/0A/50/53/60..8F.
The IC's I2C voltage register is 100mV/LSB, even though PD PPS itself permits 20mV.
No AVS/EPR requests: this PCB's output range is capped at 20V.
"""
MIN_MV, MAX_MV, STEP_MV = 3300, 20000, 100
FIXED_CODES = {5000: 0, 9000: 1, 12000: 2, 15000: 3, 20000: 4}

def knob_voltage(coarse, fine):
    coarse = max(0, min(65535, coarse))
    fine = max(0, min(65535, fine))
    mv = MIN_MV + (MAX_MV - MIN_MV) * coarse // 65535
    mv += fine * 1000 // 65535 - 500
    return max(MIN_MV, min(MAX_MV, ((mv + 50) // STEP_MV) * STEP_MV))

class KnobSelector:
    def __init__(self):
        self.value = 5000
        self.pending = self.value
        self.samples = 0
    def update(self, coarse, fine):
        candidate = knob_voltage(coarse, fine)
        if candidate != self.pending:
            self.pending, self.samples = candidate, 1
        else:
            self.samples += 1
        if self.samples >= 4:
            self.value = self.pending
        return self.value

def parse_pdos(raw):
    """Decode 7 SPR PDOs. Recognize PD Source_Capabilities header or bare PDOs.
    Firmware fails closed on unknown framing. WCH does not document the byte
    framing of its SRCCAP register region; confirm the dump on real hardware.
    """
    if len(raw) < 4:
        raise ValueError('truncated capabilities')
    header = int.from_bytes(raw[:2], 'little')
    count = (header >> 12) & 7
    if header & 31 == 1 and count and not header & 0x8000:
        if len(raw) < 2 + count * 4:
            raise ValueError('truncated PD message')
        data = raw[2:2 + count * 4]
    else:
        data = raw[:28]
    result = []
    for offset in range(0, len(data) - 3, 4):
        pdo = int.from_bytes(data[offset:offset+4], 'little')
        if pdo == 0:
            break
        kind = pdo >> 30
        if kind == 0:
            mv, ma = ((pdo >> 10) & 1023) * 50, (pdo & 1023) * 10
            if not 3000 <= mv <= 20000 or not 50 <= ma <= 5000:
                raise ValueError('invalid fixed PDO')
            result.append(('fixed', mv, mv, ma))
        elif kind == 3 and (pdo >> 28) & 3 == 0:
            lo, hi, ma = ((pdo >> 8) & 255) * 100, ((pdo >> 17) & 255) * 100, (pdo & 127) * 50
            if not 3300 <= lo <= hi <= 21000 or not 50 <= ma <= 5000:
                raise ValueError('invalid PPS APDO')
            result.append(('pps', lo, hi, ma))
        elif kind in (1, 2) or kind == 3:
            # Battery, variable supply and AVS cannot be used by this controller policy.
            result.append(('other', 0, 0, 0))
    if not result or result[0][:3] != ('fixed', 5000, 5000):
        raise ValueError('capabilities must begin with 5V fixed PDO')
    return result

def supports_pps(pdos, mv):
    # Reserve >=150mA for controller/display in addition to the 1A output target.
    return any(kind == 'pps' and lo <= mv <= min(hi, MAX_MV) and ma >= 1150
               for kind, lo, hi, ma in pdos)

class CH224Q:
    def __init__(self, bus):
        self.bus, self.address, self.pdos = bus, None, []
        self.requested_mv, self.mode = 5000, 'fixed'
    def read(self, register, count=1):
        return self.bus.readfrom_mem(self.address, register, count)
    def write(self, register, value):
        self.bus.writeto_mem(self.address, register, bytes((value,)))
    def connect(self):
        found = self.bus.scan()
        self.address = next((a for a in (0x22, 0x23) if a in found), None)
        if self.address is None:
            raise OSError('no PD controller')
        # Reset policy explicitly to 5V before any dial request.
        self.write(0x0a, 0)
        self.requested_mv, self.mode, self.pdos = 5000, 'fixed', []
    def capabilities(self):
        if not self.read(0x09)[0] & 8:
            self.pdos = []
            raise ValueError('PD handshake unavailable')
        # Read one register at a time: does not assume undocumented burst support.
        raw = bytes(self.read(reg)[0] for reg in range(0x60, 0x90))
        self.pdos = parse_pdos(raw)
        return self.pdos
    def request(self, mv):
        if mv % STEP_MV or not MIN_MV <= mv <= MAX_MV:
            raise ValueError('out of range or off 100mV grid')
        if not supports_pps(self.pdos, mv):
            # Never silently substitute a different voltage for the displayed target.
            self.write(0x0a, 0)
            self.requested_mv, self.mode = 5000, 'fixed'
            raise ValueError('PPS range/current not supported')
        self.write(0x53, mv // STEP_MV)
        if self.mode != 'pps':
            self.write(0x0a, 6)  # Voltage FIRST, then enable PPS.
        self.requested_mv, self.mode = mv, 'pps'
        return mv