Compare commits
3 Commits
67a27284b3
...
36c0e28f02
Author | SHA1 | Date | |
---|---|---|---|
36c0e28f02 | |||
d4a6816532 | |||
e74f49277f |
@ -1,26 +1,26 @@
|
|||||||
use serde::Deserialize;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
#[derive(Debug,Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Charger {
|
pub struct Charger {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
pub owners: Vec<String>,
|
pub owners: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug,Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Mattermost {
|
pub struct Mattermost {
|
||||||
pub base: String,
|
pub base: String,
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug,Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Prometheus {
|
pub struct Prometheus {
|
||||||
pub base: String
|
pub base: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug,Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub easee_token_path: String,
|
pub easee_token_path: String,
|
||||||
pub prometheus: Prometheus,
|
pub prometheus: Prometheus,
|
||||||
@ -29,7 +29,7 @@ pub struct Config {
|
|||||||
pub regulator: Option<Regulator>,
|
pub regulator: Option<Regulator>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug,Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Regulator {
|
pub struct Regulator {
|
||||||
pub site_id: u32,
|
pub site_id: u32,
|
||||||
pub circuit_id: i64,
|
pub circuit_id: i64,
|
||||||
@ -43,4 +43,4 @@ pub struct Regulator {
|
|||||||
|
|
||||||
pub fn load_config(path: &str) -> Result<Config> {
|
pub fn load_config(path: &str) -> Result<Config> {
|
||||||
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
|
Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
|
||||||
}
|
}
|
||||||
|
110
src/control.rs
110
src/control.rs
@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
@ -9,39 +10,47 @@ use easee::api::{self, ChargerOpMode, Circuit, Context, SetCurrent, Triphase};
|
|||||||
use easee::observation::{self, Event, ObservationError};
|
use easee::observation::{self, Event, ObservationError};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::config::{self, Config};
|
||||||
use crate::mattermost::{self, Channel};
|
use crate::mattermost::{self, Channel};
|
||||||
use crate::prom::PromClient;
|
use crate::prom::PromClient;
|
||||||
use crate::config::{self, Config};
|
|
||||||
|
|
||||||
use observation::{Observation,PilotMode};
|
use observation::{Observation, PilotMode};
|
||||||
|
|
||||||
struct Charger {
|
struct Charger {
|
||||||
inner: api::Charger,
|
inner: api::Charger,
|
||||||
owners: Vec<String>,
|
owners: Vec<String>,
|
||||||
current: Option<(f64, f64, f64)>
|
current: Option<(f64, f64, f64)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Charger {
|
impl Charger {
|
||||||
pub fn from_api(inner: api::Charger, configs: &[config::Charger]) -> Self {
|
pub fn from_api(inner: api::Charger, configs: &[config::Charger]) -> Self {
|
||||||
let owners = configs.iter()
|
let owners = configs
|
||||||
|
.iter()
|
||||||
.find(|c| c.id == inner.id)
|
.find(|c| c.id == inner.id)
|
||||||
.map(|c| &c.owners)
|
.map(|c| &c.owners)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
Charger { inner, owners, current: None }
|
Charger {
|
||||||
|
inner,
|
||||||
|
owners,
|
||||||
|
current: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(mut ctx: Context, config: Config, mut chargers: Vec<api::Charger>) -> Result<Infallible> {
|
pub fn start(
|
||||||
|
mut ctx: Context,
|
||||||
|
config: Config,
|
||||||
|
mut chargers: Vec<api::Charger>,
|
||||||
|
) -> Result<Infallible> {
|
||||||
let mattermost = mattermost::Context::new(config.mattermost.base, &config.mattermost.token)?;
|
let mattermost = mattermost::Context::new(config.mattermost.base, &config.mattermost.token)?;
|
||||||
let mut stream = observation::Stream::from_context(&mut ctx)?;
|
let mut stream = observation::Stream::from_context(&mut ctx)?;
|
||||||
|
|
||||||
let chargers: HashMap<String, Charger> = chargers.into_iter()
|
let chargers: HashMap<String, Charger> = chargers
|
||||||
|
.into_iter()
|
||||||
.filter_map(|c| {
|
.filter_map(|c| {
|
||||||
stream.subscribe(&c.id)
|
stream
|
||||||
|
.subscribe(&c.id)
|
||||||
.map_err(|e| error!("Cannot subscribe {}: {e}", &c.id))
|
.map_err(|e| error!("Cannot subscribe {}: {e}", &c.id))
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let name = c.name.clone();
|
let name = c.name.clone();
|
||||||
@ -61,15 +70,17 @@ pub fn start(mut ctx: Context, config: Config, mut chargers: Vec<api::Charger>)
|
|||||||
mattermost.send_to_channel(&channel, "Easee Controller started")?;
|
mattermost.send_to_channel(&channel, "Easee Controller started")?;
|
||||||
|
|
||||||
if let Some(reg) = config.regulator {
|
if let Some(reg) = config.regulator {
|
||||||
|
let circuit = ctx
|
||||||
let circuit = ctx.sites_details()?
|
.sites_details()?
|
||||||
.into_iter().find(|s| s.site.id == reg.site_id)
|
.into_iter()
|
||||||
|
.find(|s| s.site.id == reg.site_id)
|
||||||
.ok_or(anyhow!("Invalid site id {}", reg.site_id))?
|
.ok_or(anyhow!("Invalid site id {}", reg.site_id))?
|
||||||
.circuits.into_iter()
|
.circuits
|
||||||
|
.into_iter()
|
||||||
.find(|c| c.id == reg.circuit_id)
|
.find(|c| c.id == reg.circuit_id)
|
||||||
.ok_or(anyhow!("Invalid circuit id {}", reg.circuit_id))?;
|
.ok_or(anyhow!("Invalid circuit id {}", reg.circuit_id))?;
|
||||||
|
|
||||||
let controller = Controller {
|
let controller = Controller {
|
||||||
bias: Triphase::from(reg.power_bias_watts),
|
bias: Triphase::from(reg.power_bias_watts),
|
||||||
ctx,
|
ctx,
|
||||||
prom: PromClient::new(config.prometheus.base),
|
prom: PromClient::new(config.prometheus.base),
|
||||||
@ -81,45 +92,49 @@ pub fn start(mut ctx: Context, config: Config, mut chargers: Vec<api::Charger>)
|
|||||||
};
|
};
|
||||||
|
|
||||||
let _ctrl = thread::spawn(move || controller.adjust_power());
|
let _ctrl = thread::spawn(move || controller.adjust_power());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
||||||
let evt = match stream.recv() {
|
let evt = match stream.recv() {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(ObservationError::Stream(stream_error)) => Err(stream_error)?,
|
Err(ObservationError::Stream(stream_error)) => Err(stream_error)?,
|
||||||
Err(other) => { error!("Cannot process message: {}", other); continue },
|
Err(other) => {
|
||||||
|
error!("Cannot process message: {}", other);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut chargers = chargers.lock().unwrap();
|
let mut chargers = chargers.lock().unwrap();
|
||||||
|
|
||||||
let Some(charger) = chargers.get_mut(&evt.charger)
|
let Some(charger) = chargers.get_mut(&evt.charger) else {
|
||||||
else { warn!("Received message for unknown charger {}", &evt.charger); continue };
|
warn!("Received message for unknown charger {}", &evt.charger);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
let result = handle_event(evt, charger, &mattermost, &channel);
|
let result = handle_event(evt, charger, &mattermost, &channel);
|
||||||
|
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
error!("Error handling observation: {:?}", err);
|
error!("Error handling observation: {:?}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_event(evt: Event, charger: &mut Charger, ctx: &mattermost::Context, channel: &Channel) -> Result<()> {
|
fn handle_event(
|
||||||
|
evt: Event,
|
||||||
|
charger: &mut Charger,
|
||||||
|
ctx: &mattermost::Context,
|
||||||
|
channel: &Channel,
|
||||||
|
) -> Result<()> {
|
||||||
let send = |msg: &str| ctx.send_to_channel(channel, msg);
|
let send = |msg: &str| ctx.send_to_channel(channel, msg);
|
||||||
|
|
||||||
match evt.observation {
|
match evt.observation {
|
||||||
Observation::PilotMode(mode) => {
|
Observation::PilotMode(mode) => match mode {
|
||||||
match mode {
|
PilotMode::Disconnected => send("Car Disconnected"),
|
||||||
PilotMode::Disconnected => send("Car Disconnected"),
|
PilotMode::Connected => send("Car Connected"),
|
||||||
PilotMode::Connected => send("Car Connected"),
|
PilotMode::Charging => send("Car Charging"),
|
||||||
PilotMode::Charging => send("Car Charging"),
|
PilotMode::NeedsVentilation => send("Car needs ventilation"),
|
||||||
PilotMode::NeedsVentilation => send("Car needs ventilation"),
|
PilotMode::FaultDetected => send("Fault detected"),
|
||||||
PilotMode::FaultDetected => send("Fault detected"),
|
PilotMode::Unknown => send("Unknown"),
|
||||||
PilotMode::Unknown => send("Unknown"),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Observation::ChargerOpMode(mode) => {
|
Observation::ChargerOpMode(mode) => {
|
||||||
match mode {
|
match mode {
|
||||||
@ -130,9 +145,11 @@ fn handle_event(evt: Event, charger: &mut Charger, ctx: &mattermost::Context, ch
|
|||||||
ChargerOpMode::Finished => send("Charging finished"),
|
ChargerOpMode::Finished => send("Charging finished"),
|
||||||
ChargerOpMode::Error => send("Charger error"),
|
ChargerOpMode::Error => send("Charger error"),
|
||||||
ChargerOpMode::Ready => send("Charger ready"),
|
ChargerOpMode::Ready => send("Charger ready"),
|
||||||
|
ChargerOpMode::AwaitingAuthentication => send("Charger awaiting authentication"),
|
||||||
|
ChargerOpMode::Deauthenticating => send("Charger deauthenticating"),
|
||||||
}?;
|
}?;
|
||||||
ctx.set_status(mode)
|
ctx.set_status(mode)
|
||||||
},
|
}
|
||||||
other => Ok(info!("{}: {:?}", evt.charger, other)),
|
other => Ok(info!("{}: {:?}", evt.charger, other)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -150,9 +167,7 @@ struct Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Controller {
|
impl Controller {
|
||||||
|
|
||||||
pub fn adjust_power(mut self) -> Result<()> {
|
pub fn adjust_power(mut self) -> Result<()> {
|
||||||
|
|
||||||
let voltage = 240f64;
|
let voltage = 240f64;
|
||||||
let time_to_live = Some(self.delay.as_secs() as i32);
|
let time_to_live = Some(self.delay.as_secs() as i32);
|
||||||
|
|
||||||
@ -163,21 +178,24 @@ impl Controller {
|
|||||||
loop {
|
loop {
|
||||||
let export_power = self.prom.current_power()?;
|
let export_power = self.prom.current_power()?;
|
||||||
let available_power = export_power - self.bias;
|
let available_power = export_power - self.bias;
|
||||||
let available_current = available_power * (1.0/voltage);
|
let available_current = available_power * (1.0 / voltage);
|
||||||
|
|
||||||
integrated = available_current * 0.3 + integrated * (1.0 - 0.3);
|
integrated = available_current * 0.3 + integrated * (1.0 - 0.3);
|
||||||
let differentiated = available_current - prev_available_current;
|
let differentiated = available_current - prev_available_current;
|
||||||
prev_available_current = available_current;
|
prev_available_current = available_current;
|
||||||
|
|
||||||
let delta = available_current * self.p
|
let delta = available_current * self.p + differentiated * self.d + integrated * self.i;
|
||||||
+ differentiated * self.d
|
|
||||||
+ integrated * self.i;
|
|
||||||
|
|
||||||
current = current + delta;
|
current = current + delta;
|
||||||
self.circuit.set_dynamic_current(&mut self.ctx, SetCurrent { time_to_live, current })?;
|
self.circuit.set_dynamic_current(
|
||||||
|
&mut self.ctx,
|
||||||
|
SetCurrent {
|
||||||
|
time_to_live,
|
||||||
|
current,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
thread::sleep(self.delay);
|
thread::sleep(self.delay);
|
||||||
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
19
src/main.rs
19
src/main.rs
@ -8,10 +8,10 @@ use easee::observation;
|
|||||||
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
mod prom;
|
|
||||||
mod mattermost;
|
|
||||||
mod config;
|
mod config;
|
||||||
mod control;
|
mod control;
|
||||||
|
mod mattermost;
|
||||||
|
mod prom;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
enum Command {
|
enum Command {
|
||||||
@ -87,8 +87,7 @@ fn login() -> Result<()> {
|
|||||||
fn load_context() -> Result<easee::api::Context> {
|
fn load_context() -> Result<easee::api::Context> {
|
||||||
let saved = std::fs::read_to_string(SAVED_TOKEN_PATH)
|
let saved = std::fs::read_to_string(SAVED_TOKEN_PATH)
|
||||||
.context("Cannot read saved token (did you log in ?)")?;
|
.context("Cannot read saved token (did you log in ?)")?;
|
||||||
let ctx = easee::api::Context::from_saved(&saved)?
|
let ctx = easee::api::Context::from_saved(&saved)?.on_refresh(save_context);
|
||||||
.on_refresh(save_context);
|
|
||||||
Ok(ctx)
|
Ok(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,7 +153,8 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
// We need to do this before loading the context
|
// We need to do this before loading the context
|
||||||
if let Mode::Login = args.mode {
|
if let Mode::Login = args.mode {
|
||||||
login()?; return Ok(())
|
login()?;
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ctx = load_context()?;
|
let mut ctx = load_context()?;
|
||||||
@ -164,7 +164,10 @@ fn main() -> Result<()> {
|
|||||||
Mode::Login => login()?,
|
Mode::Login => login()?,
|
||||||
Mode::List => {
|
Mode::List => {
|
||||||
for site in ctx.sites_details()? {
|
for site in ctx.sites_details()? {
|
||||||
println!("Site {} (level {})", site.site.id, site.site.level_of_access);
|
println!(
|
||||||
|
"Site {} (level {})",
|
||||||
|
site.site.id, site.site.level_of_access
|
||||||
|
);
|
||||||
for circuit in site.circuits {
|
for circuit in site.circuits {
|
||||||
println!(" Circuit {} ({}A)", circuit.id, circuit.rated_current);
|
println!(" Circuit {} ({}A)", circuit.id, circuit.rated_current);
|
||||||
for charger in circuit.chargers {
|
for charger in circuit.chargers {
|
||||||
@ -172,7 +175,7 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Mode::Status => loop_chargers(&mut ctx, &args.charger_id, |c, ctx| {
|
Mode::Status => loop_chargers(&mut ctx, &args.charger_id, |c, ctx| {
|
||||||
c.state(ctx).map(|s| status(&c.id, s))
|
c.state(ctx).map(|s| status(&c.id, s))
|
||||||
})?,
|
})?,
|
||||||
@ -196,7 +199,7 @@ fn main() -> Result<()> {
|
|||||||
Mode::Power => {
|
Mode::Power => {
|
||||||
let pow = prom::PromClient::new(config.prometheus.base).current_power()?;
|
let pow = prom::PromClient::new(config.prometheus.base).current_power()?;
|
||||||
println!("P1:{}W P2:{}W P3:{}W", pow.phase1, pow.phase2, pow.phase3);
|
println!("P1:{}W P2:{}W P3:{}W", pow.phase1, pow.phase2, pow.phase3);
|
||||||
},
|
}
|
||||||
Mode::Control => {
|
Mode::Control => {
|
||||||
let chargers = load_chargers(&mut ctx, &args.charger_id)?;
|
let chargers = load_chargers(&mut ctx, &args.charger_id)?;
|
||||||
control::start(ctx, config, chargers)?;
|
control::start(ctx, config, chargers)?;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
use anyhow::Result;
|
||||||
use easee::api::ChargerOpMode;
|
use easee::api::ChargerOpMode;
|
||||||
use ureq::json;
|
use ureq::json;
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
pub struct Context {
|
pub struct Context {
|
||||||
pub base: String,
|
pub base: String,
|
||||||
@ -11,9 +11,7 @@ pub struct Channel {
|
|||||||
channel_id: String,
|
channel_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl Context {
|
impl Context {
|
||||||
|
|
||||||
pub fn new(base: String, token: &str) -> Result<Self> {
|
pub fn new(base: String, token: &str) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
base,
|
base,
|
||||||
@ -27,8 +25,7 @@ impl Context {
|
|||||||
|
|
||||||
pub fn set_custom_status(&self, text: &str, emoji: &str) -> Result<()> {
|
pub fn set_custom_status(&self, text: &str, emoji: &str) -> Result<()> {
|
||||||
let path = &self.path("users/me/status/custom");
|
let path = &self.path("users/me/status/custom");
|
||||||
ureq::put(path)
|
ureq::put(path).send_json(json!( { "emoji": emoji, "text": text } ))?;
|
||||||
.send_json(json!( { "emoji": emoji, "text": text } ))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,22 +39,25 @@ impl Context {
|
|||||||
Finished => ("Finished", "white_check_mark"),
|
Finished => ("Finished", "white_check_mark"),
|
||||||
Error => ("Error", "no_entry_sign"),
|
Error => ("Error", "no_entry_sign"),
|
||||||
Ready => ("Ready", "electric_plug"),
|
Ready => ("Ready", "electric_plug"),
|
||||||
|
AwaitingAuthentication => ("Awaiting authentication", "key"),
|
||||||
|
Deauthenticating => ("Deauthenticating", "closed_lock_with_key"),
|
||||||
};
|
};
|
||||||
self.set_custom_status(text, emoji)
|
self.set_custom_status(text, emoji)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn channel(&self, id: &str) -> Channel {
|
pub fn channel(&self, id: &str) -> Channel {
|
||||||
Channel { channel_id: id.to_owned() }
|
Channel {
|
||||||
|
channel_id: id.to_owned(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_to_channel(&self, channel: &Channel, msg: &str) -> Result<()> {
|
pub fn send_to_channel(&self, channel: &Channel, msg: &str) -> Result<()> {
|
||||||
let path = self.path("posts");
|
let path = self.path("posts");
|
||||||
ureq::post(&path)
|
ureq::post(&path)
|
||||||
.set("Authorization", &self.auth_header)
|
.set("Authorization", &self.auth_header)
|
||||||
.send_json(json!(
|
.send_json(json!(
|
||||||
{ "channel_id": channel.channel_id, "message": msg }
|
{ "channel_id": channel.channel_id, "message": msg }
|
||||||
))?;
|
))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
10
src/prom.rs
10
src/prom.rs
@ -44,13 +44,16 @@ struct MatrixEntry {
|
|||||||
|
|
||||||
pub struct PromClient {
|
pub struct PromClient {
|
||||||
base: String,
|
base: String,
|
||||||
power_query_url: String
|
power_query_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PromClient {
|
impl PromClient {
|
||||||
pub fn new(base: String) -> Self {
|
pub fn new(base: String) -> Self {
|
||||||
let power_query_url = format!("{}{}", &base, PROM_QUERY);
|
let power_query_url = format!("{}{}", &base, PROM_QUERY);
|
||||||
PromClient { base, power_query_url }
|
PromClient {
|
||||||
|
base,
|
||||||
|
power_query_url,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_power(&self) -> Result<Triphase> {
|
pub fn current_power(&self) -> Result<Triphase> {
|
||||||
@ -80,6 +83,5 @@ impl PromClient {
|
|||||||
phase2: r.1.ok_or_else(|| anyhow!("Missing phase b"))?,
|
phase2: r.1.ok_or_else(|| anyhow!("Missing phase b"))?,
|
||||||
phase3: r.2.ok_or_else(|| anyhow!("Missing phase c"))?,
|
phase3: r.2.ok_or_else(|| anyhow!("Missing phase c"))?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user