mirror of
https://github.com/esphome/esphome.git
synced 2025-09-05 21:02:20 +01:00
add zephyr ota
This commit is contained in:
@@ -22,7 +22,6 @@ from esphome.const import (
|
|||||||
CONF_LOGGER,
|
CONF_LOGGER,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_OTA,
|
CONF_OTA,
|
||||||
CONF_FOTA,
|
|
||||||
CONF_MQTT,
|
CONF_MQTT,
|
||||||
CONF_MDNS,
|
CONF_MDNS,
|
||||||
CONF_DISABLED,
|
CONF_DISABLED,
|
||||||
@@ -49,7 +48,7 @@ from esphome.util import (
|
|||||||
get_serial_ports,
|
get_serial_ports,
|
||||||
)
|
)
|
||||||
from esphome.log import color, setup_log, Fore
|
from esphome.log import color, setup_log, Fore
|
||||||
from .zephyr_tools import smpmgr_scan, smpmgr_upload, list_pyocd
|
from .zephyr_tools import smpmgr_upload, list_pyocd
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -92,10 +91,10 @@ def choose_upload_log_host(
|
|||||||
options = []
|
options = []
|
||||||
for port in get_serial_ports():
|
for port in get_serial_ports():
|
||||||
options.append((f"{port.path} ({port.description})", port.path))
|
options.append((f"{port.path} ({port.description})", port.path))
|
||||||
if show_ota and CONF_FOTA in CORE.config:
|
# if show_ota and CONF_FOTA in CORE.config:
|
||||||
options.append(
|
# options.append(
|
||||||
(f"mcumgr {port.path} ({port.description})", f"mcumgr {port.path}")
|
# (f"mcumgr {port.path} ({port.description})", f"mcumgr {port.path}")
|
||||||
)
|
# )
|
||||||
if default == "SERIAL":
|
if default == "SERIAL":
|
||||||
return choose_prompt(options, purpose=purpose)
|
return choose_prompt(options, purpose=purpose)
|
||||||
pyocd = list_pyocd()
|
pyocd = list_pyocd()
|
||||||
@@ -113,19 +112,19 @@ def choose_upload_log_host(
|
|||||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||||
if default == "OTA":
|
if default == "OTA":
|
||||||
return CORE.address
|
return CORE.address
|
||||||
if show_ota and CONF_FOTA in CORE.config:
|
# if show_ota and CONF_FOTA in CORE.config:
|
||||||
if default is None or default == "FOTA":
|
# if default is None or default == "FOTA":
|
||||||
ble_devices = asyncio.run(smpmgr_scan())
|
# ble_devices = asyncio.run(smpmgr_scan())
|
||||||
if len(ble_devices) == 0:
|
# if len(ble_devices) == 0:
|
||||||
_LOGGER.warning("No FOTA service found!")
|
# _LOGGER.warning("No FOTA service found!")
|
||||||
for device in ble_devices:
|
# for device in ble_devices:
|
||||||
options.append(
|
# options.append(
|
||||||
(
|
# (
|
||||||
f"FOTA over Bluetooth LE({device.address}) {device.name}",
|
# f"FOTA over Bluetooth LE({device.address}) {device.name}",
|
||||||
f"mcumgr {device.address}",
|
# f"mcumgr {device.address}",
|
||||||
)
|
# )
|
||||||
)
|
# )
|
||||||
return choose_prompt(options, purpose=purpose)
|
# return choose_prompt(options, purpose=purpose)
|
||||||
if show_mqtt and CONF_MQTT in CORE.config:
|
if show_mqtt and CONF_MQTT in CORE.config:
|
||||||
options.append((f"MQTT ({CORE.config['mqtt'][CONF_BROKER]})", "MQTT"))
|
options.append((f"MQTT ({CORE.config['mqtt'][CONF_BROKER]})", "MQTT"))
|
||||||
if default == "OTA":
|
if default == "OTA":
|
||||||
@@ -355,8 +354,7 @@ def upload_program(config, args, host):
|
|||||||
firmware = os.path.abspath(
|
firmware = os.path.abspath(
|
||||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||||
)
|
)
|
||||||
if CONF_FOTA in config:
|
return asyncio.run(smpmgr_upload(config, host.split(" ")[1], firmware))
|
||||||
return asyncio.run(smpmgr_upload(config, host.split(" ")[1], firmware))
|
|
||||||
|
|
||||||
if CONF_OTA not in config:
|
if CONF_OTA not in config:
|
||||||
raise EsphomeError(
|
raise EsphomeError(
|
||||||
|
@@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2012-2014 Wind River Systems, Inc.
|
|
||||||
* Copyright (c) 2020 Prevas A/S
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <zephyr/bluetooth/bluetooth.h>
|
|
||||||
#include <zephyr/bluetooth/conn.h>
|
|
||||||
#include <zephyr/bluetooth/gatt.h>
|
|
||||||
#include <zephyr/mgmt/mcumgr/transport/smp_bt.h>
|
|
||||||
|
|
||||||
#define LOG_LEVEL LOG_LEVEL_DBG
|
|
||||||
#include <zephyr/logging/log.h>
|
|
||||||
LOG_MODULE_REGISTER(smp_bt_sample);
|
|
||||||
|
|
||||||
static struct k_work advertise_work;
|
|
||||||
|
|
||||||
static const struct bt_data ad[] = {
|
|
||||||
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
|
|
||||||
BT_DATA_BYTES(BT_DATA_UUID128_ALL,
|
|
||||||
0x84, 0xaa, 0x60, 0x74, 0x52, 0x8a, 0x8b, 0x86,
|
|
||||||
0xd3, 0x4c, 0xb7, 0x1d, 0x1d, 0xdc, 0x53, 0x8d),
|
|
||||||
};
|
|
||||||
|
|
||||||
static void advertise(struct k_work *work)
|
|
||||||
{
|
|
||||||
int rc;
|
|
||||||
|
|
||||||
bt_le_adv_stop();
|
|
||||||
|
|
||||||
rc = bt_le_adv_start(BT_LE_ADV_CONN_NAME, ad, ARRAY_SIZE(ad), NULL, 0);
|
|
||||||
if (rc) {
|
|
||||||
LOG_ERR("Advertising failed to start (rc %d)", rc);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_INF("Advertising successfully started");
|
|
||||||
}
|
|
||||||
|
|
||||||
static void connected(struct bt_conn *conn, uint8_t err)
|
|
||||||
{
|
|
||||||
if (err) {
|
|
||||||
LOG_ERR("Connection failed (err 0x%02x)", err);
|
|
||||||
} else {
|
|
||||||
LOG_INF("Connected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void disconnected(struct bt_conn *conn, uint8_t reason)
|
|
||||||
{
|
|
||||||
LOG_INF("Disconnected (reason 0x%02x)", reason);
|
|
||||||
k_work_submit(&advertise_work);
|
|
||||||
}
|
|
||||||
|
|
||||||
BT_CONN_CB_DEFINE(conn_callbacks) = {
|
|
||||||
.connected = connected,
|
|
||||||
.disconnected = disconnected,
|
|
||||||
};
|
|
||||||
|
|
||||||
static void bt_ready(int err)
|
|
||||||
{
|
|
||||||
if (err != 0) {
|
|
||||||
LOG_ERR("Bluetooth failed to initialise: %d", err);
|
|
||||||
} else {
|
|
||||||
k_work_submit(&advertise_work);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void start_smp_bluetooth_adverts(void)
|
|
||||||
{
|
|
||||||
int rc;
|
|
||||||
|
|
||||||
k_work_init(&advertise_work, advertise);
|
|
||||||
rc = bt_enable(bt_ready);
|
|
||||||
|
|
||||||
if (rc != 0) {
|
|
||||||
LOG_ERR("Bluetooth enable failed: %d", rc);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -84,6 +84,7 @@ def zephyr_to_code(conf):
|
|||||||
|
|
||||||
# zephyr_add_prj_conf("LOG", True)
|
# zephyr_add_prj_conf("LOG", True)
|
||||||
# zephyr_add_prj_conf("MCUBOOT_UTIL_LOG_LEVEL_WRN", True)
|
# zephyr_add_prj_conf("MCUBOOT_UTIL_LOG_LEVEL_WRN", True)
|
||||||
|
# zephyr_add_prj_conf("BOOTLOADER_MCUBOOT", True)
|
||||||
|
|
||||||
|
|
||||||
def _format_prj_conf_val(value: PrjConfValueType) -> str:
|
def _format_prj_conf_val(value: PrjConfValueType) -> str:
|
||||||
|
@@ -21,7 +21,7 @@ CODEOWNERS = ["@esphome/core"]
|
|||||||
|
|
||||||
def AUTO_LOAD():
|
def AUTO_LOAD():
|
||||||
if CORE.using_zephyr:
|
if CORE.using_zephyr:
|
||||||
return ["ota_mcuboot"]
|
return ["zephyr_ota_mcumgr"]
|
||||||
return ["ota_network"]
|
return ["ota_network"]
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ CONF_ON_ERROR = "on_error"
|
|||||||
ota_ns = cg.esphome_ns.namespace("ota")
|
ota_ns = cg.esphome_ns.namespace("ota")
|
||||||
OTAState = ota_ns.enum("OTAState")
|
OTAState = ota_ns.enum("OTAState")
|
||||||
if CORE.using_zephyr:
|
if CORE.using_zephyr:
|
||||||
OTAComponent = cg.esphome_ns.namespace("ota_mcuboot").class_(
|
OTAComponent = cg.esphome_ns.namespace("zephyr_ota_mcumgr").class_(
|
||||||
"OTAComponent", cg.Component
|
"OTAComponent", cg.Component
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -50,7 +50,7 @@ OTAEndTrigger = ota_ns.class_("OTAEndTrigger", automation.Trigger.template())
|
|||||||
OTAErrorTrigger = ota_ns.class_("OTAErrorTrigger", automation.Trigger.template())
|
OTAErrorTrigger = ota_ns.class_("OTAErrorTrigger", automation.Trigger.template())
|
||||||
|
|
||||||
|
|
||||||
def not_supported_by_zephyr(value):
|
def _not_supported_by_zephyr(value):
|
||||||
if CORE.using_zephyr:
|
if CORE.using_zephyr:
|
||||||
raise cv.Invalid(f"Not supported by zephyr framework({value})")
|
raise cv.Invalid(f"Not supported by zephyr framework({value})")
|
||||||
return value
|
return value
|
||||||
@@ -67,7 +67,7 @@ CONFIG_SCHEMA = cv.Schema(
|
|||||||
cv.GenerateID(): cv.declare_id(OTAComponent),
|
cv.GenerateID(): cv.declare_id(OTAComponent),
|
||||||
cv.Optional(CONF_SAFE_MODE, default=True): cv.boolean,
|
cv.Optional(CONF_SAFE_MODE, default=True): cv.boolean,
|
||||||
cv.Optional(CONF_VERSION, default=_default_ota_version()): cv.All(
|
cv.Optional(CONF_VERSION, default=_default_ota_version()): cv.All(
|
||||||
cv.one_of(1, 2, int=True), not_supported_by_zephyr
|
cv.one_of(1, 2, int=True), _not_supported_by_zephyr
|
||||||
),
|
),
|
||||||
cv.SplitDefault(
|
cv.SplitDefault(
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
@@ -78,9 +78,9 @@ CONFIG_SCHEMA = cv.Schema(
|
|||||||
rtl87xx=8892,
|
rtl87xx=8892,
|
||||||
): cv.All(
|
): cv.All(
|
||||||
cv.port,
|
cv.port,
|
||||||
not_supported_by_zephyr,
|
_not_supported_by_zephyr,
|
||||||
),
|
),
|
||||||
cv.Optional(CONF_PASSWORD): cv.All(cv.string, not_supported_by_zephyr),
|
cv.Optional(CONF_PASSWORD): cv.All(cv.string, _not_supported_by_zephyr),
|
||||||
cv.Optional(
|
cv.Optional(
|
||||||
CONF_REBOOT_TIMEOUT, default="5min"
|
CONF_REBOOT_TIMEOUT, default="5min"
|
||||||
): cv.positive_time_period_milliseconds,
|
): cv.positive_time_period_milliseconds,
|
||||||
|
26
esphome/components/zephyr_ble_server/__init__.py
Normal file
26
esphome/components/zephyr_ble_server/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import esphome.codegen as cg
|
||||||
|
import esphome.config_validation as cv
|
||||||
|
from esphome.const import (
|
||||||
|
CONF_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
from esphome.components.nrf52.zephyr import zephyr_add_prj_conf
|
||||||
|
|
||||||
|
zephyr_ble_server_ns = cg.esphome_ns.namespace("zephyr_ble_server")
|
||||||
|
BLEServer = zephyr_ble_server_ns.class_("BLEServer", cg.Component)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.All(
|
||||||
|
cv.Schema(
|
||||||
|
{
|
||||||
|
cv.GenerateID(): cv.declare_id(BLEServer),
|
||||||
|
}
|
||||||
|
).extend(cv.COMPONENT_SCHEMA),
|
||||||
|
cv.only_with_zephyr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def to_code(config):
|
||||||
|
var = cg.new_Pvariable(config[CONF_ID])
|
||||||
|
zephyr_add_prj_conf("BT", True)
|
||||||
|
zephyr_add_prj_conf("BT_PERIPHERAL", True)
|
||||||
|
await cg.register_component(var, config)
|
71
esphome/components/zephyr_ble_server/ble_server.cpp
Normal file
71
esphome/components/zephyr_ble_server/ble_server.cpp
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "ble_server.h"
|
||||||
|
#include "esphome/core/defines.h"
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
#include <zephyr/bluetooth/bluetooth.h>
|
||||||
|
#include <zephyr/bluetooth/conn.h>
|
||||||
|
|
||||||
|
namespace esphome {
|
||||||
|
namespace zephyr_ble_server {
|
||||||
|
|
||||||
|
static const char *const TAG = "zephyr_ble_server";
|
||||||
|
|
||||||
|
static struct k_work advertise_work;
|
||||||
|
static const struct bt_data ad[] = {
|
||||||
|
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
|
||||||
|
#ifdef USE_OTA
|
||||||
|
|
||||||
|
BT_DATA_BYTES(BT_DATA_UUID128_ALL, 0x84, 0xaa, 0x60, 0x74, 0x52, 0x8a, 0x8b, 0x86, 0xd3, 0x4c, 0xb7, 0x1d, 0x1d,
|
||||||
|
0xdc, 0x53, 0x8d),
|
||||||
|
#endif
|
||||||
|
};
|
||||||
|
|
||||||
|
const struct bt_le_adv_param *adv_param = BT_LE_ADV_CONN_NAME;
|
||||||
|
|
||||||
|
static void advertise(struct k_work *work) {
|
||||||
|
bt_le_adv_stop();
|
||||||
|
|
||||||
|
int rc = bt_le_adv_start(adv_param, ad, ARRAY_SIZE(ad), NULL, 0);
|
||||||
|
if (rc) {
|
||||||
|
ESP_LOGE(TAG, "Advertising failed to start (rc %d)", rc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ESP_LOGI(TAG, "Advertising successfully started");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void connected(struct bt_conn *conn, uint8_t err) {
|
||||||
|
if (err) {
|
||||||
|
ESP_LOGE(TAG, "Connection failed (err 0x%02x)", err);
|
||||||
|
} else {
|
||||||
|
ESP_LOGI(TAG, "Connected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void disconnected(struct bt_conn *conn, uint8_t reason) {
|
||||||
|
ESP_LOGI(TAG, "Disconnected (reason 0x%02x)", reason);
|
||||||
|
k_work_submit(&advertise_work);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void bt_ready(int err) {
|
||||||
|
if (err != 0) {
|
||||||
|
ESP_LOGE(TAG, "Bluetooth failed to initialise: %d", err);
|
||||||
|
} else {
|
||||||
|
k_work_submit(&advertise_work);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BT_CONN_CB_DEFINE(conn_callbacks) = {
|
||||||
|
.connected = connected,
|
||||||
|
.disconnected = disconnected,
|
||||||
|
};
|
||||||
|
|
||||||
|
void BLEServer::setup() {
|
||||||
|
k_work_init(&advertise_work, advertise);
|
||||||
|
|
||||||
|
int rc = bt_enable(bt_ready);
|
||||||
|
if (rc != 0) {
|
||||||
|
ESP_LOGE(TAG, "Bluetooth enable failed: %d", rc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace zephyr_ble_server
|
||||||
|
} // namespace esphome
|
14
esphome/components/zephyr_ble_server/ble_server.h
Normal file
14
esphome/components/zephyr_ble_server/ble_server.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/core/component.h"
|
||||||
|
|
||||||
|
namespace esphome {
|
||||||
|
namespace zephyr_ble_server {
|
||||||
|
|
||||||
|
class BLEServer : public Component {
|
||||||
|
public:
|
||||||
|
void setup() override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace zephyr_ble_server
|
||||||
|
} // namespace esphome
|
16
esphome/components/zephyr_mcumgr/__init__.py
Normal file
16
esphome/components/zephyr_mcumgr/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from esphome.components.nrf52.zephyr import zephyr_add_prj_conf
|
||||||
|
|
||||||
|
|
||||||
|
async def to_code(config):
|
||||||
|
zephyr_add_prj_conf("NET_BUF", True)
|
||||||
|
zephyr_add_prj_conf("ZCBOR", True)
|
||||||
|
zephyr_add_prj_conf("MCUMGR", True)
|
||||||
|
|
||||||
|
zephyr_add_prj_conf("MCUMGR_GRP_IMG", True)
|
||||||
|
|
||||||
|
zephyr_add_prj_conf("IMG_MANAGER", True)
|
||||||
|
zephyr_add_prj_conf("STREAM_FLASH", True)
|
||||||
|
zephyr_add_prj_conf("FLASH_MAP", True)
|
||||||
|
zephyr_add_prj_conf("FLASH", True)
|
||||||
|
|
||||||
|
zephyr_add_prj_conf("BOOTLOADER_MCUBOOT", True)
|
14
esphome/components/zephyr_ota_mcumgr/__init__.py
Normal file
14
esphome/components/zephyr_ota_mcumgr/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from esphome.components.nrf52.zephyr import zephyr_add_prj_conf
|
||||||
|
|
||||||
|
DEPENDENCIES = ["zephyr_ble_server"]
|
||||||
|
AUTO_LOAD = ["zephyr_mcumgr"]
|
||||||
|
|
||||||
|
|
||||||
|
async def to_code(config):
|
||||||
|
zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True)
|
||||||
|
zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True)
|
||||||
|
|
||||||
|
zephyr_add_prj_conf("MCUMGR_GRP_OS", True)
|
||||||
|
zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True)
|
||||||
|
|
||||||
|
zephyr_add_prj_conf("NCS_SAMPLE_MCUMGR_BT_OTA_DFU_SPEEDUP", True)
|
@@ -3,9 +3,9 @@
|
|||||||
#include "esphome/components/ota/ota_component.h"
|
#include "esphome/components/ota/ota_component.h"
|
||||||
|
|
||||||
namespace esphome {
|
namespace esphome {
|
||||||
namespace ota_mcuboot {
|
namespace zephyr_ota_mcumgr {
|
||||||
|
|
||||||
class OTAComponent : public ota::OTAComponent {};
|
class OTAComponent : public ota::OTAComponent {};
|
||||||
|
|
||||||
} // namespace ota_mcuboot
|
} // namespace zephyr_ota_mcumgr
|
||||||
} // namespace esphome
|
} // namespace esphome
|
@@ -555,7 +555,6 @@ CONF_OSCILLATION_COMMAND_TOPIC = "oscillation_command_topic"
|
|||||||
CONF_OSCILLATION_OUTPUT = "oscillation_output"
|
CONF_OSCILLATION_OUTPUT = "oscillation_output"
|
||||||
CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic"
|
CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic"
|
||||||
CONF_OTA = "ota"
|
CONF_OTA = "ota"
|
||||||
CONF_FOTA = "fota"
|
|
||||||
CONF_OUTPUT = "output"
|
CONF_OUTPUT = "output"
|
||||||
CONF_OUTPUT_ID = "output_id"
|
CONF_OUTPUT_ID = "output_id"
|
||||||
CONF_OUTPUTS = "outputs"
|
CONF_OUTPUTS = "outputs"
|
||||||
|
@@ -27,10 +27,10 @@ pyparsing >= 3.0
|
|||||||
argcomplete>=2.0.0
|
argcomplete>=2.0.0
|
||||||
|
|
||||||
# for mcumgr
|
# for mcumgr
|
||||||
git+https://github.com/tomaszduda23/smp/#f9b85266485062f6a466989e8f59b913cc83b08b
|
git+https://github.com/tomaszduda23/smp/@f9b85266485062f6a466989e8f59b913cc83b08b
|
||||||
git+https://github.com/tomaszduda23/smpclient/#d25c8035ae2858fd41a106058297b619d58fbcb5
|
git+https://github.com/tomaszduda23/smpclient/@d25c8035ae2858fd41a106058297b619d58fbcb5
|
||||||
# move to framework?
|
# move to framework?
|
||||||
git+https://github.com/tomaszduda23/pyOCD/#949193f7cbf09081f8e46d6b9d2e4a79e536997e
|
git+https://github.com/tomaszduda23/pyOCD/@949193f7cbf09081f8e46d6b9d2e4a79e536997e
|
||||||
bleak==0.21.1
|
bleak==0.21.1
|
||||||
pydantic==2.16.2
|
pydantic==2.16.2
|
||||||
cbor2==5.6.1
|
cbor2==5.6.1
|
||||||
|
Reference in New Issue
Block a user