computing trip statistics on ESP32 (distance, time, elevation, etc.)
This commit is contained in:
parent
98a59085d4
commit
97c2f379b8
@ -18,4 +18,19 @@ namespace utils
|
|||||||
return (biggestValue - from) + to + 1;
|
return (biggestValue - from) + to + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint32_t elapsed(uint32_t from, uint32_t to)
|
||||||
|
{
|
||||||
|
if(to >= from)
|
||||||
|
{
|
||||||
|
return to - from;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// if the counter overflowed, this computes the real duration
|
||||||
|
// of course it won't work if the counter made a "full turn" or more
|
||||||
|
const uint32_t biggestValue = (uint32_t)-1;
|
||||||
|
return (biggestValue - from) + to + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,12 +33,20 @@ const int8_t I2C_SCL = 4;
|
|||||||
const float wheelDiameterInches = 20;
|
const float wheelDiameterInches = 20;
|
||||||
const int numImpulsesPerTurn = 2;
|
const int numImpulsesPerTurn = 2;
|
||||||
const float wheelCircumferenceMeters = wheelDiameterInches * 0.0254f * 3.1415f / (float)numImpulsesPerTurn;
|
const float wheelCircumferenceMeters = wheelDiameterInches * 0.0254f * 3.1415f / (float)numImpulsesPerTurn;
|
||||||
|
const uint32_t wheelCircumferenceMillimeters = (uint32_t)(wheelCircumferenceMeters * 1000.0f + 0.5f);
|
||||||
|
|
||||||
uint16_t batteryVoltage = 0; // in mV
|
uint16_t batteryVoltage = 0; // in mV
|
||||||
uint16_t batteryOutputCurrent = 0; // in mV
|
uint16_t batteryOutputCurrent = 0; // in mV
|
||||||
int16_t temperature = 0; // in tenth of °C
|
int16_t temperature = 0; // in tenth of °C
|
||||||
int32_t altitude = 0; // in mm above sea level (can be negative if below sea level, or depending on atmospheric conditions)
|
int32_t altitude = 0; // in mm above sea level (can be negative if below sea level, or depending on atmospheric conditions)
|
||||||
|
|
||||||
|
// current trip
|
||||||
|
uint32_t tripDistance = 0; // in meters
|
||||||
|
uint16_t tripMovingTime = 0; // cumulated seconds, only when moving at non-zero speed
|
||||||
|
uint16_t tripTotalTime = 0; // total trip time in seconds
|
||||||
|
uint32_t tripAscendingElevation = 0; // cumulated ascending elevation, in millimeters
|
||||||
|
uint32_t tripMotorEnergy = 0; // in Joules
|
||||||
|
|
||||||
WiFiMulti wifiMulti;
|
WiFiMulti wifiMulti;
|
||||||
wl_status_t wifi_STA_status = WL_NO_SHIELD;
|
wl_status_t wifi_STA_status = WL_NO_SHIELD;
|
||||||
unsigned long wifiConnexionBegin = 0;
|
unsigned long wifiConnexionBegin = 0;
|
||||||
@ -52,6 +60,7 @@ volatile bool speedSensorState = false;
|
|||||||
volatile unsigned long speedSensorRiseTime = 0;
|
volatile unsigned long speedSensorRiseTime = 0;
|
||||||
volatile unsigned long speedSensorLastImpulseTime = 0;
|
volatile unsigned long speedSensorLastImpulseTime = 0;
|
||||||
volatile unsigned long speedSensorLastImpulseInterval = (unsigned long)-1; // in milliseconds
|
volatile unsigned long speedSensorLastImpulseInterval = (unsigned long)-1; // in milliseconds
|
||||||
|
volatile uint32_t speedSensorDistance = 0; // Cumulated measured distance, in millimeters. This value will overflow after about 4000km.
|
||||||
void IRAM_ATTR onSpeedSensorChange(bool newState)
|
void IRAM_ATTR onSpeedSensorChange(bool newState)
|
||||||
{
|
{
|
||||||
if(speedSensorState == newState) return;
|
if(speedSensorState == newState) return;
|
||||||
@ -78,16 +87,21 @@ void IRAM_ATTR onSpeedSensorChange(bool newState)
|
|||||||
{
|
{
|
||||||
// too little time between impulses, probably some bouncing, ignore it
|
// too little time between impulses, probably some bouncing, ignore it
|
||||||
}
|
}
|
||||||
else if(timeSinceLastImpulse < 4000)
|
|
||||||
{
|
|
||||||
speedSensorLastImpulseTime = now;
|
|
||||||
speedSensorLastImpulseInterval = timeSinceLastImpulse;
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// too much time between impulses, can't compute speed from that
|
speedSensorDistance += wheelCircumferenceMillimeters;
|
||||||
speedSensorLastImpulseTime = now;
|
|
||||||
speedSensorLastImpulseInterval = (unsigned long)-1;
|
if(timeSinceLastImpulse < 4000)
|
||||||
|
{
|
||||||
|
speedSensorLastImpulseTime = now;
|
||||||
|
speedSensorLastImpulseInterval = timeSinceLastImpulse;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// too much time between impulses, can't compute speed from that
|
||||||
|
speedSensorLastImpulseTime = now;
|
||||||
|
speedSensorLastImpulseInterval = (unsigned long)-1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -200,17 +214,23 @@ void setup()
|
|||||||
int v = batteryVoltage;
|
int v = batteryVoltage;
|
||||||
int c = batteryOutputCurrent;
|
int c = batteryOutputCurrent;
|
||||||
int s = (int)(getSpeed() * 1000.0f + 0.5f);
|
int s = (int)(getSpeed() * 1000.0f + 0.5f);
|
||||||
int t = temperature;
|
int temp = temperature;
|
||||||
int alt = altitude;
|
int alt = altitude;
|
||||||
|
|
||||||
|
int td = tripDistance;
|
||||||
|
int ttt = tripTotalTime;
|
||||||
|
int tmt = tripMovingTime;
|
||||||
|
int tae = tripAscendingElevation / 100; // convert mm to dm
|
||||||
|
int tme = tripMotorEnergy / 360; // convert Joules to dWh (tenth of Wh)
|
||||||
|
|
||||||
const char* logFileName = DataLogger::get().currentLogFileName();
|
const char* logFileName = DataLogger::get().currentLogFileName();
|
||||||
if(String(logFileName).startsWith("/log/")) logFileName += 5;
|
if(String(logFileName).startsWith("/log/")) logFileName += 5;
|
||||||
|
|
||||||
int totalSize = (int)(SPIFFS.totalBytes() / 1000);
|
int totalSize = (int)(SPIFFS.totalBytes() / 1000);
|
||||||
int usedSize = (int)(SPIFFS.usedBytes() / 1000);
|
int usedSize = (int)(SPIFFS.usedBytes() / 1000);
|
||||||
|
|
||||||
char json[128];
|
char json[256];
|
||||||
sprintf(json, "{\"v\":%d,\"c\":%d,\"s\":%d,\"t\":%d,\"alt\":%d,\"log\":\"%s\",\"tot\":%d,\"used\":%d}", v, c, s, t, alt, logFileName, totalSize, usedSize);
|
sprintf(json, "{\"v\":%d,\"c\":%d,\"s\":%d,\"td\":%d,\"ttt\":%d,\"tmt\":%d,\"tae\":%d,\"tme\":%d,\"temp\":%d,\"alt\":%d,\"log\":\"%s\",\"tot\":%d,\"used\":%d}", v, c, s, td, ttt, tmt, tae, tme, temp, alt, logFileName, totalSize, usedSize);
|
||||||
request->send(200, "text/json", json);
|
request->send(200, "text/json", json);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -399,6 +419,10 @@ void loop()
|
|||||||
handle_pressure_measure(); // also measures temperature
|
handle_pressure_measure(); // also measures temperature
|
||||||
|
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
static unsigned long lastLoopMillis = now;
|
||||||
|
unsigned long dt = utils::elapsed(lastLoopMillis, now);
|
||||||
|
lastLoopMillis = now;
|
||||||
|
|
||||||
static DataLogger::Entry entry;
|
static DataLogger::Entry entry;
|
||||||
entry.batteryVoltage = (float)batteryVoltage / 1000.0f;
|
entry.batteryVoltage = (float)batteryVoltage / 1000.0f;
|
||||||
entry.batteryOutputCurrent = (float)batteryOutputCurrent / 1000.0f;
|
entry.batteryOutputCurrent = (float)batteryOutputCurrent / 1000.0f;
|
||||||
@ -413,6 +437,11 @@ void loop()
|
|||||||
{
|
{
|
||||||
DebugLog.println("Starting DataLogger");
|
DebugLog.println("Starting DataLogger");
|
||||||
DataLogger::get().open();
|
DataLogger::get().open();
|
||||||
|
tripDistance = 0;
|
||||||
|
tripMovingTime = 0;
|
||||||
|
tripTotalTime = 0;
|
||||||
|
tripAscendingElevation = 0;
|
||||||
|
tripMotorEnergy = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -432,5 +461,41 @@ void loop()
|
|||||||
}
|
}
|
||||||
DataLogger::get().log(now, entry);
|
DataLogger::get().log(now, entry);
|
||||||
|
|
||||||
delay(DataLogger::get().isOpen() ? 10 : 1000);
|
bool isOnTrip = DataLogger::get().isOpen();
|
||||||
|
bool isMoving = entry.speed > 0.0f;
|
||||||
|
|
||||||
|
static unsigned long cumulatedMillis = 0;
|
||||||
|
cumulatedMillis += dt;
|
||||||
|
unsigned long newSeconds = cumulatedMillis / 1000;
|
||||||
|
cumulatedMillis -= newSeconds * 1000;
|
||||||
|
|
||||||
|
uint32_t currentMillimeters = speedSensorDistance;
|
||||||
|
static uint32_t lastLoopMillimeters = currentMillimeters;
|
||||||
|
uint32_t newMillimeters = utils::elapsed(lastLoopMillimeters, currentMillimeters);
|
||||||
|
lastLoopMillimeters = currentMillimeters;
|
||||||
|
|
||||||
|
static uint32_t cumulatedMillimeters = 0;
|
||||||
|
cumulatedMillimeters += newMillimeters;
|
||||||
|
uint32_t newMeters = cumulatedMillimeters / 1000;
|
||||||
|
cumulatedMillimeters -= newMeters * 1000;
|
||||||
|
|
||||||
|
uint32_t altitudeMillimeters = (uint32_t)(entry.altitude * 1000.0f + 0.5f);
|
||||||
|
static uint32_t lastLoopAltitude = altitudeMillimeters;
|
||||||
|
uint32_t altitudeChange = altitudeMillimeters - lastLoopAltitude;
|
||||||
|
lastLoopAltitude = altitudeMillimeters;
|
||||||
|
|
||||||
|
if(isOnTrip)
|
||||||
|
{
|
||||||
|
tripTotalTime += newSeconds;
|
||||||
|
if(isMoving) tripMovingTime += newSeconds;
|
||||||
|
tripDistance += newMeters;
|
||||||
|
|
||||||
|
if(altitudeChange > 0)
|
||||||
|
tripAscendingElevation += altitudeChange;
|
||||||
|
|
||||||
|
uint32_t newEnergy = entry.batteryVoltage * entry.batteryOutputCurrent * ((float)dt / 1000.0f);
|
||||||
|
tripMotorEnergy += newEnergy;
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(isOnTrip ? 10 : 1000);
|
||||||
}
|
}
|
||||||
|
@ -4,16 +4,26 @@ export interface Status {
|
|||||||
batteryVoltage: number; // in Volts
|
batteryVoltage: number; // in Volts
|
||||||
motorCurrent: number; // in Amperes
|
motorCurrent: number; // in Amperes
|
||||||
speed: number; // in meters per second
|
speed: number; // in meters per second
|
||||||
|
tripDistance: number; // in meters
|
||||||
|
tripTotalTime: number; // in seconds
|
||||||
|
tripMovingTime: number; // in seconds
|
||||||
|
tripAscendingElevation: number; // in meters
|
||||||
|
tripMotorEnergy: number; // in Watt-hour
|
||||||
temperature: number; // in Celcius degrees
|
temperature: number; // in Celcius degrees
|
||||||
altitude: number; // in meters above sea level
|
altitude: number; // in meters above sea level
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ApiStatus {
|
interface ApiStatus {
|
||||||
v: number;
|
v: number; // voltage (mV)
|
||||||
c: number;
|
c: number; // current (mA)
|
||||||
s: number;
|
s: number; // speed (mm/s)
|
||||||
t: number;
|
td: number; // trip distance (m)
|
||||||
alt: number;
|
ttt: number; // trip total time (s)
|
||||||
|
tmt: number; // trip moving time (s)
|
||||||
|
tae: number; // trip ascending elevation (m)
|
||||||
|
tme: number; // trip motor energy (Wh)
|
||||||
|
temp: number; // temperature (tenth of °C)
|
||||||
|
alt: number; // altitude (mm)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MonitorApi {
|
export class MonitorApi {
|
||||||
@ -21,6 +31,15 @@ export class MonitorApi {
|
|||||||
private lastStatus: Status = null;
|
private lastStatus: Status = null;
|
||||||
private static api: MonitorApi = null;
|
private static api: MonitorApi = null;
|
||||||
|
|
||||||
|
private lastFetchTime = 0;
|
||||||
|
private lastFetchAltitude = -100000;
|
||||||
|
|
||||||
|
private mockedTripDistance = 0;
|
||||||
|
private mockedTripTotalTime = 0;
|
||||||
|
private mockedTripMovingTime = 0;
|
||||||
|
private mockedTripAscendingElevation = 0;
|
||||||
|
private mockedTripMotorEnergy = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mockServer = window.location.protocol == "file:";
|
this.mockServer = window.location.protocol == "file:";
|
||||||
}
|
}
|
||||||
@ -39,13 +58,43 @@ export class MonitorApi {
|
|||||||
if(this.mockServer) {
|
if(this.mockServer) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
|
||||||
let t = new Date().getTime() / 1000.0;
|
let t = Date.now() / 1000.0;
|
||||||
|
if(this.lastFetchTime == 0) this.lastFetchTime = t;
|
||||||
|
let dt = t - this.lastFetchTime;
|
||||||
|
this.lastFetchTime = t;
|
||||||
|
|
||||||
|
let speed = (Math.cos(t*0.3)+1.0)*0.5 * 14; // in meters per second
|
||||||
|
if(speed < 0.25) speed = 0;
|
||||||
|
|
||||||
|
this.mockedTripDistance += speed * dt;
|
||||||
|
this.mockedTripTotalTime += dt;
|
||||||
|
if(speed > 0)
|
||||||
|
this.mockedTripMovingTime += dt;
|
||||||
|
|
||||||
|
let altitude = (Math.cos(t*0.0001)+1.0)*0.5 * 4500 - 200 + (Math.cos(t*0.03))*0.5 * 60; // in meters
|
||||||
|
if(this.lastFetchAltitude == -100000) this.lastFetchAltitude = altitude;
|
||||||
|
let altitudeChange = altitude - this.lastFetchAltitude;
|
||||||
|
this.lastFetchAltitude = altitude;
|
||||||
|
|
||||||
|
if(altitudeChange > 0)
|
||||||
|
this.mockedTripAscendingElevation += altitudeChange;
|
||||||
|
|
||||||
|
let voltage = (Math.cos(t*0.4)+1.0)*0.5 * 6 + 36;
|
||||||
|
let current = (Math.cos(t*0.7)+1.0)*0.5 * 30;
|
||||||
|
let newEnergy = voltage * current * dt / 3600;
|
||||||
|
this.mockedTripMotorEnergy += newEnergy;
|
||||||
|
|
||||||
apiStatus = {
|
apiStatus = {
|
||||||
v: (Math.cos(t*0.4)+1.0)*0.5 * 6000 + 36000,
|
v: Math.round(voltage * 1000),
|
||||||
c: (Math.cos(t*0.7)+1.0)*0.5 * 30000,
|
c: Math.round(current * 1000),
|
||||||
s: (Math.cos(t*0.3)+1.0)*0.5 * 14000,
|
s: Math.round(speed * 1000),
|
||||||
t: (Math.cos(t*0.2)+1.0)*0.5 * 400 - 100,
|
td: Math.round(this.mockedTripDistance),
|
||||||
alt: (Math.cos(t*0.0001)+1.0)*0.5 * 4500000 - 200000 + (Math.cos(t*0.03))*0.5 * 60000
|
ttt: Math.round(this.mockedTripTotalTime),
|
||||||
|
tmt: Math.round(this.mockedTripMovingTime),
|
||||||
|
tae: Math.round(this.mockedTripAscendingElevation*10.0),
|
||||||
|
tme: Math.round(this.mockedTripMotorEnergy*10.0),
|
||||||
|
temp: Math.round((Math.cos(t*0.2)+1.0)*0.5 * 400 - 100),
|
||||||
|
alt: Math.round(altitude * 1000)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
apiStatus = await new Promise<ApiStatus>((resolve, error) => {
|
apiStatus = await new Promise<ApiStatus>((resolve, error) => {
|
||||||
@ -69,7 +118,12 @@ export class MonitorApi {
|
|||||||
batteryVoltage: apiStatus.v / 1000,
|
batteryVoltage: apiStatus.v / 1000,
|
||||||
motorCurrent: apiStatus.c / 1000,
|
motorCurrent: apiStatus.c / 1000,
|
||||||
speed: apiStatus.s / 1000,
|
speed: apiStatus.s / 1000,
|
||||||
temperature: apiStatus.t / 10,
|
tripDistance: apiStatus.td,
|
||||||
|
tripTotalTime: apiStatus.ttt,
|
||||||
|
tripMovingTime: apiStatus.tmt,
|
||||||
|
tripAscendingElevation: apiStatus.tae / 10,
|
||||||
|
tripMotorEnergy: apiStatus.tme / 10,
|
||||||
|
temperature: apiStatus.temp / 10,
|
||||||
altitude: apiStatus.alt / 1000
|
altitude: apiStatus.alt / 1000
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -19,14 +19,12 @@ export class DashboardPage extends Page {
|
|||||||
private battery = new Pipe(0.0);
|
private battery = new Pipe(0.0);
|
||||||
private speed = new Pipe(0.0);
|
private speed = new Pipe(0.0);
|
||||||
|
|
||||||
private lastRefreshTime = 0.0;
|
private tripDistance = new Pipe(0.0);
|
||||||
private movementTime = 0.0;
|
private tripAverageSpeed = new Pipe(0.0);
|
||||||
private distance = new Pipe(0.0);
|
private tripEnergy = new Pipe(0.0);
|
||||||
private averageSpeed = new Pipe(0.0);
|
private tripAverageConsumption = new Pipe(0.0);
|
||||||
private energy = new Pipe(0.0);
|
private tripAscendingElevation = new Pipe(0.0);
|
||||||
private averageConsumption = new Pipe(0.0);
|
|
||||||
private ascendingElevation = new Pipe(0.0);
|
|
||||||
private lastElevation = -100000;
|
|
||||||
private altitude = new Pipe(0.0);
|
private altitude = new Pipe(0.0);
|
||||||
private temperature = new Pipe(0.0);
|
private temperature = new Pipe(0.0);
|
||||||
|
|
||||||
@ -55,30 +53,14 @@ export class DashboardPage extends Page {
|
|||||||
|
|
||||||
this.speed.set(this.status.speed * 3.6); // convert m/s to km/h
|
this.speed.set(this.status.speed * 3.6); // convert m/s to km/h
|
||||||
|
|
||||||
let now = Date.now() / 1000;
|
this.tripDistance.set(this.status.tripDistance/1000);
|
||||||
if(this.lastRefreshTime == 0.0) this.lastRefreshTime = now;
|
this.tripAverageSpeed.set(this.status.tripDistance/1000 / (Math.max(1.0, this.status.tripMovingTime)/3600));
|
||||||
let dt = now - this.lastRefreshTime;
|
this.tripEnergy.set(this.status.tripMotorEnergy);
|
||||||
this.lastRefreshTime = now;
|
this.tripAverageConsumption.set(this.status.tripMotorEnergy / Math.max(0.1, this.status.tripDistance/1000));
|
||||||
|
this.tripAscendingElevation.set(this.status.tripAscendingElevation);
|
||||||
if(this.status.speed > 0.0)
|
|
||||||
this.movementTime += dt;
|
|
||||||
this.distance.set(this.distance.get() + this.status.speed * dt / 1000);
|
|
||||||
if(this.movementTime > 0.0)
|
|
||||||
this.averageSpeed.set(this.distance.get() / (this.movementTime / 3600));
|
|
||||||
|
|
||||||
if(this.lastElevation == -100000)
|
|
||||||
this.lastElevation = this.status.altitude;
|
|
||||||
let elevationChange = this.status.altitude - this.lastElevation;
|
|
||||||
this.lastElevation = this.status.altitude;
|
|
||||||
if(elevationChange > 0.0)
|
|
||||||
this.ascendingElevation.set(this.ascendingElevation.get() + elevationChange);
|
|
||||||
this.altitude.set(this.status.altitude);
|
|
||||||
|
|
||||||
this.temperature.set(this.status.temperature);
|
this.temperature.set(this.status.temperature);
|
||||||
|
this.altitude.set(this.status.altitude);
|
||||||
this.energy.set(this.energy.get() + power * dt / 3600);
|
|
||||||
if(this.distance.get() > 0.1)
|
|
||||||
this.averageConsumption.set(this.energy.get() / this.distance.get());
|
|
||||||
|
|
||||||
if(this.autoRefresh)
|
if(this.autoRefresh)
|
||||||
setTimeout(() => { if(this.autoRefresh) this.refresh(); }, 150);
|
setTimeout(() => { if(this.autoRefresh) this.refresh(); }, 150);
|
||||||
@ -98,15 +80,15 @@ export class DashboardPage extends Page {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="widgets-row">
|
<div class="widgets-row">
|
||||||
<NumericValue widgetWidth={0.5} value={this.distance} decimals={1} unit="km" />
|
<NumericValue widgetWidth={0.5} value={this.tripDistance} decimals={1} unit="km" />
|
||||||
</div>
|
</div>
|
||||||
<div class="widgets-row">
|
<div class="widgets-row">
|
||||||
<NumericValue widgetWidth={0.5} value={this.averageSpeed} decimals={1} unit="km/h" />
|
<NumericValue widgetWidth={0.5} value={this.tripAverageSpeed} decimals={1} unit="km/h" />
|
||||||
<NumericValue widgetWidth={0.5} value={this.ascendingElevation} decimals={1} unit="m" />
|
<NumericValue widgetWidth={0.5} value={this.tripAscendingElevation} decimals={1} unit="m" />
|
||||||
</div>
|
</div>
|
||||||
<div class="widgets-row">
|
<div class="widgets-row">
|
||||||
<NumericValue widgetWidth={0.5} value={this.averageConsumption} decimals={1} unit="Wh/km" />
|
<NumericValue widgetWidth={0.5} value={this.tripAverageConsumption} decimals={1} unit="Wh/km" />
|
||||||
<NumericValue widgetWidth={0.5} value={this.energy} decimals={1} unit="Wh" />
|
<NumericValue widgetWidth={0.5} value={this.tripEnergy} decimals={1} unit="Wh" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="widgets-row">
|
<div class="widgets-row">
|
||||||
|
Loading…
Reference in New Issue
Block a user