mirror of
https://github.com/esphome/esphome.git
synced 2026-10-03 09:46:03 +00:00
feat: add support for nrf52 pwm
add tests update codeowners [pre-commit.ci lite] apply automatic fixes fix preproc if refactor to zephyr_pwm validate pwm channel limit Post copilot review support inverted & check min frequency typing for builder Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> protect parent Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> check max frequency support multiple blocks validate max pwm components fix test config refactor psels remove key_user
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
fd16eec416
commit
1310fe685a
@@ -619,6 +619,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zephyr_pwm/* @wiomoc
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
|
||||
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
zephyr_add_overlay_builder,
|
||||
zephyr_add_prj_conf,
|
||||
zephyr_add_user,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
@@ -113,6 +113,18 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
|
||||
def _overlay_io_channels():
|
||||
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
||||
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
||||
return f"""
|
||||
/ {{
|
||||
zephyr,user {{
|
||||
io-channels = {entries};
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -173,9 +185,8 @@ async def to_code(config):
|
||||
if isinstance(pin_number, int):
|
||||
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
|
||||
pin_number = GPIO_TO_AIN[pin_number]
|
||||
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
|
||||
zephyr_add_overlay(
|
||||
f"""
|
||||
zephyr_add_overlay_builder(_overlay_io_channels)
|
||||
zephyr_add_overlay(f"""
|
||||
&adc {{
|
||||
#address-cells = <1>;
|
||||
#size-cells = <0>;
|
||||
@@ -190,8 +201,7 @@ async def to_code(config):
|
||||
zephyr,oversampling = <8>;
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import textwrap
|
||||
from typing import TypedDict
|
||||
@@ -16,10 +17,10 @@ from .const import (
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_KCONFIG,
|
||||
KEY_OVERLAY,
|
||||
KEY_OVERLAY_BUILDER,
|
||||
KEY_PM_STATIC,
|
||||
KEY_PRJ_CONF,
|
||||
KEY_SYSBUILD,
|
||||
KEY_USER,
|
||||
KEY_ZEPHYR,
|
||||
zephyr_ns,
|
||||
)
|
||||
@@ -54,9 +55,9 @@ class ZephyrData(TypedDict):
|
||||
overlay: dict[str, str]
|
||||
extra_build_files: dict[str, Path]
|
||||
pm_static: list[Section]
|
||||
user: dict[str, list[str]]
|
||||
kconfig: str
|
||||
sysbuild: bool
|
||||
overlay_builder: list[Callable[[], str]]
|
||||
|
||||
|
||||
def zephyr_set_core_data(config: ConfigType) -> None:
|
||||
@@ -67,9 +68,9 @@ def zephyr_set_core_data(config: ConfigType) -> None:
|
||||
overlay={
|
||||
"": "",
|
||||
}, # set empty to make sure that overlay is cleared after config change
|
||||
overlay_builder=[],
|
||||
extra_build_files={},
|
||||
pm_static=[],
|
||||
user={},
|
||||
kconfig="",
|
||||
# When OTA is disabled, the image is built without a bootloader even if the
|
||||
# config says `bootloader: mcuboot`, so the image can be smaller. This was
|
||||
@@ -113,6 +114,12 @@ def zephyr_add_overlay(content: str, image: str = "") -> None:
|
||||
data[KEY_OVERLAY][image] += textwrap.dedent(content)
|
||||
|
||||
|
||||
def zephyr_add_overlay_builder(func: Callable[[], str]) -> None:
|
||||
data = zephyr_data()
|
||||
if func not in data[KEY_OVERLAY_BUILDER]:
|
||||
data[KEY_OVERLAY_BUILDER].append(func)
|
||||
|
||||
|
||||
def add_extra_build_file(filename: str, path: Path) -> bool:
|
||||
"""Add an extra build file to the project."""
|
||||
extra_build_files = zephyr_data()[KEY_EXTRA_BUILD_FILES]
|
||||
@@ -203,13 +210,6 @@ def zephyr_add_pm_static(sections: list[Section]) -> None:
|
||||
zephyr_data()[KEY_PM_STATIC].extend(sections)
|
||||
|
||||
|
||||
def zephyr_add_user(key, value):
|
||||
user = zephyr_data()[KEY_USER]
|
||||
if key not in user:
|
||||
user[key] = []
|
||||
user[key] += [value]
|
||||
|
||||
|
||||
def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> bool:
|
||||
"""Write content to path, or remove a stale file when content is empty.
|
||||
|
||||
@@ -224,20 +224,9 @@ def _write_file_if_changed_or_remove_when_empty(path: Path, content: str) -> boo
|
||||
|
||||
|
||||
def copy_files() -> None:
|
||||
user = zephyr_data()[KEY_USER]
|
||||
if user:
|
||||
entries = " ".join(
|
||||
f"{key} = {', '.join(value)};" for key, value in user.items()
|
||||
)
|
||||
zephyr_add_overlay(
|
||||
f"""
|
||||
/ {{
|
||||
zephyr,user {{
|
||||
{entries}
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
)
|
||||
for builder_func in zephyr_data()[KEY_OVERLAY_BUILDER]:
|
||||
overlay_contents = builder_func()
|
||||
zephyr_add_overlay(overlay_contents)
|
||||
|
||||
changed = False
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ BOOTLOADER_MCUBOOT = "mcuboot"
|
||||
KEY_BOOTLOADER: Final = "bootloader"
|
||||
KEY_EXTRA_BUILD_FILES: Final = "extra_build_files"
|
||||
KEY_OVERLAY: Final = "overlay"
|
||||
KEY_OVERLAY_BUILDER: Final = "overlay_builder"
|
||||
KEY_PM_STATIC: Final = "pm_static"
|
||||
KEY_KCONFIG: Final = "kconfig"
|
||||
KEY_PRJ_CONF: Final = "prj_conf"
|
||||
KEY_ZEPHYR = "zephyr"
|
||||
KEY_BOARD: Final = "board"
|
||||
KEY_USER: Final = "user"
|
||||
KEY_SYSBUILD: Final = "sysbuild"
|
||||
|
||||
zephyr_ns = cg.esphome_ns.namespace("zephyr")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CODEOWNERS = ["@wiomoc"]
|
||||
@@ -0,0 +1,163 @@
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
from esphome.components.zephyr import zephyr_add_overlay_builder, zephyr_add_prj_conf
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FREQUENCY,
|
||||
CONF_ID,
|
||||
CONF_INVERTED,
|
||||
CONF_NUMBER,
|
||||
CONF_OUTPUT,
|
||||
CONF_PIN,
|
||||
CONF_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["zephyr"]
|
||||
|
||||
zephyr_pwm_ns = cg.esphome_ns.namespace("zephyr_pwm")
|
||||
ZephyrPWMChannel = zephyr_pwm_ns.class_(
|
||||
"ZephyrPWMChannel", output.FloatOutput, cg.Component
|
||||
)
|
||||
SetFrequencyAction = zephyr_pwm_ns.class_("SetFrequencyAction", automation.Action)
|
||||
validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0, max=1e7))
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
output.FLOAT_OUTPUT_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(ZephyrPWMChannel),
|
||||
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
|
||||
cv.Optional(CONF_FREQUENCY, default="1kHz"): validate_frequency,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_nrf52,
|
||||
)
|
||||
|
||||
PWM_BLOCK_COUNT = 4
|
||||
PWM_CHANNELS_PER_BLOCK = 4
|
||||
|
||||
|
||||
def _final_validate(config: ConfigType) -> ConfigType:
|
||||
full_config = fv.full_config.get()
|
||||
zephyr_pwm_conf = [
|
||||
cfg
|
||||
for cfg in full_config.get(CONF_OUTPUT, [])
|
||||
if cfg.get(CONF_PLATFORM) == "zephyr_pwm"
|
||||
]
|
||||
if zephyr_pwm_conf and len(zephyr_pwm_conf) > (
|
||||
PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"Only {PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK} PWM outputs are supported by nrf52"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
CONF_ZEPHYR_PWM_PINS = "zephyr_pwm_pins"
|
||||
|
||||
|
||||
def _overlay_pwm():
|
||||
pwm_pins = CORE.data[CONF_ZEPHYR_PWM_PINS]
|
||||
|
||||
assert CORE.is_nrf52
|
||||
pwm_pins_by_block = [
|
||||
pwm_pins[i : i + PWM_CHANNELS_PER_BLOCK]
|
||||
for i in range(0, len(pwm_pins), PWM_CHANNELS_PER_BLOCK)
|
||||
]
|
||||
|
||||
overlay_parts = []
|
||||
|
||||
overlay_parts.extend(
|
||||
f"""
|
||||
&pwm{block_id} {{
|
||||
status = "okay";
|
||||
pinctrl-0 = <&pwm{block_id}_default_custom>;
|
||||
pinctrl-1 = <&pwm{block_id}_sleep_custom>;
|
||||
pinctrl-names = "default", "sleep";
|
||||
}};"""
|
||||
for block_id in range(len(pwm_pins_by_block))
|
||||
)
|
||||
|
||||
psels_by_block = [
|
||||
", ".join(
|
||||
f"<NRF_PSEL(PWM_OUT{channel_id}, {pin // 32}, {pin % 32})>"
|
||||
for channel_id, pin in enumerate(block_pwm_pins)
|
||||
)
|
||||
for block_pwm_pins in pwm_pins_by_block
|
||||
]
|
||||
pinctls = "\n".join(
|
||||
f"""
|
||||
pwm{block_id}_default_custom: pwm{block_id}_default_custom {{
|
||||
group1 {{
|
||||
psels = {block_psels};
|
||||
}};
|
||||
}};
|
||||
pwm{block_id}_sleep_custom: pwm{block_id}_sleep_custom {{
|
||||
group1 {{
|
||||
psels = {block_psels};
|
||||
low-power-enable;
|
||||
}};
|
||||
}};"""
|
||||
for block_id, block_psels in enumerate(psels_by_block)
|
||||
)
|
||||
|
||||
overlay_parts.append(f"""
|
||||
&pinctrl {{
|
||||
{pinctls}
|
||||
}};""")
|
||||
return "\n".join(overlay_parts)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
assert CORE.is_nrf52
|
||||
zephyr_add_prj_conf("PWM", True)
|
||||
pin = config[CONF_PIN]
|
||||
|
||||
CORE.data.setdefault(CONF_ZEPHYR_PWM_PINS, [])
|
||||
pwm_pins = CORE.data[CONF_ZEPHYR_PWM_PINS]
|
||||
pwm_id = len(pwm_pins)
|
||||
assert pwm_id < (PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK), (
|
||||
f"Only {PWM_BLOCK_COUNT * PWM_CHANNELS_PER_BLOCK} PWM outputs are supported by nrf52"
|
||||
)
|
||||
|
||||
block_id = pwm_id // PWM_CHANNELS_PER_BLOCK
|
||||
channel_id = pwm_id % PWM_CHANNELS_PER_BLOCK
|
||||
pwm_pins.append(pin[CONF_NUMBER])
|
||||
zephyr_add_overlay_builder(_overlay_pwm)
|
||||
|
||||
inverted = pin.get(CONF_INVERTED, False)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
cg.RawExpression(f"DEVICE_DT_GET_OR_NULL(DT_NODELABEL(pwm{block_id}))"),
|
||||
channel_id,
|
||||
inverted,
|
||||
)
|
||||
cg.add(var.set_frequency(config[CONF_FREQUENCY]))
|
||||
await cg.register_component(var, config)
|
||||
await output.register_output(var, config)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"output.zephyr_pwm.set_frequency",
|
||||
SetFrequencyAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(ZephyrPWMChannel),
|
||||
cv.Required(CONF_FREQUENCY): cv.templatable(validate_frequency),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def zephyr_pwm_set_frequency_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
template_ = await cg.templatable(config[CONF_FREQUENCY], args, cg.float_)
|
||||
cg.add(var.set_frequency(template_))
|
||||
return var
|
||||
@@ -0,0 +1,46 @@
|
||||
#ifdef USE_ZEPHYR
|
||||
|
||||
#include "zephyr_pwm.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/macros.h"
|
||||
|
||||
#include <zephyr/drivers/pwm.h>
|
||||
#include <cmath>
|
||||
|
||||
namespace esphome::zephyr_pwm {
|
||||
|
||||
static const char *const TAG = "zephyr_pwm";
|
||||
|
||||
void ZephyrPWMChannel::setup() {
|
||||
if (!device_is_ready(this->device_)) {
|
||||
ESP_LOGE(TAG, "pwm is not ready.");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ZephyrPWMChannel::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Zephyr PWM:\n"
|
||||
" Channel: %u\n"
|
||||
" Period: %u ns",
|
||||
this->channel_, this->period_ns_);
|
||||
LOG_FLOAT_OUTPUT(this);
|
||||
}
|
||||
void HOT ZephyrPWMChannel::write_state(float state) {
|
||||
this->last_output_ = state;
|
||||
|
||||
uint32_t pulse_width_ns = state * this->period_ns_;
|
||||
pwm_flags_t flags = this->inverted_ ? PWM_POLARITY_INVERTED : PWM_POLARITY_NORMAL;
|
||||
int err = pwm_set(this->device_, this->channel_, this->period_ns_, pulse_width_ns, flags);
|
||||
if (err != 0) {
|
||||
ESP_LOGE(TAG, "Failed to set PWM output: channel=%u, period=%u ns, pulse_width=%u ns, error=%d", this->channel_,
|
||||
this->period_ns_, pulse_width_ns, err);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::zephyr_pwm
|
||||
|
||||
#endif // USE_ZEPHYR
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <zephyr/device.h>
|
||||
|
||||
namespace esphome::zephyr_pwm {
|
||||
|
||||
class ZephyrPWMChannel : public output::FloatOutput, public Component {
|
||||
public:
|
||||
explicit ZephyrPWMChannel(const struct device *device, uint8_t channel, bool inverted)
|
||||
: device_(device), channel_(channel), inverted_(inverted) {}
|
||||
|
||||
bool set_frequency(float frequency) {
|
||||
if (frequency < 1 || frequency > 1e7) {
|
||||
return false;
|
||||
}
|
||||
this->period_ns_ = 1e9f / frequency;
|
||||
return true;
|
||||
}
|
||||
/// Dynamically update frequency
|
||||
void update_frequency(float frequency) override {
|
||||
if (!this->set_frequency(frequency)) {
|
||||
return;
|
||||
}
|
||||
this->write_state(this->last_output_);
|
||||
}
|
||||
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
/// HARDWARE setup_priority
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
|
||||
protected:
|
||||
void write_state(float state) override;
|
||||
|
||||
const struct device *device_;
|
||||
uint8_t channel_;
|
||||
bool inverted_;
|
||||
uint32_t period_ns_{1000 * 1000}; // default to 1kHz
|
||||
float last_output_{0.0};
|
||||
};
|
||||
|
||||
template<typename... Ts> class SetFrequencyAction : public Action<Ts...> {
|
||||
public:
|
||||
SetFrequencyAction(ZephyrPWMChannel *parent) : parent_(parent) {}
|
||||
TEMPLATABLE_VALUE(float, frequency);
|
||||
|
||||
void play(const Ts &...x) {
|
||||
float freq = this->frequency_.value(x...);
|
||||
this->parent_->update_frequency(freq);
|
||||
}
|
||||
|
||||
protected:
|
||||
ZephyrPWMChannel *parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::zephyr_pwm
|
||||
|
||||
#endif // USE_ZEPHYR
|
||||
@@ -1,19 +1,23 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- light.toggle: test_binary_light
|
||||
|
||||
output:
|
||||
- platform: gpio
|
||||
id: light_test_binary
|
||||
pin: 0
|
||||
pin: 12
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_1
|
||||
pin: 13
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_2
|
||||
pin:
|
||||
number: 14
|
||||
inverted: true
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_3
|
||||
pin: 15
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_4
|
||||
pin: 16
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_5
|
||||
pin: 17
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
id: test_binary_light
|
||||
name: Binary Light
|
||||
output: light_test_binary
|
||||
effects:
|
||||
- strobe:
|
||||
on_state:
|
||||
- logger.log: Binary light state changed
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- light.toggle: test_binary_light
|
||||
|
||||
output:
|
||||
- platform: gpio
|
||||
id: light_test_binary
|
||||
pin: 0
|
||||
pin: 12
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_1
|
||||
pin: 13
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_2
|
||||
pin:
|
||||
number: 14
|
||||
inverted: true
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_3
|
||||
pin: 15
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_4
|
||||
pin: 16
|
||||
- platform: zephyr_pwm
|
||||
id: test_ledc_5
|
||||
pin: 17
|
||||
|
||||
light:
|
||||
- platform: binary
|
||||
id: test_binary_light
|
||||
name: Binary Light
|
||||
output: light_test_binary
|
||||
effects:
|
||||
- strobe:
|
||||
on_state:
|
||||
- logger.log: Binary light state changed
|
||||
<<: !include common.yaml
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
output:
|
||||
- platform: zephyr_pwm
|
||||
id: pwm_output_1
|
||||
pin: P0.02
|
||||
- platform: zephyr_pwm
|
||||
id: pwm_output_2
|
||||
pin:
|
||||
number: 10
|
||||
inverted: true
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -13,9 +13,9 @@ from esphome.components.zephyr.const import (
|
||||
KEY_EXTRA_BUILD_FILES,
|
||||
KEY_KCONFIG,
|
||||
KEY_OVERLAY,
|
||||
KEY_OVERLAY_BUILDER,
|
||||
KEY_PM_STATIC,
|
||||
KEY_PRJ_CONF,
|
||||
KEY_USER,
|
||||
KEY_ZEPHYR,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
@@ -53,9 +53,9 @@ def _setup_nrf52_core(
|
||||
KEY_BOOTLOADER: bootloader,
|
||||
KEY_PRJ_CONF: {},
|
||||
KEY_OVERLAY: {"": ""},
|
||||
KEY_OVERLAY_BUILDER: [],
|
||||
KEY_EXTRA_BUILD_FILES: {},
|
||||
KEY_PM_STATIC: [],
|
||||
KEY_USER: {},
|
||||
KEY_KCONFIG: "",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user