A driver from scratch
This commit is contained in:
parent
222dc8d2cf
commit
f276be1fbf
1 changed files with 419 additions and 0 deletions
419
projects/hatctl/src/python/hatctl/driver.py
Normal file
419
projects/hatctl/src/python/hatctl/driver.py
Normal file
|
@ -0,0 +1,419 @@
|
|||
"""An I2C driver for the ClusterCTRL/ClusterHAT device(s)."""
|
||||
|
||||
from enum import Enum
|
||||
from itertools import chain, repeat
|
||||
from time import sleep
|
||||
from typing import Union
|
||||
|
||||
import smbus
|
||||
|
||||
|
||||
def once(f):
|
||||
"""Decorator. Defer to f once and only once, caching the result forever.
|
||||
|
||||
Users with a functional background may recognize the concept of a `thunk`.
|
||||
|
||||
"""
|
||||
|
||||
unset = val = object()
|
||||
def _helper(*args, **kwargs):
|
||||
nonlocal val
|
||||
if val is unset:
|
||||
val = f(*args, **kwargs)
|
||||
return val
|
||||
|
||||
return _helper
|
||||
|
||||
|
||||
# I2C address of ClusterCTRL device
|
||||
I2C_ADDRESS = 0x20
|
||||
|
||||
|
||||
class BoardType(Enum):
|
||||
DA = 0x00 # Unknown, presunably a prototype
|
||||
A6 = 0x03 # https://clusterctrl.com/p/aplus6
|
||||
STACK = 0x04 # https://shop.pimoroni.com/products/clusterctrl-stack
|
||||
SINGLE = 0x01 # Do the 'single' and 'triple' really use the same board ID?
|
||||
TRIPLE = 0x01
|
||||
PHAT = 0x02
|
||||
|
||||
|
||||
class Status(Enum):
|
||||
"""Values of the Reg.STATUS register."""
|
||||
|
||||
OKAY = 0
|
||||
UNSUPPORTED = 1
|
||||
NO_PI = 2
|
||||
UNKNOWN = 3
|
||||
RESET_FAILED = 4
|
||||
|
||||
|
||||
class Reg(Enum):
|
||||
"""The registers supported by an Cluster device."""
|
||||
|
||||
# https://github.com/burtyb/ClusterCTRL/blob/54bd6c4593e99decbf5a4953c794775b8c4db022/src/main.c#L380-L403
|
||||
VERSION = 0x00 # Register layout version
|
||||
MAXPI = 0x01 # Maximum number of Pi
|
||||
ORDER = 0x02 # Order - used to sort multiple ClusterCTRL devices
|
||||
MODE = 0x03 # N/A
|
||||
TYPE = 0x04 # 0=DA, 1=pHAT
|
||||
DATA7 = 0x05 #
|
||||
DATA6 = 0x06 #
|
||||
DATA5 = 0x07 #
|
||||
DATA4 = 0x08 #
|
||||
DATA3 = 0x09 #
|
||||
DATA2 = 0x0A #
|
||||
DATA1 = 0x0B #
|
||||
DATA0 = 0x0C #
|
||||
CMD = 0x0D # Command
|
||||
STATUS = 0x0E # Status
|
||||
|
||||
|
||||
class Cmd(Enum):
|
||||
"""Commands supported by various Cluster devices."""
|
||||
|
||||
# https://github.com/burtyb/ClusterCTRL/blob/54bd6c4593e99decbf5a4953c794775b8c4db022/src/main.c#L405-L434
|
||||
ON = 0x03 # Turn on Px (data0=x)
|
||||
OFF = 0x04 # Turn off Px (data0=x)
|
||||
ALERT_ON = 0x05 # Turn on Alert LED
|
||||
ALERT_OFF = 0x06 # Turn off Alert LED
|
||||
HUB_CYCLE = 0x07 # Reset USB HUB (turn off for data0*10ms, then back on)
|
||||
HUB_ON = 0x08 # Turn on the USB hub
|
||||
HUB_OFF = 0x09 # Turn off the USB hub
|
||||
LED_EN = 0x0A # Enable Px LED (data0=x) (PHAT only)
|
||||
LED_DIS = 0x0B # Disable Px LED (data0=x) (PHAT only)
|
||||
PWR_ON = 0x0C # Turn off PWR LED
|
||||
PWR_OFF = 0x0D # Turn off PWR LED
|
||||
RESET = 0x0E # Resets ClusterCTRL (does not keep power state)
|
||||
GET_PSTATUS = 0x0F # Get Px power status (data0=x)
|
||||
FAN = 0x10 # Turn fan on (data0=1) or off (data0=0)
|
||||
GETPATH = 0x11 # Get USB path to Px (data0=x 0=controller) returned in data7-data0
|
||||
USBBOOT_EN = 0x12 # Turn on USBBOOT
|
||||
USBBOOT_DIS = 0x13 # Turn off USBBOOT
|
||||
GET_USTATUS = 0x14 # Get Px USBBOOT status (data0=x)
|
||||
SET_ORDER = 0x15 # Set order (data0=order)
|
||||
SAVE = 0xF0 # Save current PWR/P1-LED/P2-LED/P1/P2/Order/Mode to EEPROM
|
||||
SAVEDEFAULTS = 0xF1 # Save factory defaults
|
||||
GET_DATA = 0xF2 # Get DATA (Temps/ADC/etc.)
|
||||
SAVE_ORDER = 0xF3 # Save order to EEPROM
|
||||
SAVE_USBBOOT = 0xF4 # Save usbboot status to EEPROM
|
||||
SAVE_POS = 0xF5 # Save Power On State to EEPROM
|
||||
SAVE_LED = 0xF6 # Save LED to EEPROM
|
||||
NOP = 0x90 # Do nothing
|
||||
|
||||
|
||||
class Data(Enum):
|
||||
"""Datum that can be read back from the Cluster device via Cmd.GET_DATA"""
|
||||
|
||||
# Get arbitrary data from ClusterCTRL
|
||||
VERSION = 0x00 # Get firmware version
|
||||
ADC_CNT = 0x01 # Returns number of ADC ClusterCTRL supports
|
||||
ADC_READ = 0x02 # Read ADC data for ADC number 'data0'
|
||||
ADC_TEMP = 0x03 # Read Temperature ADC
|
||||
FANSTATUS = 0x04 # Read fan status
|
||||
|
||||
|
||||
class ClusterCTRLDriver(object):
|
||||
def __init__(self, bus: smbus.SMBus, address: int = I2C_ADDRESS, delay: int = 0, clear = False):
|
||||
"""Initialize a ClusterCTRL/ClusterHAT driver instance for a given bus device."""
|
||||
self._bus = bus
|
||||
self._address = address
|
||||
self._delay = delay
|
||||
self._clear = clear
|
||||
|
||||
def _read(self, id: Union[Reg, Data], len: int = 1):
|
||||
"""A convenient abstraction for reading data back."""
|
||||
|
||||
# Performing a "fundamental" read
|
||||
if isinstance(id, Reg):
|
||||
if len == 1:
|
||||
return self._bus.read_byte_data(self._address, id.value)
|
||||
else:
|
||||
return self._bus.read_block_data(self._address, id.value, len)
|
||||
|
||||
# Performing a "command" read
|
||||
elif isinstance(id, Data):
|
||||
return self._call(Cmd.GET_DATA, id.value)
|
||||
|
||||
def _write(self, id: Reg, val: int):
|
||||
"""A convenient abstraction for writing a register."""
|
||||
|
||||
return self._bus.write_byte_data(self._address, id.value, val)
|
||||
|
||||
def _call(self, op: Cmd, *args, clear = False):
|
||||
"""A convenient abstraction over the 'calling' convention for ops.
|
||||
|
||||
Operations are "called" when Reg.CMD is written to.
|
||||
Operations consume parameters from Reg.DATA0-Reg.DATA7.
|
||||
|
||||
If `clear=` is truthy, any registers not defined by parameters will be cleared (zeroed) as a safety measure.
|
||||
|
||||
Note that the caller is responsible for reading any returned data, for which the protocol is less clear.
|
||||
Most operations "just" return via reg.DATA0, but some don't.
|
||||
|
||||
"""
|
||||
|
||||
if self._clear or clear:
|
||||
args = chain(args, repeat(0))
|
||||
|
||||
args = zip([Reg.DATA0, Reg.DATA1, Reg.DATA2, Reg.DATA3, Reg.DATA4, Reg.DATA5, Reg.DATA6, Reg.DATA7], args)
|
||||
for r, v in args:
|
||||
self._write(r, v)
|
||||
|
||||
# Execute the call
|
||||
self._write(Reg.CMD, op.value)
|
||||
|
||||
# If the user has specified a delay, sleep
|
||||
if self._delay:
|
||||
sleep(self._delay)
|
||||
|
||||
if self._read(Reg.STATUS) == 1: # Status error: Unsupported
|
||||
raise Exception("Command %s appears not to be supported by the board" % op)
|
||||
|
||||
# Return the (mostly) meaningful return code
|
||||
return self._read(Reg.DATA0)
|
||||
|
||||
@property
|
||||
def min_pi(self):
|
||||
"""Get the minimum supported Pi ID on this controller."""
|
||||
|
||||
return 1
|
||||
|
||||
@property
|
||||
@once
|
||||
def max_pi(self):
|
||||
"""Get the maximum supported Pi ID on this controller."""
|
||||
|
||||
return self._read(Reg.MAXPI)
|
||||
|
||||
@property
|
||||
def pi_ids(self):
|
||||
"""Iterate over the IDs of Pis which could be connected to this controller."""
|
||||
|
||||
return range(self.min_pi, self.max_pi + 1)
|
||||
|
||||
@property
|
||||
def type(self) -> BoardType:
|
||||
return BoardType(self._read(Reg.TYPE))
|
||||
|
||||
@property
|
||||
def fw_version(self):
|
||||
self._read(Data.VERSION)
|
||||
return (self._read(Reg.DATA1), self._read(Reg.DATA0))
|
||||
|
||||
def reset_all(self):
|
||||
"""[Power] cycle the entire Controller."""
|
||||
|
||||
return self._call(Cmd.RESET)
|
||||
|
||||
####################################################################################################
|
||||
# EEPROM management
|
||||
####################################################################################################
|
||||
def eeprom_save_all(self):
|
||||
"""Persist all supported options to EEPROM."""
|
||||
|
||||
return self._call(Cmd.SAVE)
|
||||
|
||||
def eeprom_reset(self):
|
||||
"""Reset EEPROM to factory/firmware default value(s)."""
|
||||
|
||||
return self._call(Cmd.SAVEDEFAULTS)
|
||||
|
||||
def eeprom_save_powerstate(self):
|
||||
"""Persist the current power state to EEPROM."""
|
||||
|
||||
return self._call(Cmd.SAVE_POS)
|
||||
|
||||
def eeprom_save_leds(self):
|
||||
"""Persist the current LED state to EEPROM."""
|
||||
|
||||
return self._call(Cmd.SAVE_LED)
|
||||
|
||||
def eeprom_save_order(self):
|
||||
"""Persist the current order value to EEPROM."""
|
||||
|
||||
return self._call(Cmd.SAVE_ORDER)
|
||||
|
||||
def eeprom_save_usbboot(self):
|
||||
"""Persist USB booting settings to EEPROM."""
|
||||
|
||||
return self._call(Cmd.SAVEUSBBOOT)
|
||||
|
||||
####################################################################################################
|
||||
# USB hub management
|
||||
####################################################################################################
|
||||
def hub_on(self):
|
||||
"""Turn on the USB hub."""
|
||||
|
||||
return self._call(Cmd.HUB_ON)
|
||||
|
||||
def hub_off(self):
|
||||
"""Turn off the USB hub."""
|
||||
|
||||
return self._call(Cmd.HUB_ON)
|
||||
|
||||
# FIXME: Is hub_status unsupported in the firmware?
|
||||
|
||||
def reset_hub(self, delay: int = 0):
|
||||
"""[Power] cycle the Controller hub for `delay` x 10ms."""
|
||||
|
||||
return self._call(Cmd.HUB_CYCLE, delay)
|
||||
|
||||
####################################################################################################
|
||||
# Power management
|
||||
####################################################################################################
|
||||
def power_on(self, id: int):
|
||||
"""Power on a given slot by ID."""
|
||||
|
||||
assert 0 < id <= self.max_pi
|
||||
return self._call(Cmd.ON, id)
|
||||
|
||||
def power_off(self, id: int):
|
||||
"""Power off a given slot by ID."""
|
||||
|
||||
assert 0 < id <= self.max_pi
|
||||
return self._call(Cmd.OFF, id)
|
||||
|
||||
def power_status(self, id: int):
|
||||
"""Read the status of a given slot by ID."""
|
||||
|
||||
assert 0 < id <= self.max_pi
|
||||
return self._call(Cmd.GET_PSTATUS, id)
|
||||
|
||||
def power_all_on(self):
|
||||
"""Power on all slots in this Controller."""
|
||||
|
||||
for id in self.pi_ids:
|
||||
if not self.power_status(id):
|
||||
self.power_on(id)
|
||||
|
||||
def power_all_off(self):
|
||||
"""Power off all slots in this Controller."""
|
||||
|
||||
for id in self.pi_ids:
|
||||
self.power_off(id)
|
||||
|
||||
####################################################################################################
|
||||
# LED management
|
||||
####################################################################################################
|
||||
def alert_on(self):
|
||||
"""Turn on the alert LED on the Controller."""
|
||||
|
||||
return self._call(Cmd.ALERT_ON)
|
||||
|
||||
def alert_off(self):
|
||||
"""Turn off the alert LED on the Controller."""
|
||||
|
||||
return self._call(Cmd.ALERT_OFF)
|
||||
|
||||
####################################################################################################
|
||||
# LED management
|
||||
####################################################################################################
|
||||
def fan_on(self):
|
||||
"""Turn on the fan(s) attached to this Controller."""
|
||||
|
||||
return self._call(Cmd.FAN, 1)
|
||||
|
||||
def fan_off(self):
|
||||
"""Turn off the fan(s) attached to this Controller."""
|
||||
|
||||
return self._call(Cmd.FAN, 0)
|
||||
|
||||
def fan_status(self):
|
||||
"""Get the status of the fan(s) attached to this Controller."""
|
||||
|
||||
return self._read(Data.FANSTATUS)
|
||||
|
||||
####################################################################################################
|
||||
# 'order' (board ID) management
|
||||
####################################################################################################
|
||||
def get_order(self):
|
||||
"""Get the 'order' value of this device. Can be updated via """
|
||||
|
||||
return self._read(Reg.ORDER)
|
||||
|
||||
def set_order(self, order: int):
|
||||
"""Set an 'order' (Controller ID) value."""
|
||||
|
||||
assert 0 < order <= 255
|
||||
return self._call(Cmd.SET_ORDER, order)
|
||||
|
||||
####################################################################################################
|
||||
# USB booting
|
||||
####################################################################################################
|
||||
def usbboot_on(self, id: int):
|
||||
"""Enable USB booting for the given Pi."""
|
||||
|
||||
assert 0 < id <= self.max_pi
|
||||
return self._call(Cmd.USBBOOT_EN, id)
|
||||
|
||||
def usbboot_off(self, id: int):
|
||||
"""Disable USB booting for the given Pi."""
|
||||
|
||||
assert 0 < id <= self.max_pi
|
||||
return self._call(Cmd.USBBOOT_DIS, id)
|
||||
|
||||
def usbboot_status(self, id: int):
|
||||
"""Get the current USB booting status for the given Pi."""
|
||||
|
||||
assert 0 < id <= self.max_pi
|
||||
return self._call(Cmd.GET_USTATUS, id)
|
||||
|
||||
####################################################################################################
|
||||
# ADCs
|
||||
####################################################################################################
|
||||
@property
|
||||
@once
|
||||
def max_adc(self):
|
||||
return self._read(Data.ADC_CNT)
|
||||
|
||||
@property
|
||||
def adc_ids(self):
|
||||
return range(1, self.max_adc + 1)
|
||||
|
||||
def read_adc(self, id: int):
|
||||
self._call(Cmd.GET_DATA, Data.ADC_READ.value, id)
|
||||
# Now this is screwy.
|
||||
# DATA0 gets set to 0 or 1, indicating the voldage type
|
||||
# DATA1 and DATA2 are a 16bi number that need to be reassembled.
|
||||
# Note that DATA2 is the high bits.
|
||||
|
||||
val = self._read(Reg.DATA2) << 8 + self._read(Reg.DATA1)
|
||||
type = self._read(Reg.DATA0)
|
||||
|
||||
if type == 0:
|
||||
# Voltage type '1' 3v3 REF, Voltage /2
|
||||
val *= 6.4453125
|
||||
elif type == 1:
|
||||
# Voltage type '2' 3v3 REF, Voltage = ((VIN*1.07)/10+1.07)
|
||||
val *= 33.34093896028037
|
||||
else:
|
||||
raise ValueError("Unknown voltage type %d" % type)
|
||||
|
||||
return val
|
||||
|
||||
def read_temp(self) -> int:
|
||||
# Now this is screwy.
|
||||
# DATA0 is ... something expected to be 2
|
||||
# DATA1 and DATA2 form a low/high 16bi number. Unit is Kelvin.
|
||||
if self._call(Cmd.GET_DATA, Data.ADC_TEMP.value) == 2:
|
||||
return self._read(Reg.DATA2) << 8 + self._read(Reg.DATA1)
|
||||
|
||||
|
||||
class ClusterHATDriver(ClusterCTRLDriver):
|
||||
"""The ClusterHAT controller supports some verbs not supported by the basic ClusterCTRL board."""
|
||||
|
||||
# FIXME: The ClusterHAT also has some CONSIDERABLE differences in how it does I/O, due to leveraging RPi GPIO not
|
||||
# just i2c. Whether this is essential or incidental is unclear.
|
||||
|
||||
def led_on(self, id: int):
|
||||
"""Turn on an LED by ID."""
|
||||
|
||||
assert self.type is BoardType.PHAT
|
||||
return self._call(Cmd.LED_EN, id)
|
||||
|
||||
def led_off(self, id: int):
|
||||
"""Turn off an LED by ID."""
|
||||
|
||||
assert self.type is BoardType.PHAT
|
||||
return self._call(Cmd.LED_DIS, id)
|
Loading…
Reference in a new issue