The K70 RGB PRO V2 (1B1C:1BB3) was detected but never lit: it blanked into software render mode and then fell back to its onboard effect. Getting it working turned up five faults, three of which affect every Corsair V2 device rather than just this keyboard. Packet size is now read from the HID report descriptor before the first write instead of being inferred from a reply. These devices come in a 64 byte and a 1024 byte flavour, and sending a short packet to a 1024 byte endpoint stalls it until the device is power cycled, so the size has to be known up front rather than after two short queries have gone out. CORSAIR_V2_PACKET_SIZE was 1024 while pkt_sze reached 1025, so every read using pkt_sze overran its stack buffer by a byte. The command helpers now use pkt_sze against buffers of the corrected size. The lighting resource probe ran before the device was placed in software render mode, where the answer is meaningless, and treated any error as "use resource 1". Devices that answer invalid or unsupported for resource 1 want the alternate lighting resource, which takes RGB triplets; this keyboard is one of them. The probe now runs after the render mode switch and distinguishes a stale open handle from an unsupported resource. Direct lighting writes were sized from the keymap, which covers only the keys that exist. The hardware expects its full slot count and ignores a short write, so corsair_v2_device carries an optional hw_led_count and the buffer map is padded to it. Set to 193 for this keyboard. The keepalive thread wrote LEDs on its first pass, because last_update_time was left at the clock epoch and so always read as older than the update period. That raced detection on the same HID handle with no locking, which both corrupted the transaction and could abort the process. The clock is now started before the thread runs, device transactions are serialised behind a mutex, and the thread is stopped before Shutdown rather than after. Also fixes an uninitialised stack buffer sent to the device during setup, and widens the K70 RGB PRO matrix to 22 columns so the numpad period key has a position in the layout instead of being dropped. Verified on hardware: repeated colour changes apply and the device stays responsive across runs. Co-Authored-By: Claude Opus 5 <[email protected]>
588 lines
19 KiB
C++
588 lines
19 KiB
C++
/*---------------------------------------------------------*\
|
|
| CorsairPeripheralV2Controller.cpp |
|
|
| |
|
|
| Driver for Corsair V2 peripherals |
|
|
| |
|
|
| Chris M (Dr_No) 07 Aug 2022 |
|
|
| |
|
|
| This file is part of the OpenRGB project |
|
|
| SPDX-License-Identifier: GPL-2.0-or-later |
|
|
\*---------------------------------------------------------*/
|
|
|
|
#include <chrono>
|
|
#include <thread>
|
|
|
|
#include "CorsairPeripheralV2Controller.h"
|
|
#include "StringUtils.h"
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
CorsairPeripheralV2Controller::CorsairPeripheralV2Controller(hid_device* dev_handle, const char* path, std::string name)
|
|
{
|
|
dev = dev_handle;
|
|
location = path;
|
|
device_name = name;
|
|
device_index = CORSAIR_V2_DEVICE_NOT_FOUND;
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Get PID |
|
|
| If the PID is in the know wireless receivers list |
|
|
| switch the write_cmd to talk to the device and retry |
|
|
\*---------------------------------------------------------*/
|
|
/*---------------------------------------------------------*\
|
|
| Establish the packet size before talking to the device. |
|
|
\*---------------------------------------------------------*/
|
|
uint16_t report_size = GetOutputReportSize();
|
|
|
|
if(report_size >= CORSAIR_V2_WRITE_SIZE && report_size <= CORSAIR_V2_PACKET_SIZE)
|
|
{
|
|
pkt_sze = report_size;
|
|
}
|
|
|
|
LOG_DEBUG("[%s] Packet length set to %d", device_name.c_str(), pkt_sze);
|
|
|
|
unsigned int pid = GetAddressRetry(0x12);
|
|
|
|
switch(pid)
|
|
{
|
|
case CORSAIR_SLIPSTREAM_WIRELESS_PID1:
|
|
case CORSAIR_SLIPSTREAM_WIRELESS_V2_PID1:
|
|
case CORSAIR_SLIPSTREAM_WIRELESS_PID2:
|
|
case CORSAIR_SLIPSTREAM_M75_PID:
|
|
case CORSAIR_SLIPSTREAM_IRONCLAW_SE_PID:
|
|
write_cmd = CORSAIR_V2_WRITE_WIRELESS_ID;
|
|
pid = GetAddressRetry(0x12);
|
|
break;
|
|
|
|
case CORSAIR_K57_RGB_WIRED_PID:
|
|
write_cmd = 0x80;
|
|
light_ctrl = CORSAIR_V2_LIGHT_CTRL1;
|
|
skip_reads = true;
|
|
break;
|
|
}
|
|
|
|
/*---------------------------------------------------------*\
|
|
| If the hid_pid passed in from the detector does not match |
|
|
| the pid reported by the device then it is likey |
|
|
| behind a wireless receiver. |
|
|
\*---------------------------------------------------------*/
|
|
LOG_DEBUG("[%s] Setting write CMD to %02X for %s mode for PID %04X", device_name.c_str(),
|
|
write_cmd, (write_cmd == CORSAIR_V2_WRITE_WIRELESS_ID) ? "wireless" : "wired", pid);
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Get VID |
|
|
| NB: this can be achieved with GetAddress(0x11) but we |
|
|
| also need to set the packet length capabilities for |
|
|
| the device being set up. |
|
|
\*---------------------------------------------------------*/
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
memset(buffer, 0, pkt_sze);
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_GET;
|
|
buffer[3] = 0x11;
|
|
hid_write(dev, buffer, pkt_sze);
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
|
|
/*---------------------------------------------------------*\
|
|
| NB: If the device is not found in the device list |
|
|
| then wireless mode may not work reliably |
|
|
\*---------------------------------------------------------*/
|
|
bool not_found = true;
|
|
|
|
for(uint16_t i = 0; i < CORSAIR_V2_DEVICE_COUNT; i++)
|
|
{
|
|
LOG_DEBUG("[%s] Checking PID %04X against index %d with %04X - %smatch", device_name.c_str(),
|
|
pid, i, corsair_v2_device_list[i]->pid, corsair_v2_device_list[i]->pid == pid ? "" : "no ");
|
|
if(corsair_v2_device_list[i]->pid == pid)
|
|
{
|
|
/*---------------------------------------------------------*\
|
|
| Set device ID |
|
|
\*---------------------------------------------------------*/
|
|
not_found = false;
|
|
device_index = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Unknown PID |
|
|
| Leave device_index at CORSAIR_V2_DEVICE_NOT_FOUND so |
|
|
| GetDeviceData() returns nullptr and the detector drops |
|
|
| the device, rather than building an RGBController on |
|
|
| top of an out of range index. |
|
|
\*---------------------------------------------------------*/
|
|
if(not_found)
|
|
{
|
|
LOG_ERROR("[%s] device capabilities not found. Please creata a new device request.",
|
|
device_name.c_str());
|
|
return;
|
|
}
|
|
|
|
}
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Check lighting control endpoints |
|
|
| If lighting control endpoint 2 is unavailable |
|
|
| then use endpoint 1. |
|
|
\*---------------------------------------------------------*/
|
|
uint16_t CorsairPeripheralV2Controller::GetOutputReportSize()
|
|
{
|
|
unsigned char descriptor[HID_API_MAX_REPORT_DESCRIPTOR_SIZE];
|
|
int length = hid_get_report_descriptor(dev, descriptor, sizeof(descriptor));
|
|
|
|
if(length <= 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
unsigned int report_size = 0;
|
|
unsigned int report_count = 0;
|
|
int index = 0;
|
|
|
|
while(index < length)
|
|
{
|
|
unsigned char item = descriptor[index];
|
|
unsigned char tag = item & 0xFC;
|
|
unsigned char item_size = item & 0x03;
|
|
|
|
/*---------------------------------------------------------*\
|
|
| A size field of 3 means four data bytes follow. |
|
|
\*---------------------------------------------------------*/
|
|
if(item_size == 3)
|
|
{
|
|
item_size = 4;
|
|
}
|
|
|
|
if(index + 1 + item_size > length)
|
|
{
|
|
break;
|
|
}
|
|
|
|
unsigned int value = 0;
|
|
|
|
for(unsigned char byte = 0; byte < item_size; byte++)
|
|
{
|
|
value |= (unsigned int)descriptor[index + 1 + byte] << (8 * byte);
|
|
}
|
|
|
|
switch(tag)
|
|
{
|
|
case 0x74: /* Report Size */
|
|
report_size = value;
|
|
break;
|
|
|
|
case 0x94: /* Report Count */
|
|
report_count = value;
|
|
break;
|
|
|
|
case 0x90: /* Output */
|
|
if(report_size > 0 && report_count > 0)
|
|
{
|
|
return (uint16_t)(((report_size * report_count) / 8) + 1);
|
|
}
|
|
break;
|
|
}
|
|
|
|
index += 1 + item_size;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::DetectLightingEndpoint()
|
|
{
|
|
/*---------------------------------------------------------*\
|
|
| A device that cannot be read from cannot be probed; those |
|
|
| carry the endpoint they need already set. |
|
|
\*---------------------------------------------------------*/
|
|
if(skip_reads)
|
|
{
|
|
return;
|
|
}
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Lighting control 1 is the plain lighting resource and |
|
|
| takes colour block data. A device that answers |
|
|
| invalid or unsupported for it wants the alternate |
|
|
| lighting resource, which takes RGB triplets. |
|
|
\*---------------------------------------------------------*/
|
|
light_ctrl = CORSAIR_V2_LIGHT_CTRL1;
|
|
unsigned char result = StartTransaction(0);
|
|
|
|
/*---------------------------------------------------------*\
|
|
| A handle left open by an earlier session answers failed; |
|
|
| close it and open it again. |
|
|
\*---------------------------------------------------------*/
|
|
if(result == CORSAIR_V2_ERR_FAILED)
|
|
{
|
|
StopTransaction(0);
|
|
result = StartTransaction(0);
|
|
}
|
|
|
|
if(result == CORSAIR_V2_ERR_INVALID || result == CORSAIR_V2_ERR_NOT_SUPPORTED)
|
|
{
|
|
light_ctrl = CORSAIR_V2_LIGHT_CTRL2;
|
|
StartTransaction(0);
|
|
}
|
|
|
|
StopTransaction(0);
|
|
LOG_DEBUG("[%s] Lighting Endpoint set to %02X", device_name.c_str(), light_ctrl);
|
|
}
|
|
|
|
CorsairPeripheralV2Controller::~CorsairPeripheralV2Controller()
|
|
{
|
|
hid_close(dev);
|
|
}
|
|
|
|
const corsair_v2_device* CorsairPeripheralV2Controller::GetDeviceData()
|
|
{
|
|
if(!IsDeviceSupported())
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
return corsair_v2_device_list[device_index];
|
|
}
|
|
|
|
bool CorsairPeripheralV2Controller::IsDeviceSupported()
|
|
{
|
|
return device_index < CORSAIR_V2_DEVICE_COUNT;
|
|
}
|
|
|
|
std::string CorsairPeripheralV2Controller::GetDeviceLocation()
|
|
{
|
|
return("HID: " + location);
|
|
}
|
|
|
|
std::string CorsairPeripheralV2Controller::GetErrorString(uint8_t err)
|
|
{
|
|
switch(err)
|
|
{
|
|
case 1:
|
|
return "Invalid Value";
|
|
case 3:
|
|
return "Failed";
|
|
case 5:
|
|
return "Unsupported";
|
|
default:
|
|
return "Protocol Error (Unknown)";
|
|
}
|
|
}
|
|
|
|
std::string CorsairPeripheralV2Controller::GetFirmwareString()
|
|
{
|
|
return "";
|
|
}
|
|
|
|
std::string CorsairPeripheralV2Controller::GetName()
|
|
{
|
|
return device_name;
|
|
}
|
|
|
|
std::string CorsairPeripheralV2Controller::GetSerialString()
|
|
{
|
|
wchar_t serial_string[128];
|
|
int ret = hid_get_serial_number_string(dev, serial_string, 128);
|
|
|
|
if(ret != 0)
|
|
{
|
|
return("");
|
|
}
|
|
|
|
return(StringUtils::wstring_to_string(serial_string));
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::SetRenderMode(corsair_v2_device_mode mode)
|
|
{
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
|
|
memset(buffer, 0, pkt_sze);
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Set Mode |
|
|
\*---------------------------------------------------------*/
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_SET;
|
|
buffer[3] = CORSAIR_V2_VALUE_MODE;
|
|
buffer[5] = mode;
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
|
|
if(!skip_reads)
|
|
{
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::LightingControl(uint8_t opt1)
|
|
{
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
|
|
memset(buffer, 0, pkt_sze);
|
|
|
|
/*---------------------------------------------------------*\
|
|
| The Corsair command is the same for each initialisation |
|
|
| packet and the registers and options differ for |
|
|
| each peripheral supported by the protocol |
|
|
\*---------------------------------------------------------*/
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_GET;
|
|
buffer[3] = opt1;
|
|
buffer[5] = 0x00;
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
|
|
if(!skip_reads)
|
|
{
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
unsigned int CorsairPeripheralV2Controller::GetKeyboardLayout()
|
|
{
|
|
return GetAddress(0x41);
|
|
}
|
|
|
|
/*---------------------------------------------------------*\
|
|
| GetAddress returns NA when the device answers with a |
|
|
| protocol error, and a receiver whose device has not |
|
|
| woken answers zero. Straight after enumeration both |
|
|
| are normal, so poll before treating either as final. |
|
|
\*---------------------------------------------------------*/
|
|
unsigned int CorsairPeripheralV2Controller::GetAddressRetry(uint8_t address)
|
|
{
|
|
unsigned int value = NA;
|
|
|
|
for(unsigned int attempt = 0; attempt < CORSAIR_V2_PID_RETRIES; attempt++)
|
|
{
|
|
value = GetAddress(address);
|
|
|
|
if(value != NA && value != 0)
|
|
{
|
|
return value;
|
|
}
|
|
|
|
if(attempt + 1 < CORSAIR_V2_PID_RETRIES)
|
|
{
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(CORSAIR_V2_PID_RETRY_DELAY));
|
|
}
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
unsigned int CorsairPeripheralV2Controller::GetAddress(uint8_t address)
|
|
{
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
uint8_t read[CORSAIR_V2_PACKET_SIZE];
|
|
|
|
memset(buffer, 0, pkt_sze);
|
|
memset(read, 0, pkt_sze);
|
|
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_GET;
|
|
buffer[3] = address;
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
hid_read_timeout(dev, read, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
|
|
unsigned int temp = (unsigned int)(read[6] << 24 | read[5] << 16 | read[4] << 8 | read[3]);
|
|
LOG_DEBUG("[%s] GetAddress %02X - %02X %02X - %02X %02X %02X %02X %02X %02X %02X %02X", device_name.c_str(),
|
|
address, read[0], read[1], read[2], read[3], read[4], read[5], read[6], read[7], read[8], read[9]);
|
|
|
|
uint8_t result = read[2];
|
|
if(result > 0)
|
|
{
|
|
LOG_DEBUG("[%s] An error occurred! Get Address %02X failed - %d %s", device_name.c_str(),
|
|
address, result, GetErrorString(result).c_str());
|
|
return -1;
|
|
}
|
|
return temp;
|
|
}
|
|
|
|
unsigned char CorsairPeripheralV2Controller::StartTransaction(uint8_t opt1)
|
|
{
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
|
|
memset(buffer, 0, pkt_sze);
|
|
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_START_TX;
|
|
buffer[3] = opt1;
|
|
buffer[4] = light_ctrl;
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
|
|
if(!skip_reads)
|
|
{
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
}
|
|
|
|
return buffer[2];
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::StopTransaction(uint8_t opt1)
|
|
{
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
|
|
memset(buffer, 0, pkt_sze);
|
|
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_STOP_TX;
|
|
buffer[3] = 0x01;
|
|
buffer[4] = opt1;
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
|
|
if(!skip_reads)
|
|
{
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
}
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::ClearPacketBuffer()
|
|
{
|
|
if(skip_reads)
|
|
{
|
|
return;
|
|
}
|
|
|
|
uint8_t result = 0;
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Draining expects to find nothing, so the last read here |
|
|
| always waits out the timeout. Keep it short: this runs |
|
|
| before every LED update. |
|
|
\*---------------------------------------------------------*/
|
|
do
|
|
{
|
|
result = hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT_SHORT);
|
|
}
|
|
while(result > 0);
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::SetLEDs(uint8_t *data, uint16_t data_size)
|
|
{
|
|
std::lock_guard<std::mutex> lock(device_mutex);
|
|
|
|
const uint8_t offset1 = 8;
|
|
const uint8_t offset2 = 4;
|
|
uint16_t remaining = data_size;
|
|
|
|
uint8_t buffer[CORSAIR_V2_PACKET_SIZE];
|
|
memset(buffer, 0, CORSAIR_V2_PACKET_SIZE);
|
|
|
|
ClearPacketBuffer();
|
|
StartTransaction(0);
|
|
/*---------------------------------------------------------*\
|
|
| Set the data header in packet 1 with the data length |
|
|
| signaling how many packets to expect to the device |
|
|
\*---------------------------------------------------------*/
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_BLK_W1;
|
|
buffer[4] = data_size & 0xFF;
|
|
buffer[5] = data_size >> 8;
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Check if the data needs more than 1 packet |
|
|
\*---------------------------------------------------------*/
|
|
uint16_t copy_bytes = pkt_sze - offset1;
|
|
if(remaining < copy_bytes)
|
|
{
|
|
copy_bytes = remaining;
|
|
}
|
|
|
|
memcpy(&buffer[offset1], &data[0], copy_bytes);
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
|
|
if(!skip_reads)
|
|
{
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
}
|
|
|
|
remaining -= copy_bytes;
|
|
buffer[2] = CORSAIR_V2_CMD_BLK_WN;
|
|
copy_bytes = pkt_sze - offset2;
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Send the remaining packets |
|
|
\*---------------------------------------------------------*/
|
|
while(remaining)
|
|
{
|
|
uint16_t index = data_size - remaining;
|
|
if(remaining < copy_bytes)
|
|
{
|
|
memset(&buffer[offset2], 0, copy_bytes);
|
|
copy_bytes = remaining;
|
|
}
|
|
|
|
memcpy(&buffer[offset2], &data[index], copy_bytes);
|
|
|
|
hid_write(dev, buffer, pkt_sze);
|
|
|
|
if(!skip_reads)
|
|
{
|
|
hid_read_timeout(dev, buffer, pkt_sze, CORSAIR_V2_TIMEOUT);
|
|
}
|
|
|
|
remaining -= copy_bytes;
|
|
}
|
|
|
|
StopTransaction(0);
|
|
}
|
|
|
|
void CorsairPeripheralV2Controller::UpdateHWMode(uint16_t mode, corsair_v2_color /*color_mode*/, uint8_t /*speed*/,
|
|
uint8_t /*direction*/, uint8_t /*brightness*/, std::vector<RGBColor> /*colors*/)
|
|
{
|
|
std::lock_guard<std::mutex> lock(device_mutex);
|
|
|
|
/*---------------------------------------------------------*\
|
|
| If we are switching to `Direct` mode |
|
|
| set device in software mode |
|
|
\*---------------------------------------------------------*/
|
|
if(mode == CORSAIR_V2_MODE_DIRECT)
|
|
{
|
|
SetRenderMode(CORSAIR_V2_MODE_SW);
|
|
return;
|
|
}
|
|
|
|
/*
|
|
SetRenderMode(CORSAIR_V2_MODE_HW);
|
|
|
|
uint8_t buffer[CORSAIR_V2_WRITE_SIZE];
|
|
memset(buffer, 0, CORSAIR_V2_WRITE_SIZE);
|
|
*/
|
|
|
|
/*---------------------------------------------------------*\
|
|
| Set the data header in packet 1 with the data length |
|
|
| signaling how many packets to expect to the device |
|
|
\*---------------------------------------------------------*/
|
|
|
|
/*
|
|
buffer[1] = write_cmd;
|
|
buffer[2] = CORSAIR_V2_CMD_BLK_W1;
|
|
buffer[3] = CORSAIR_V2_MODE_HW;
|
|
buffer[4] = 0x30;
|
|
buffer[8] = mode & 0xFF;
|
|
buffer[9] = mode >> 8;
|
|
|
|
buffer[10] = CORSAIR_V2_COLOR_SPECIFIC;
|
|
buffer[11] = speed;
|
|
|
|
buffer[14] = colors.size();
|
|
|
|
for(size_t i = 0; i < colors.size(); ++i)
|
|
{
|
|
uint8_t offset = 15 + (i * 4);
|
|
|
|
buffer[offset] = brightness;
|
|
buffer[offset + 1] = RGBGetBValue(colors[i]);
|
|
buffer[offset + 2] = RGBGetGValue(colors[i]);
|
|
buffer[offset + 3] = RGBGetRValue(colors[i]);
|
|
}
|
|
*/
|
|
|
|
}
|