Skip to content

Latest commit

 

History

History
86 lines (53 loc) · 8.91 KB

porting-drivers-from-opencorsairlink.md

File metadata and controls

86 lines (53 loc) · 8.91 KB

Porting drivers from OpenCorsairLink

Originally posted as a comment in issue #129.

In essence, writing a new liquidctl driver means implementing all (suitable) methods of a liquidctl.base.BaseDriver.

Note that you shouldn't directly subclass the BaseDriver; instead you'll inherit from a bus-specific base driver like liquidctl.usb.UsbDriver or liquidctl.usb.UsbHidDevice, which will already include default implementations for many methods and properties.

And for the new driver to work out-of-the-box it's sufficient to import its module in liquidctl/driver/__init__.py.

Next, in order to port a driver from OCL, the first step is to check the corsair_device_info struct that matches the device, which defines the low-level and driver (protocol) functions used for it in OCL, besides a few other important parameters.

    {
        .vendor_id = 0x1b1c,
        .product_id = 0x0c04,
        .device_id = 0x3b,
        .name = "H80i",
        .read_endpoint = 0x01 | LIBUSB_ENDPOINT_IN,
        .write_endpoint = 0x00 | LIBUSB_ENDPOINT_OUT,
        .driver = &corsairlink_driver_coolit,
        .lowlevel = &corsairlink_lowlevel_coolit,
        .led_control_count = 1,
        .fan_control_count = 4,
        .pump_index = 5,
    },

in device.c

The low-level functions

Starting with the low-level functions specified by corsairlink_lowlevel_coolit, and implemented in lowlevel/coolit.c: the equivalence between these and the methods in a liquidctl driver is:

  • init -> connect (in some cases and/or initialize)
  • deinit -> disconnect
  • read/write -> self.device.read/self.device.write (see next paragraphs)

This is a HID device, so the liquidctl driver should inherit liquidctl.usb.UsbHidDriver, meaning that in the driver self.device will be a liquidctl.usb.HidapiDevice. Additionally, liquidctl already automatically handles how to write to a HID, but does so mimicking hidapi; HidapiDevice.write follows the specification:

The first byte of data[] must contain the Report ID. For devices which only support a single report, this must be set to 0x0. The remaining bytes contain the report data. Since the Report ID is mandatory, calls to hid_write() will always contain one more byte than the report contains. —from hidapi/hidapi.h

Practically, it means that you only need to implement init and deinit, and that in the translated driver, when OCL would call corsairlink_coolit_write with [byte1, byte2, byte3, ...], you'll instead call self.device.write with [0x00, byte1, byte2, byte3, ...] (note the prepended 0x00 byte)

Higher-level functionality

The remaining get_status, set_fixed_speed, set_speed_profile and set_color methods (required by BaseDriver) will encapsulate the functionality specified by corsairlink_driver_coolit (implemented in protocol/coolit/*.c), and are for the most part what users will access through the CLI.

Data that is read from the cooler, like the pump speed, will generally go into get_status. The firmware version is an exception in this case: it's read with a specific command (instead of being part of other replies), and so it belongs in the output of initialize.

(You can fetch the firmware version directly in initialize or, if you need to use it anywhere else, you read it and cache it in connect, and only return the cached value in initialize.)

The other three methods are self-explanatory and should be fairly straightforward to implement, apart from the special considerations that I go into next.

Protocols with interdependent messages

A big aspect in the design of the liquidctl CLI was not requiring the user to configure different aspects of the cooler in a single command: you should be able to set the pump speed without resetting the fan speed or the LED colors.

For most devices there's a clear mapping between the CLI and the implementation: the CLI command set <channel> speed <fixed duty> implemented with set_fixed_speed won't depend on other BaseDriver methods (apart from connect and disconnect).

There are however "complicated" devices where, at the protocol level, functionality is grouped (all channels must be set at once) or even completely consolidated into a single "state" (everything must be reset when changing a single parameter). Messages can also be required to follow an arbitrary order.

So, besides looking at how each individual parameter is configured, you also need to check the "logic" part of OCL, in this case implemented in hydro_coolit_settings. This doesn't mean that all OCL devices will fall into the "complicated" category, or that you'll necessarily need to match that order exactly.

In fact, in the case of the H80i (or other devices using the same protocol) I think that the different aspects of the cooler can indeed be configured independently, at least for the most part.

This is mostly due to the empty implementations of init and deinit: in more complex cases these functions usually involve some type of opening and closing of a "transaction", but there's nothing of the sort here.

The ordering in hydro_coolit_settings also seems to be strictly due to natural requirements (you need to know how many sensors there are before reading them), instead of being totally arbitrary. But I could be wrong...

Anyway, the main concern I have right now is the CommandId byte that's sent in every message. It starts at 0x81 and is continually incremented. On one hand it clearly doesn't need to be a perfect sequence number (as OCL doesn't guarantee that in multiple invocations), but on the other the shorter message chains in liquidctl (due to only a few parameters being read or changed at a time) could cause the cooler to complain.

I'd start following OCL: initialize a similar variable to 0x81 every time the driver is instantiated, and increment it every time it's used. But if that somehow doesn't work, you can use the internal keyval API (example usage) to temporarily persist it to disk, allowing you to implement a true (wrapping) sequence number across liquidctl invocations.

No matter what, just don't forget to explicitly wrap CommandId it at 255, you'll probably be using a normal Python integer instead of a u8.

Advanced driver binding

liquidctl driver don't normally need to check anything super special to know whether or not they are compatible with a particular device. As long as SUPPORTED_DEVICES lists the compatible USB vendor and product IDs, besides any additional parameters required by __init__, the bus-specific base driver will do the rest.

This wont be the case with the H80i: it shares a common vendor and product ID with other devices, and is only differentiated by a "device ID", that has to be explicitly read. Reading of this device ID is implemented in OCL by corsairlink_coolit_device_id.

There are two ways of handling this in liquidctl. One way is to override probe (implemented in UsbHidDriver) to fetch the device ID, filter out any unknown IDs, and (only) yield driver instances that have as field a know ID; each instance should also map that ID to the corresponding parameters for that device (description, fan count, pump index, etc.). Another way is to have a generic driver that only fetches the ID and customizes itself accordingly at connect time, meaning that before that it identifies itself as something like "Undetermined Corsair device".

Because having the driver instance in an undetermined state will cause some issues, both for us and for the user, I think you should try the probe method first.