samples: boards: Add sample application based on the STM32 USB TCPC Driver
This sample application uses the STM32 USB TCPC Driver to create a USBC Sink Application for the STM32G081b_eval board. Signed-off-by: Sam Hurst <sbh1187@gmail.com>
This commit is contained in:
parent
ffec52c0cc
commit
986093c8cc
10 changed files with 1156 additions and 0 deletions
9
samples/boards/stm32/usbc/sink/CMakeLists.txt
Normal file
9
samples/boards/stm32/usbc/sink/CMakeLists.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
cmake_minimum_required(VERSION 3.20.0)
|
||||
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
|
||||
project(usbc_sink)
|
||||
|
||||
target_sources(app PRIVATE src/main.c)
|
||||
target_sources(app PRIVATE src/usbc_snk.c)
|
||||
target_sources(app PRIVATE src/stm32g081b_eval_board.c)
|
86
samples/boards/stm32/usbc/sink/README.rst
Normal file
86
samples/boards/stm32/usbc/sink/README.rst
Normal file
|
@ -0,0 +1,86 @@
|
|||
.. _sink-sample:
|
||||
|
||||
Basic USBC SINK
|
||||
###############
|
||||
|
||||
Overview
|
||||
********
|
||||
|
||||
This example demonstrates how to create a USBC Power Delivery application
|
||||
using a TypeC Port Controller (TCPC) driver. The application implements a
|
||||
USBC Sink device.
|
||||
|
||||
After the USBC Sink device is powered, an LED begins to blink and
|
||||
when the USBC Sink device is plugged into a Power Delivery charger, it
|
||||
negotiates with the charger to provide 5V@100mA and displays all
|
||||
Power Delivery Objects (PDOs) provided by the charger.
|
||||
|
||||
Please note that this example does not implement any of the features and
|
||||
requiresments outlined in the USBC Specification needed to create a robust
|
||||
USBC Sink device. It is meant for demonstration purposes only.
|
||||
|
||||
.. _sink-sample-requirements:
|
||||
|
||||
Requirements
|
||||
************
|
||||
|
||||
The TCPC device that's used by the sample is specified by defining a devicetree
|
||||
node label named ``tcpc``.
|
||||
The sample has been tested on :ref:`stm32g081b_eval_board` and provides an
|
||||
overlay file for both board.
|
||||
|
||||
For the :ref:`stm32g081b_eval_board`, Port 2 is configured as a Sink and Port 1
|
||||
is unused. So, the charger must be plugged into Port 2.
|
||||
|
||||
Building and Running
|
||||
********************
|
||||
|
||||
Build and flash Sink as follows, changing ``stm32g081b_eval_board`` for your board:
|
||||
|
||||
.. zephyr-app-commands::
|
||||
:zephyr-app: samples/boards/usbc/sink
|
||||
:board: stm32g081b_eval_board
|
||||
:goals: build flash
|
||||
:compact:
|
||||
|
||||
After flashing, the LED starts to blink. Connect a charger and see console output:
|
||||
|
||||
Sample Output
|
||||
=============
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
UnAttached.SNK
|
||||
AttachedWait.SNK
|
||||
Attached.SNK
|
||||
Got PD_CTRL_ACCEPT
|
||||
Got PD_CTRL_PS_RDY
|
||||
Source Caps:
|
||||
PDO 0:
|
||||
Type: FIXED
|
||||
DRP: 0
|
||||
Suspend: 0
|
||||
UP: 1
|
||||
USB Comm: 0
|
||||
DRD: 0
|
||||
Voltage: 5000 mV
|
||||
Current: 2400 mA
|
||||
PDO 1:
|
||||
Type: FIXED
|
||||
DRP: 0
|
||||
Suspend: 0
|
||||
UP: 0
|
||||
USB Comm: 0
|
||||
DRD: 0
|
||||
Voltage: 9000 mV
|
||||
Current: 3000 mA
|
||||
PDO 2:
|
||||
Type: FIXED
|
||||
DRP: 0
|
||||
Suspend: 0
|
||||
UP: 0
|
||||
USB Comm: 0
|
||||
DRD: 0
|
||||
Voltage: 20000 mV
|
||||
Current: 3000 mA
|
||||
SNK_READY
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* Copyright (c) 2022 The Chromium OS Authors.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
*/
|
||||
|
||||
/ {
|
||||
aliases {
|
||||
tcpc = &ucpd2;
|
||||
};
|
||||
};
|
4
samples/boards/stm32/usbc/sink/prj.conf
Normal file
4
samples/boards/stm32/usbc/sink/prj.conf
Normal file
|
@ -0,0 +1,4 @@
|
|||
CONFIG_USBC_TCPC_DRIVER=y
|
||||
CONFIG_SMF=y
|
||||
CONFIG_LOG=y
|
||||
CONFIG_ADC=y
|
12
samples/boards/stm32/usbc/sink/sample.yaml
Normal file
12
samples/boards/stm32/usbc/sink/sample.yaml
Normal file
|
@ -0,0 +1,12 @@
|
|||
sample:
|
||||
name: USBC SINK
|
||||
tests:
|
||||
sample.usbc.sink:
|
||||
depends_on: tcpc
|
||||
tags: usbc
|
||||
platform_allow: stm32g081b_eval
|
||||
harness: console
|
||||
harness_config:
|
||||
type: one_line
|
||||
regex:
|
||||
- "Unattached.SNK"
|
43
samples/boards/stm32/usbc/sink/src/board.h
Normal file
43
samples/boards/stm32/usbc/sink/src/board.h
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2022 The Chromium OS Authors.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#ifndef __BOARD_H__
|
||||
#define __BOARD_H__
|
||||
|
||||
/**
|
||||
* @brief Configure the board
|
||||
*/
|
||||
int board_config(void);
|
||||
|
||||
/**
|
||||
* @brief Control a board specific led
|
||||
*
|
||||
* @param val LED is off when 0, else on
|
||||
*/
|
||||
void board_led(int val);
|
||||
|
||||
/**
|
||||
* @brief Measure VBUS in a board specific way
|
||||
*
|
||||
* @param mv pointer where VBUS, in millivolts, is stored
|
||||
*
|
||||
* @return 0 on success
|
||||
* @return -EINVAL on failure
|
||||
*/
|
||||
int board_vbus_meas(int *mv);
|
||||
|
||||
/**
|
||||
* @brief Discharge VBUS in a board specific way
|
||||
*
|
||||
* @param en VBUS is discharged when true, else it is not
|
||||
*
|
||||
* @return 0 on success
|
||||
* @return -EIO on failure
|
||||
* @return -ENOTSUP if not supported
|
||||
*/
|
||||
int board_discharge_vbus(int en);
|
||||
|
||||
#endif /* __BOARD_H__ */
|
62
samples/boards/stm32/usbc/sink/src/main.c
Normal file
62
samples/boards/stm32/usbc/sink/src/main.c
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright (c) 2022 The Chromium OS Authors.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include <zephyr.h>
|
||||
#include <device.h>
|
||||
#include <devicetree.h>
|
||||
#include <drivers/gpio.h>
|
||||
|
||||
#include <logging/log.h>
|
||||
LOG_MODULE_REGISTER(main, LOG_LEVEL_DBG);
|
||||
|
||||
#include "usbc_snk.h"
|
||||
#include "board.h"
|
||||
|
||||
/* 1 s = 200 * 5 msec */
|
||||
#define LED_TOGGLE_TIME 200
|
||||
|
||||
void main(void)
|
||||
{
|
||||
int ret;
|
||||
int loop_interval;
|
||||
int timer;
|
||||
int led_is_on;
|
||||
|
||||
/* Config the board */
|
||||
ret = board_config();
|
||||
if (ret) {
|
||||
LOG_ERR("Could not configure board");
|
||||
return;
|
||||
}
|
||||
|
||||
/* Initialize the Sink State Machine */
|
||||
ret = sink_init();
|
||||
if (ret) {
|
||||
LOG_ERR("Could not initialize sink state machine\n");
|
||||
return;
|
||||
}
|
||||
|
||||
led_is_on = 0;
|
||||
timer = 0;
|
||||
|
||||
/* Run Sink State Machine on 5 msec loop interval */
|
||||
while (1) {
|
||||
/* Run Sink State Machine */
|
||||
loop_interval = sink_sm();
|
||||
|
||||
/* Update LED toggle timer */
|
||||
timer++;
|
||||
|
||||
/* Toggle Board LED */
|
||||
if (timer == LED_TOGGLE_TIME) {
|
||||
timer = 0;
|
||||
board_led(led_is_on);
|
||||
led_is_on = !led_is_on;
|
||||
}
|
||||
|
||||
k_msleep(loop_interval);
|
||||
}
|
||||
}
|
128
samples/boards/stm32/usbc/sink/src/stm32g081b_eval_board.c
Normal file
128
samples/boards/stm32/usbc/sink/src/stm32g081b_eval_board.c
Normal file
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Copyright (c) 2022 The Chromium OS Authors
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include <zephyr.h>
|
||||
#include <device.h>
|
||||
#include <devicetree.h>
|
||||
#include <drivers/gpio.h>
|
||||
#include <drivers/adc.h>
|
||||
|
||||
#include <logging/log.h>
|
||||
LOG_MODULE_REGISTER(board, LOG_LEVEL_DBG);
|
||||
|
||||
#define VBUS DT_PATH(vbus)
|
||||
#define DISCHARGE_VBUS_NODE DT_PATH(discharge_vbus2_config, discharge_vbus2)
|
||||
#define LED0_NODE DT_ALIAS(led0)
|
||||
|
||||
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
|
||||
static const struct gpio_dt_spec discharge_vbus = GPIO_DT_SPEC_GET(DISCHARGE_VBUS_NODE, gpios);
|
||||
|
||||
/* Common settings supported by most ADCs */
|
||||
#define ADC_RESOLUTION 12
|
||||
#define ADC_GAIN ADC_GAIN_1
|
||||
#define ADC_REFERENCE ADC_REF_INTERNAL
|
||||
#define ADC_ACQUISITION_TIME ADC_ACQ_TIME_DEFAULT
|
||||
#define ADC_REF_MV 3300
|
||||
|
||||
static const struct device *dev_adc;
|
||||
static int16_t sample_buffer;
|
||||
|
||||
static const uint32_t output_ohm = DT_PROP(VBUS, output_ohms);
|
||||
static const uint32_t full_ohm = DT_PROP(VBUS, full_ohms);
|
||||
|
||||
struct adc_channel_cfg channel_cfg = {
|
||||
.gain = ADC_GAIN,
|
||||
.reference = ADC_REFERENCE,
|
||||
.acquisition_time = ADC_ACQUISITION_TIME,
|
||||
.differential = 0
|
||||
};
|
||||
|
||||
struct adc_sequence sequence = {
|
||||
.channels = BIT(3),
|
||||
.buffer = &sample_buffer,
|
||||
/* buffer size in bytes, not number of samples */
|
||||
.buffer_size = sizeof(sample_buffer),
|
||||
.resolution = ADC_RESOLUTION,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief This function is used to measure the VBUS in millivolts
|
||||
*/
|
||||
int board_vbus_meas(int *mv)
|
||||
{
|
||||
int32_t value;
|
||||
int ret;
|
||||
|
||||
ret = adc_read(dev_adc, &sequence);
|
||||
if (ret != 0) {
|
||||
printk("ADC reading failed with error %d.\n", ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
value = sample_buffer;
|
||||
ret = adc_raw_to_millivolts(ADC_REF_MV, ADC_GAIN, ADC_RESOLUTION, &value);
|
||||
if (ret != 0) {
|
||||
printk("Scaling ADC failed with error %d.\n", ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* VBUS is scaled down though a voltage divider */
|
||||
*mv = (value * 1000) / ((output_ohm * 1000) / full_ohm);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief This function discharges VBUS
|
||||
*/
|
||||
int board_discharge_vbus(int en)
|
||||
{
|
||||
gpio_pin_set_dt(&discharge_vbus, en);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void board_led(int val)
|
||||
{
|
||||
gpio_pin_set_dt(&led, val);
|
||||
}
|
||||
|
||||
int board_config(void)
|
||||
{
|
||||
int ret;
|
||||
|
||||
dev_adc = DEVICE_DT_GET(DT_IO_CHANNELS_CTLR(VBUS));
|
||||
if (!device_is_ready(dev_adc)) {
|
||||
printk("ADC device not found\n");
|
||||
return -ENODEV;
|
||||
}
|
||||
|
||||
if (!device_is_ready(led.port)) {
|
||||
LOG_ERR("LED device not ready");
|
||||
return -ENODEV;
|
||||
}
|
||||
|
||||
if (!device_is_ready(discharge_vbus.port)) {
|
||||
LOG_ERR("Discharge VBUS device not ready");
|
||||
return -ENODEV;
|
||||
}
|
||||
|
||||
adc_channel_setup(dev_adc, &channel_cfg);
|
||||
|
||||
ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
|
||||
if (ret) {
|
||||
LOG_ERR("Could not configure LED pin");
|
||||
return ret;
|
||||
}
|
||||
|
||||
ret = gpio_pin_configure_dt(&discharge_vbus, GPIO_OUTPUT_ACTIVE);
|
||||
if (ret) {
|
||||
LOG_ERR("Could not configure VBUS Discharge pin");
|
||||
return ret;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
775
samples/boards/stm32/usbc/sink/src/usbc_snk.c
Normal file
775
samples/boards/stm32/usbc/sink/src/usbc_snk.c
Normal file
|
@ -0,0 +1,775 @@
|
|||
/*
|
||||
* Copyright (c) 2022 The Chromium OS Authors.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#include <zephyr.h>
|
||||
#include <device.h>
|
||||
#include <devicetree.h>
|
||||
#include <smf.h>
|
||||
#include <drivers/usbc/usbc_tcpc.h>
|
||||
|
||||
#include "board.h"
|
||||
|
||||
#include <logging/log.h>
|
||||
LOG_MODULE_REGISTER(snk, LOG_LEVEL_DBG);
|
||||
|
||||
#define TCPC_NODE DT_ALIAS(tcpc)
|
||||
|
||||
/**
|
||||
* @brief Loop time: 5 ms
|
||||
*/
|
||||
#define LOOP_TIMEOUT_5MS 5
|
||||
|
||||
/**
|
||||
* @brief CC debounce time converted to 5mS counts
|
||||
*/
|
||||
#define T_CC_DEBOUNCE (TC_T_CC_DEBOUNCE_MIN_MS / LOOP_TIMEOUT_5MS)
|
||||
|
||||
/**
|
||||
* @brief RP value change time converted to 5mS counts
|
||||
*/
|
||||
#define T_RP_VALUE_CHANGE (TC_T_RP_VALUE_CHANGE_MIN_MS / LOOP_TIMEOUT_5MS)
|
||||
|
||||
/**
|
||||
* @brief Sink wait for source cap time converted to 5mS counts
|
||||
*/
|
||||
#define T_SINK_WAIT_CAP (PD_T_TYPEC_SINK_WAIT_CAP_MAX_MS / LOOP_TIMEOUT_5MS)
|
||||
|
||||
|
||||
/* PD message received flag*/
|
||||
#define FLAG_PDMSG_RECEIVED BIT(0)
|
||||
/* Hard Reset message received flag */
|
||||
#define FLAG_HARD_RESET_RECEIVED BIT(1)
|
||||
/* CC event flag */
|
||||
#define FLAG_CC_EVENT BIT(2)
|
||||
/* PD Message Send Failed */
|
||||
#define FLAG_PDMSG_FAILED BIT(3)
|
||||
/* PD Message Send was Discarded */
|
||||
#define FLAG_PDMSG_DISCARDED BIT(4)
|
||||
/* PD Message Send was successful */
|
||||
#define FLAG_PDMSG_SENT BIT(5)
|
||||
|
||||
/*
|
||||
* USB Type-C Sink
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief List of all TypeC-level states
|
||||
*/
|
||||
enum usb_tc_state {
|
||||
/** Unattached Sink */
|
||||
TC_UNATTACHED_SNK,
|
||||
/** Attach Wait Sink */
|
||||
TC_ATTACH_WAIT_SNK,
|
||||
/** Attached Sink */
|
||||
TC_ATTACHED_SNK,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Power Delivery states
|
||||
*/
|
||||
enum pd_states {
|
||||
/** Sink wait for source caps */
|
||||
SNK_WAIT_FOR_CAPABILITIES,
|
||||
/** Sink evaluate source caps */
|
||||
SNK_EVALUATE_CAPABILITY,
|
||||
/** Sink select source caps */
|
||||
SNK_SELECT_CAPABILITY,
|
||||
/** SinK transition sink */
|
||||
SNK_TRANSITION_SINK,
|
||||
/** Sink ready */
|
||||
SNK_READY,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Generic substates
|
||||
*/
|
||||
enum substate {
|
||||
/** Generic substate 0 */
|
||||
SUB_STATE0,
|
||||
/** Generic substate 1 */
|
||||
SUB_STATE1,
|
||||
/** Generic substate 2 */
|
||||
SUB_STATE2,
|
||||
/** Generic substate 3 */
|
||||
SUB_STATE3,
|
||||
};
|
||||
|
||||
/* TypeC Only Power strings (No PD) */
|
||||
static const char *const pwr2_5_str = "5V/0.5A";
|
||||
static const char *const pwr7_5_str = "5V/1.5A";
|
||||
static const char *const pwr15_str = "5V/3A";
|
||||
static const char *const pwr_open_str = "Removed";
|
||||
|
||||
/**
|
||||
* @brief TypeC PD object
|
||||
*/
|
||||
static struct tc_t {
|
||||
/** state machine context */
|
||||
struct smf_ctx ctx;
|
||||
/** TypeC Port Controller device */
|
||||
const struct device *tcpc_dev;
|
||||
/** VBUS measurement device */
|
||||
const struct device *vbus_dev;
|
||||
/** Port polarity */
|
||||
enum tc_cc_polarity cc_polarity;
|
||||
/** Time a port shall wait before it can determine it is attached */
|
||||
uint32_t cc_debounce;
|
||||
/** The cc state */
|
||||
enum tc_cc_states cc_state;
|
||||
/** Voltage on CC pin */
|
||||
enum tc_cc_voltage_state cc_voltage;
|
||||
/** Current CC1 value */
|
||||
enum tc_cc_voltage_state cc1;
|
||||
/** Current CC2 value */
|
||||
enum tc_cc_voltage_state cc2;
|
||||
/** PD message for RX and TX */
|
||||
struct pd_msg msg;
|
||||
/** PD connection flag. Also used as PD contract in place flag */
|
||||
bool pd_connected;
|
||||
/** PD state */
|
||||
enum pd_states pd_state;
|
||||
/** Event flags */
|
||||
uint32_t flag;
|
||||
/** Transmission message id count */
|
||||
uint32_t msg_id_cnt;
|
||||
/** Receive message id */
|
||||
uint32_t msg_id;
|
||||
/** General timer */
|
||||
uint32_t timer;
|
||||
/** Sub state variable */
|
||||
enum substate sub_state;
|
||||
/** Source caps */
|
||||
uint32_t src_caps[7];
|
||||
/** Source caps header */
|
||||
union pd_header src_caps_hdr;
|
||||
} tc;
|
||||
|
||||
static void set_state_tc(const enum usb_tc_state new_state);
|
||||
|
||||
/**
|
||||
* @brief Sink startup
|
||||
*/
|
||||
static void snk_startup(struct tc_t *tc)
|
||||
{
|
||||
tc->flag = 0;
|
||||
tc->msg_id_cnt = 0;
|
||||
tc->msg_id = 0;
|
||||
tc->timer = 0;
|
||||
|
||||
/* Not currently PD connected */
|
||||
tc->pd_connected = false;
|
||||
|
||||
/* Initialize PD state */
|
||||
tc->pd_state = SNK_WAIT_FOR_CAPABILITIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sink power sub states. Only called if a PD contract is ot in place
|
||||
*/
|
||||
static void sink_power_sub_states(void)
|
||||
{
|
||||
enum tc_cc_voltage_state cc;
|
||||
enum tc_cc_voltage_state new_cc_voltage;
|
||||
char const *pwr;
|
||||
|
||||
cc = tc.cc_polarity ? tc.cc2 : tc.cc1;
|
||||
|
||||
if (cc == TC_CC_VOLT_RP_DEF) {
|
||||
new_cc_voltage = TC_CC_VOLT_RP_DEF;
|
||||
pwr = pwr2_5_str;
|
||||
} else if (cc == TC_CC_VOLT_RP_1A5) {
|
||||
new_cc_voltage = TC_CC_VOLT_RP_1A5;
|
||||
pwr = pwr7_5_str;
|
||||
} else if (cc == TC_CC_VOLT_RP_3A0) {
|
||||
new_cc_voltage = TC_CC_VOLT_RP_3A0;
|
||||
pwr = pwr15_str;
|
||||
} else {
|
||||
new_cc_voltage = TC_CC_VOLT_OPEN;
|
||||
pwr = pwr_open_str;
|
||||
}
|
||||
|
||||
/* Debounce the cc state */
|
||||
if (new_cc_voltage != tc.cc_voltage) {
|
||||
tc.cc_voltage = new_cc_voltage;
|
||||
tc.cc_debounce = T_RP_VALUE_CHANGE;
|
||||
} else if (tc.cc_debounce) {
|
||||
/* Update timer */
|
||||
tc.cc_debounce--;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* TYPE-C State Implementations
|
||||
*/
|
||||
|
||||
/**
|
||||
* @brief Unattached.SNK
|
||||
*/
|
||||
static void tc_unattached_snk_entry(void *tc_obj)
|
||||
{
|
||||
struct tc_t *tc = tc_obj;
|
||||
|
||||
LOG_INF("UnAttached.SNK");
|
||||
|
||||
tcpc_set_cc(tc->tcpc_dev, TC_CC_RD);
|
||||
tc->cc_voltage = TC_CC_VOLT_OPEN;
|
||||
}
|
||||
|
||||
static void tc_unattached_snk_run(void *tc_obj)
|
||||
{
|
||||
struct tc_t *tc = tc_obj;
|
||||
|
||||
/*
|
||||
* The port shall transition to AttachWait.SNK when a Source
|
||||
* connection is detected, as indicated by the SNK.Rp state
|
||||
* on at least one of its CC pins.
|
||||
*/
|
||||
if (tcpc_is_cc_rp(tc->cc1) || tcpc_is_cc_rp(tc->cc2)) {
|
||||
set_state_tc(TC_ATTACH_WAIT_SNK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief AttachWait.SNK
|
||||
*/
|
||||
static void tc_attach_wait_snk_entry(void *tc_obj)
|
||||
{
|
||||
struct tc_t *tc = tc_obj;
|
||||
|
||||
LOG_INF("AttachedWait.SNK");
|
||||
tc->cc_state = TC_CC_NONE;
|
||||
}
|
||||
|
||||
static void tc_attach_wait_snk_run(void *tc_obj)
|
||||
{
|
||||
|
||||
struct tc_t *tc = tc_obj;
|
||||
enum tc_cc_states new_cc_state;
|
||||
|
||||
if (tcpc_is_cc_rp(tc->cc1) && tcpc_is_cc_rp(tc->cc2)) {
|
||||
new_cc_state = TC_CC_DFP_DEBUG_ACC;
|
||||
} else if (tcpc_is_cc_rp(tc->cc1) || tcpc_is_cc_rp(tc->cc2)) {
|
||||
new_cc_state = TC_CC_DFP_ATTACHED;
|
||||
} else {
|
||||
new_cc_state = TC_CC_NONE;
|
||||
}
|
||||
|
||||
/* Debounce the cc state */
|
||||
if (new_cc_state != tc->cc_state) {
|
||||
tc->cc_debounce = T_CC_DEBOUNCE;
|
||||
tc->cc_state = new_cc_state;
|
||||
} else if (tc->cc_debounce) {
|
||||
/* Update timers */
|
||||
tc->cc_debounce--;
|
||||
}
|
||||
|
||||
/* Wait for CC debounce */
|
||||
if (tc->cc_debounce) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* The port shall transition to Attached.SNK after the state of only
|
||||
* one of the CC1 or CC2 pins is SNK.Rp for at least tCCDebounce and
|
||||
* VBUS is detected.
|
||||
*/
|
||||
if (tcpc_check_vbus_level(tc->tcpc_dev, TC_VBUS_PRESENT) &&
|
||||
(new_cc_state == TC_CC_DFP_ATTACHED)) {
|
||||
set_state_tc(TC_ATTACHED_SNK);
|
||||
} else {
|
||||
set_state_tc(TC_UNATTACHED_SNK);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @brief Display a single Power Delivery Object
|
||||
*/
|
||||
static void display_pdo(int idx, uint32_t pdo_value)
|
||||
{
|
||||
union pd_fixed_supply_pdo_source pdo;
|
||||
|
||||
/* Default to fixed supply pdo source until type is detected */
|
||||
pdo.raw_value = pdo_value;
|
||||
|
||||
LOG_INF("PDO %d:", idx);
|
||||
switch (pdo.type) {
|
||||
case PDO_FIXED: {
|
||||
LOG_INF("\tType: FIXED");
|
||||
LOG_INF("\tCurrent: %d",
|
||||
PD_CONVERT_FIXED_PDO_CURRENT_TO_MA(pdo.max_current));
|
||||
LOG_INF("\tVoltage: %d",
|
||||
PD_CONVERT_FIXED_PDO_VOLTAGE_TO_MV(pdo.voltage));
|
||||
LOG_INF("\tPeak Current: %d", pdo.peak_current);
|
||||
LOG_INF("\tUchunked Support: %d",
|
||||
pdo.unchunked_ext_msg_supported);
|
||||
LOG_INF("\tDual Role Data: %d",
|
||||
pdo.dual_role_data);
|
||||
LOG_INF("\tUSB Comms: %d",
|
||||
pdo.usb_comms_capable);
|
||||
LOG_INF("\tUnconstrained Pwr: %d",
|
||||
pdo.unconstrained_power);
|
||||
LOG_INF("\tUSB Susspend: %d",
|
||||
pdo.usb_suspend_supported);
|
||||
LOG_INF("\tDual Role Power: %d",
|
||||
pdo.dual_role_power);
|
||||
}
|
||||
break;
|
||||
case PDO_BATTERY: {
|
||||
union pd_battery_supply_pdo_source pdo;
|
||||
|
||||
pdo.raw_value = pdo_value;
|
||||
LOG_INF("\tType: BATTERY");
|
||||
LOG_INF("\tMin Voltage: %d",
|
||||
PD_CONVERT_BATTERY_PDO_VOLTAGE_TO_MV(pdo.min_voltage));
|
||||
LOG_INF("\tMax Voltage: %d",
|
||||
PD_CONVERT_BATTERY_PDO_VOLTAGE_TO_MV(pdo.max_voltage));
|
||||
LOG_INF("\tMax Power: %d",
|
||||
PD_CONVERT_BATTERY_PDO_POWER_TO_MW(pdo.max_power));
|
||||
}
|
||||
break;
|
||||
case PDO_VARIABLE: {
|
||||
union pd_variable_supply_pdo_source pdo;
|
||||
|
||||
pdo.raw_value = pdo_value;
|
||||
LOG_INF("\tType: VARIABLE");
|
||||
LOG_INF("\tMin Voltage: %d",
|
||||
PD_CONVERT_VARIABLE_PDO_VOLTAGE_TO_MV(pdo.min_voltage));
|
||||
LOG_INF("\tMax Voltage: %d",
|
||||
PD_CONVERT_VARIABLE_PDO_VOLTAGE_TO_MV(pdo.max_voltage));
|
||||
LOG_INF("\tMax Current: %d",
|
||||
PD_CONVERT_VARIABLE_PDO_CURRENT_TO_MA(pdo.max_current));
|
||||
}
|
||||
break;
|
||||
case PDO_AUGMENTED: {
|
||||
union pd_augmented_supply_pdo_source pdo;
|
||||
|
||||
pdo.raw_value = pdo_value;
|
||||
LOG_INF("\tType: AUGMENTED");
|
||||
LOG_INF("\tMin Voltage: %d",
|
||||
PD_CONVERT_AUGMENTED_PDO_VOLTAGE_TO_MV(pdo.min_voltage));
|
||||
LOG_INF("\tMax Voltage: %d",
|
||||
PD_CONVERT_AUGMENTED_PDO_VOLTAGE_TO_MV(pdo.max_voltage));
|
||||
LOG_INF("\tMax Current: %d",
|
||||
PD_CONVERT_AUGMENTED_PDO_CURRENT_TO_MA(pdo.max_current));
|
||||
LOG_INF("\tPPS Power Limited: %d", pdo.pps_power_limited);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Display source caps
|
||||
*/
|
||||
static void display_source_caps(struct tc_t *tc)
|
||||
{
|
||||
int pdo_num;
|
||||
union pd_header header = tc->src_caps_hdr;
|
||||
|
||||
/* Make sure the message is a source caps */
|
||||
if (header.number_of_data_objects == 0 ||
|
||||
header.message_type != PD_DATA_SOURCE_CAP) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Get number of Power Data Objects (PDOs) */
|
||||
pdo_num = header.number_of_data_objects;
|
||||
|
||||
LOG_INF("Source Caps:");
|
||||
for (int i = 0; i < pdo_num; i++) {
|
||||
display_pdo(i, tc->src_caps[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Attached.SNK
|
||||
*/
|
||||
static void tc_attached_snk_entry(void *tc_obj)
|
||||
{
|
||||
struct tc_t *tc = tc_obj;
|
||||
|
||||
LOG_INF("Attached.SNK");
|
||||
|
||||
snk_startup(tc);
|
||||
|
||||
/* Set CC polarity */
|
||||
tcpc_set_cc_polarity(tc->tcpc_dev, tc->cc_polarity);
|
||||
|
||||
/* Enable PD */
|
||||
tcpc_set_rx_enable(tc->tcpc_dev, true);
|
||||
}
|
||||
|
||||
static void tc_attached_snk_run(void *tc_obj)
|
||||
{
|
||||
struct tc_t *tc = tc_obj;
|
||||
union pd_rdo rdo;
|
||||
|
||||
/* Detach detection */
|
||||
if (!tcpc_check_vbus_level(tc->tcpc_dev, TC_VBUS_PRESENT)) {
|
||||
set_state_tc(TC_UNATTACHED_SNK);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Check if Hard Reset was received */
|
||||
if (tc->flag & FLAG_HARD_RESET_RECEIVED) {
|
||||
/* Flag is cleard in snk_startup function */
|
||||
snk_startup(tc);
|
||||
}
|
||||
|
||||
switch (tc->pd_state) {
|
||||
case SNK_WAIT_FOR_CAPABILITIES:
|
||||
if (tc->timer == 0) {
|
||||
tc->timer = T_SINK_WAIT_CAP;
|
||||
} else {
|
||||
/* Update SinkWaitCapTimer */
|
||||
tc->timer--;
|
||||
if (tc->timer == 0) {
|
||||
LOG_INF("Source Caps not received. Continuing to wait!");
|
||||
tc->timer = T_SINK_WAIT_CAP;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* Check if a PD message was received */
|
||||
if (tc->flag & FLAG_PDMSG_RECEIVED) {
|
||||
/* Clear flag */
|
||||
tc->flag &= ~FLAG_PDMSG_RECEIVED;
|
||||
/* Get the message */
|
||||
if (!tcpc_receive_data(tc->tcpc_dev, &tc->msg)) {
|
||||
/* Problem getting the message */
|
||||
break;
|
||||
}
|
||||
/* Check if the message is a source caps message */
|
||||
if (tc->msg.header.number_of_data_objects == 0 ||
|
||||
tc->msg.header.message_type != PD_DATA_SOURCE_CAP) {
|
||||
/* Not a source caps message */
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
/* Message not received */
|
||||
break;
|
||||
}
|
||||
/* We got the source caps message */
|
||||
tc->pd_state = SNK_EVALUATE_CAPABILITY;
|
||||
__fallthrough;
|
||||
case SNK_EVALUATE_CAPABILITY:
|
||||
/* Save source caps header and payload */
|
||||
tc->src_caps_hdr = tc->msg.header;
|
||||
memcpy(tc->src_caps, tc->msg.data,
|
||||
PD_CONVERT_PD_HEADER_COUNT_TO_BYTES(tc->msg.header.number_of_data_objects));
|
||||
|
||||
/* Move on to the select capabilities */
|
||||
tc->pd_state = SNK_SELECT_CAPABILITY;
|
||||
tc->sub_state = SUB_STATE0;
|
||||
__fallthrough;
|
||||
case SNK_SELECT_CAPABILITY:
|
||||
switch (tc->sub_state) {
|
||||
case SUB_STATE0:
|
||||
/* Select the 5V PDO at index 1 */
|
||||
|
||||
/* Create the PD header */
|
||||
|
||||
/* Request message */
|
||||
tc->msg.header.message_type = PD_DATA_REQUEST;
|
||||
/* Always sink */
|
||||
tc->msg.header.port_power_role = TC_ROLE_SINK;
|
||||
/* Always upstream facing port */
|
||||
tc->msg.header.port_data_role = TC_ROLE_UFP;
|
||||
/* Message ID */
|
||||
tc->msg.header.message_id = tc->msg_id_cnt;
|
||||
/* one 4-byte Request Data Object (RDO) */
|
||||
tc->msg.header.number_of_data_objects = 1;
|
||||
/* PD Revision 2.0 */
|
||||
tc->msg.header.specification_revision = PD_REV20;
|
||||
/* Not an extended message */
|
||||
tc->msg.header.extended = 0;
|
||||
|
||||
/* Create the Request Data Object */
|
||||
|
||||
/* Maximum operating current 100mA (GIVEBACK = 0) */
|
||||
rdo.fixed.min_or_max_operating_current =
|
||||
PD_CONVERT_MA_TO_FIXED_PDO_CURRENT(100);
|
||||
/* Operating current 100mA */
|
||||
rdo.fixed.operating_current = PD_CONVERT_MA_TO_FIXED_PDO_CURRENT(100);
|
||||
/* Unchunked Extended Messages Not Supported */
|
||||
rdo.fixed.unchunked_ext_msg_supported = 0;
|
||||
/* No USB Suspend */
|
||||
rdo.fixed.no_usb_suspend = 1;
|
||||
/* Not USB Communications Capable */
|
||||
rdo.fixed.usb_comm_capable = 0;
|
||||
/* No capability mismatch */
|
||||
rdo.fixed.cap_mismatch = 0;
|
||||
/* Don't giveback */
|
||||
rdo.fixed.giveback = 0;
|
||||
/* Object position 1 (5V PDO) */
|
||||
rdo.fixed.object_pos = 1;
|
||||
|
||||
/* Set message payload. Select the 5V PDO */
|
||||
*(uint32_t *)tc->msg.data = rdo.raw_value;
|
||||
|
||||
/* Set packet type */
|
||||
tc->msg.type = PD_PACKET_SOP;
|
||||
|
||||
/* Set message length */
|
||||
tc->msg.len = 4;
|
||||
|
||||
/* Send the message */
|
||||
tcpc_transmit_data(tc->tcpc_dev, &tc->msg);
|
||||
tc->sub_state = SUB_STATE1;
|
||||
break;
|
||||
case SUB_STATE1:
|
||||
if (tc->flag & FLAG_PDMSG_SENT) {
|
||||
tc->msg_id_cnt++;
|
||||
tc->sub_state = SUB_STATE2;
|
||||
} else if (tc->flag &
|
||||
(FLAG_PDMSG_DISCARDED | FLAG_PDMSG_FAILED)) {
|
||||
LOG_INF("Failed to send Request message");
|
||||
set_state_tc(TC_UNATTACHED_SNK);
|
||||
} else {
|
||||
/* Waiting for message to send */
|
||||
break;
|
||||
}
|
||||
__fallthrough;
|
||||
case SUB_STATE2:
|
||||
/* Check if a PD message was received */
|
||||
if (tc->flag & FLAG_PDMSG_RECEIVED) {
|
||||
/* Clear flag */
|
||||
tc->flag &= ~FLAG_PDMSG_RECEIVED;
|
||||
/* Get the message */
|
||||
if (!tcpc_receive_data(tc->tcpc_dev,
|
||||
&tc->msg)) {
|
||||
/* Problem getting the message */
|
||||
break;
|
||||
}
|
||||
/* Check if the message is an Accept message */
|
||||
if (tc->msg.header.number_of_data_objects != 0 ||
|
||||
tc->msg.header.message_type != PD_CTRL_ACCEPT) {
|
||||
/* Not an Accept message */
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
/* Message not received */
|
||||
break;
|
||||
}
|
||||
LOG_INF("Received PD_CTRL_ACCEPT");
|
||||
tc->sub_state = SUB_STATE3;
|
||||
break;
|
||||
case SUB_STATE3:
|
||||
/* Check if a PD message was received */
|
||||
if (tc->flag & FLAG_PDMSG_RECEIVED) {
|
||||
/* Clear flag */
|
||||
tc->flag &= ~FLAG_PDMSG_RECEIVED;
|
||||
/* Get the message */
|
||||
if (!tcpc_receive_data(tc->tcpc_dev, &tc->msg)) {
|
||||
/* Problem getting the message */
|
||||
break;
|
||||
}
|
||||
/* Check if the message is a PS Ready message */
|
||||
if (tc->msg.header.number_of_data_objects != 0 ||
|
||||
tc->msg.header.message_type != PD_CTRL_PS_RDY) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
LOG_INF("Received PD_CTRL_PS_RDY");
|
||||
/* PD contract in place and PD connected */
|
||||
tc->pd_connected = true;
|
||||
tc->pd_state = SNK_TRANSITION_SINK;
|
||||
}
|
||||
break;
|
||||
case SNK_TRANSITION_SINK:
|
||||
/* Display Soruce Caps Received form Source */
|
||||
display_source_caps(tc);
|
||||
tc->sub_state = SUB_STATE0;
|
||||
tc->pd_state = SNK_READY;
|
||||
__fallthrough;
|
||||
case SNK_READY:
|
||||
switch (tc->sub_state) {
|
||||
case SUB_STATE0:
|
||||
LOG_INF("SNK_READY");
|
||||
tc->sub_state++;
|
||||
__fallthrough;
|
||||
case SUB_STATE1:
|
||||
/* STAY HERE FOREVER AND REJECT ALL RECEIVED MESSAGES */
|
||||
|
||||
/* Check if a PD message was received */
|
||||
if (tc->flag & FLAG_PDMSG_RECEIVED) {
|
||||
/* Clear flag */
|
||||
tc->flag &= ~FLAG_PDMSG_RECEIVED;
|
||||
|
||||
/* Create the PD header */
|
||||
|
||||
/* Reject message */
|
||||
tc->msg.header.message_type = PD_CTRL_REJECT;
|
||||
/* Always sink */
|
||||
tc->msg.header.port_power_role = TC_ROLE_SINK;
|
||||
/* Always upstream facing port */
|
||||
tc->msg.header.port_data_role = TC_ROLE_UFP;
|
||||
/* Message ID */
|
||||
tc->msg.header.message_id = tc->msg_id_cnt;
|
||||
/* one 4-byte Request Data Object (RDO) */
|
||||
tc->msg.header.number_of_data_objects = 0;
|
||||
/* PD Revision 2.0 */
|
||||
tc->msg.header.specification_revision = PD_REV20;
|
||||
/* Not an extended message */
|
||||
tc->msg.header.extended = 0;
|
||||
|
||||
/* Set packet type */
|
||||
tc->msg.type = PD_PACKET_SOP;
|
||||
|
||||
/* Set message length */
|
||||
tc->msg.len = 0;
|
||||
|
||||
/* Send the message */
|
||||
tcpc_transmit_data(tc->tcpc_dev, &tc->msg);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
/* Only the sink pwer sub states when we are not in a pd contract */
|
||||
if (!tc->pd_connected) {
|
||||
/* Run Sink Power Sub-State */
|
||||
sink_power_sub_states();
|
||||
}
|
||||
}
|
||||
|
||||
static void tc_attached_snk_exit(void *tc_obj)
|
||||
{
|
||||
struct tc_t *tc = tc_obj;
|
||||
|
||||
tc->pd_connected = false;
|
||||
/* Disable PD */
|
||||
tcpc_set_rx_enable(tc->tcpc_dev, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Type-C State Table
|
||||
*/
|
||||
static struct smf_state tc_states[] = {
|
||||
/** Unattached Sink */
|
||||
[TC_UNATTACHED_SNK] = {
|
||||
.entry = tc_unattached_snk_entry,
|
||||
.run = tc_unattached_snk_run,
|
||||
},
|
||||
/** Attach Wait Sink */
|
||||
[TC_ATTACH_WAIT_SNK] = {
|
||||
.entry = tc_attach_wait_snk_entry,
|
||||
.run = tc_attach_wait_snk_run,
|
||||
},
|
||||
/** Attached Sink */
|
||||
[TC_ATTACHED_SNK] = {
|
||||
.entry = tc_attached_snk_entry,
|
||||
.run = tc_attached_snk_run,
|
||||
.exit = tc_attached_snk_exit
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Set the TypeC state machine to a new state
|
||||
*/
|
||||
static void set_state_tc(const enum usb_tc_state new_state)
|
||||
{
|
||||
smf_set_state(SMF_CTX(&tc), &tc_states[new_state]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Alert Handler called by the TCPC driver
|
||||
*/
|
||||
static void alert_handler(const struct device *dev,
|
||||
void *data, enum tcpc_alert alert)
|
||||
{
|
||||
struct tc_t *tc = data;
|
||||
|
||||
switch (alert) {
|
||||
case TCPC_ALERT_CC_STATUS:
|
||||
tc->flag |= FLAG_CC_EVENT;
|
||||
break;
|
||||
case TCPC_ALERT_POWER_STATUS:
|
||||
break;
|
||||
case TCPC_ALERT_MSG_STATUS:
|
||||
tc->flag |= FLAG_PDMSG_RECEIVED;
|
||||
break;
|
||||
case TCPC_ALERT_HARD_RESET_RECEIVED:
|
||||
tc->flag |= FLAG_HARD_RESET_RECEIVED;
|
||||
break;
|
||||
case TCPC_ALERT_TRANSMIT_MSG_FAILED:
|
||||
tc->flag |= FLAG_PDMSG_FAILED;
|
||||
break;
|
||||
case TCPC_ALERT_TRANSMIT_MSG_DISCARDED:
|
||||
tc->flag |= FLAG_PDMSG_DISCARDED;
|
||||
break;
|
||||
case TCPC_ALERT_TRANSMIT_MSG_SUCCESS:
|
||||
tc->flag |= FLAG_PDMSG_SENT;
|
||||
break;
|
||||
/* These alerts are ignored */
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int discharge_vbus(const struct device *dev, bool en)
|
||||
{
|
||||
return board_discharge_vbus(en);
|
||||
}
|
||||
|
||||
int vbus_meas(const struct device *dev, int *vbus_meas)
|
||||
{
|
||||
int ret;
|
||||
|
||||
ret = board_vbus_meas(vbus_meas);
|
||||
if (ret != 0) {
|
||||
*vbus_meas = 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sink_init(void)
|
||||
{
|
||||
tc.tcpc_dev = DEVICE_DT_GET(TCPC_NODE);
|
||||
if (!device_is_ready(tc.tcpc_dev)) {
|
||||
LOG_ERR("TCPC device not ready");
|
||||
return -ENODEV;
|
||||
}
|
||||
|
||||
/* Set the Hard Reset Received callback */
|
||||
tcpc_set_alert_handler_cb(tc.tcpc_dev, alert_handler, &tc);
|
||||
|
||||
/* Set the VBUS measurement callback */
|
||||
tcpc_set_vbus_measure_cb(tc.tcpc_dev, vbus_meas);
|
||||
|
||||
/* Set the VBUS discharge callback */
|
||||
tcpc_set_discharge_vbus_cb(tc.tcpc_dev, discharge_vbus);
|
||||
|
||||
/* Remove resistors from CC1 */
|
||||
tcpc_set_cc(tc.tcpc_dev, TC_CC_OPEN);
|
||||
|
||||
/* Allow time for disconnection to be detected */
|
||||
k_msleep(100);
|
||||
|
||||
/* Unattached.SNK is the default starting state. */
|
||||
smf_set_initial(SMF_CTX(&tc), &tc_states[TC_UNATTACHED_SNK]);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sink_sm(void)
|
||||
{
|
||||
/* Sample CC lines */
|
||||
tcpc_get_cc(tc.tcpc_dev, &tc.cc1, &tc.cc2);
|
||||
|
||||
/* Detect polarity */
|
||||
tc.cc_polarity = (tc.cc1 > tc.cc2) ?
|
||||
TC_POLARITY_CC1 : TC_POLARITY_CC2;
|
||||
|
||||
/* Run TypeC state machine */
|
||||
smf_run_state(SMF_CTX(&tc));
|
||||
|
||||
return LOOP_TIMEOUT_5MS;
|
||||
}
|
25
samples/boards/stm32/usbc/sink/src/usbc_snk.h
Normal file
25
samples/boards/stm32/usbc/sink/src/usbc_snk.h
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2022 The Chromium OS Authors.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
#ifndef __USBC_SINK_H__
|
||||
#define __USBC_SINK_H__
|
||||
|
||||
/**
|
||||
* @brief Initialize the Sink state machine
|
||||
*
|
||||
* @return 0 on success
|
||||
* @return -EIO on failure
|
||||
*/
|
||||
int sink_init(void);
|
||||
|
||||
/**
|
||||
* @brief Run the Sink state machine
|
||||
*
|
||||
* @return time to wait before calling again
|
||||
*/
|
||||
int sink_sm(void);
|
||||
|
||||
#endif /* __USBC_SINK_H__ */
|
Loading…
Add table
Add a link
Reference in a new issue