Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 497c042a2a | |||
| bfc6a75e66 | |||
| 07051a7255 | |||
| 1543dde603 |
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from APIs.aichat import generate
|
from APIs.aichat import generate
|
||||||
|
import shared.config as config
|
||||||
|
|
||||||
|
|
||||||
# 1. Define the output schema as a Pydantic model
|
# 1. Define the output schema as a Pydantic model
|
||||||
@@ -9,10 +10,10 @@ class DishSafetyResult(BaseModel):
|
|||||||
description="True if no microwave hazards presents."
|
description="True if no microwave hazards presents."
|
||||||
)
|
)
|
||||||
warning_message: str = Field(
|
warning_message: str = Field(
|
||||||
description="Short explanation of any hazard found (no more than one sentence), or an empty string if safe."
|
description=f"Short explanation of any hazard found (no more than one sentence), or an empty string if safe.",
|
||||||
)
|
)
|
||||||
detected_hazards: list[str] = Field(
|
detected_hazards: list[str] = Field(
|
||||||
description="List of specific hazard items detected."
|
description="List of specific hazard items detected. Only include items that are actually present in the image. If no hazards are detected, this list should be empty."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ class MicrowaveScreen:
|
|||||||
self.h_centered_text(text, 29, 0)
|
self.h_centered_text(text, 29, 0)
|
||||||
|
|
||||||
def message(self, text, alert):
|
def message(self, text, alert):
|
||||||
self.flick()
|
# self.flick()
|
||||||
self._message(text, alert)
|
self._message(text, alert)
|
||||||
self.show()
|
self.show()
|
||||||
pass
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from shared.uart_comm import UARTCommand, UARTCommandType
|
|||||||
from shared.sensors import RGBLED
|
from shared.sensors import RGBLED
|
||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
from shared.lora_device import LoraCommands
|
from shared.lora_device import LoraCommands
|
||||||
|
from shared.alerts import Alert, AlertType, AlertManager
|
||||||
|
|
||||||
# --- READ DEVICE ID ---
|
# --- READ DEVICE ID ---
|
||||||
try:
|
try:
|
||||||
@@ -37,6 +38,7 @@ current_temp = [None, None]
|
|||||||
last_temp = [None, None]
|
last_temp = [None, None]
|
||||||
temperature_asked = False
|
temperature_asked = False
|
||||||
last_temp_request_time = 0
|
last_temp_request_time = 0
|
||||||
|
alert_manager = None
|
||||||
|
|
||||||
|
|
||||||
PING_PAYLOAD = {
|
PING_PAYLOAD = {
|
||||||
@@ -44,6 +46,11 @@ PING_PAYLOAD = {
|
|||||||
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
"type": deviceTypes.DEVICE_TYPES["MICROWAVE"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def on_new_alert(alert: Alert):
|
||||||
|
global microwave_screen
|
||||||
|
print(f"[Alert Manager] New alert received: {alert.to_dict()}")
|
||||||
|
microwave_screen.message(alert.message, True)
|
||||||
|
|
||||||
def init_hardware():
|
def init_hardware():
|
||||||
"""Initializes all hardware components."""
|
"""Initializes all hardware components."""
|
||||||
global lora, uart_device, magnetron_led, microwave_screen
|
global lora, uart_device, magnetron_led, microwave_screen
|
||||||
@@ -247,8 +254,13 @@ async def lora_process_task():
|
|||||||
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
print("[LoRa Process] Toggling defrost mode via orchestrator command.")
|
||||||
defrost_mode = data["defrost_state"]
|
defrost_mode = data["defrost_state"]
|
||||||
microwave_screen.update(cooking_state, defrost_mode)
|
microwave_screen.update(cooking_state, defrost_mode)
|
||||||
|
elif data["action"] == LoraCommands.NEW_ALERT:
|
||||||
|
alert = Alert(data.get("alert_type"), data.get("message", "Unsafe area"))
|
||||||
|
alert.timestamp = data.get("timestamp", time.time())
|
||||||
|
print(f"[LoRa Process] New alert received via orchestrator: {alert.to_dict()}")
|
||||||
|
alert_manager.add_alert(alert)
|
||||||
|
|
||||||
await asyncio.sleep_ms(50)
|
await asyncio.sleep_ms(100)
|
||||||
|
|
||||||
|
|
||||||
async def cooking_loop_task():
|
async def cooking_loop_task():
|
||||||
@@ -276,11 +288,14 @@ async def memory_cleanup_task():
|
|||||||
|
|
||||||
# --- BOOTSTRAP ---
|
# --- BOOTSTRAP ---
|
||||||
async def main():
|
async def main():
|
||||||
global cooking_state, defrost_mode, microwave_screen
|
global cooking_state, defrost_mode, microwave_screen, alert_manager
|
||||||
print("[Main] Starting application...")
|
print("[Main] Starting application...")
|
||||||
|
|
||||||
init_hardware()
|
init_hardware()
|
||||||
|
|
||||||
|
alert_manager = AlertManager()
|
||||||
|
alert_manager.set_on_alert_callback(on_new_alert)
|
||||||
|
|
||||||
# Launch dedicated hardware thread for LoRa RX
|
# Launch dedicated hardware thread for LoRa RX
|
||||||
try:
|
try:
|
||||||
_thread.stack_size(16 * 1024)
|
_thread.stack_size(16 * 1024)
|
||||||
|
|||||||
+48
-4
@@ -9,6 +9,7 @@ from shared import get_lora, get_mqtt_client, deviceTypes, config, payloads, db
|
|||||||
from shared.logging import log
|
from shared.logging import log
|
||||||
from shared.cookingState import CookingStates
|
from shared.cookingState import CookingStates
|
||||||
from shared.lora_device import LoraCommands
|
from shared.lora_device import LoraCommands
|
||||||
|
from shared.alerts import AlertManager, Alert, AlertType
|
||||||
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
|
from sensors import ultrasonicRanger, temp_hum, button, camera, buzzer
|
||||||
from lib.systemd_logs import get_systemd_logs
|
from lib.systemd_logs import get_systemd_logs
|
||||||
|
|
||||||
@@ -76,7 +77,6 @@ async_event_queue = None
|
|||||||
ir_data_cache = {} # mw_id -> dict of IR readings
|
ir_data_cache = {} # mw_id -> dict of IR readings
|
||||||
ir_data_events = {} # mw_id -> asyncio.Event()
|
ir_data_events = {} # mw_id -> asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
# --- HARDWARE & MQTT SETUP ---
|
# --- HARDWARE & MQTT SETUP ---
|
||||||
lora = get_lora()
|
lora = get_lora()
|
||||||
lora.configure()
|
lora.configure()
|
||||||
@@ -188,6 +188,28 @@ def button_callback():
|
|||||||
button.set_callback(button_callback)
|
button.set_callback(button_callback)
|
||||||
button.start_button_monitoring_thread()
|
button.start_button_monitoring_thread()
|
||||||
|
|
||||||
|
# --- Alert manager ---
|
||||||
|
alert_manager = None
|
||||||
|
|
||||||
|
def on_new_alert(alert):
|
||||||
|
# Sends to cloud server
|
||||||
|
alert_dict = alert.to_dict()
|
||||||
|
alert_dict["orchestrator_id"] = DEVICE_ID
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://smartwave.matthiasg.dev/alert",
|
||||||
|
json=alert_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
res_json = response.json()
|
||||||
|
print(f"[Alert Handling] Alert sent to cloud: {alert_dict}, Response: {res_json}")
|
||||||
|
|
||||||
|
# Send the alert to the lora device
|
||||||
|
lora.send_reliable(payloads.lora_new_alert(alert))
|
||||||
|
|
||||||
|
# TODO : envoi de l'alerte aux écrans externes
|
||||||
|
|
||||||
# --- HARDWARE CONTROLLERS ---
|
# --- HARDWARE CONTROLLERS ---
|
||||||
def _stop_hardware(microwave_id: str):
|
def _stop_hardware(microwave_id: str):
|
||||||
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
|
print(f"[{microwave_id}] /!\\ Emergency stop issued to hardware.")
|
||||||
@@ -268,7 +290,7 @@ async def handle_new_dish(microwave_id, detected_height):
|
|||||||
|
|
||||||
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
||||||
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
|
"""Sends all data to the cloud with up to 3 retries (330s timeout for AI generation)."""
|
||||||
global cloud_alert
|
global cloud_alert, alert_manager
|
||||||
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
microwave_states[microwave_id] = MicrowaveState.WAITING_FOR_CLOUD
|
||||||
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
URL = "https://smartwave.matthiasg.dev/cooking-params"
|
||||||
|
|
||||||
@@ -306,8 +328,22 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
# Robust JSON extraction: handles direct dict or nested {"cook_plan": {...}}
|
# JSON extraction
|
||||||
res_json = response.json()
|
res_json = response.json()
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
if "error" in res_json:
|
||||||
|
print(f"[{microwave_id}] Cloud API returned error: {res_json['error']}")
|
||||||
|
microwave_states[microwave_id] = MicrowaveState.DONE
|
||||||
|
|
||||||
|
if not res_json['is_safe']: # The cooking area is not safe, raise an alert
|
||||||
|
alert = Alert(AlertType.COOKING_SAFETY, res_json['warning_message'])
|
||||||
|
alert_manager.add_alert(alert)
|
||||||
|
else:
|
||||||
|
alert = Alert(AlertType.ERROR, f"Cloud API error for microwave {microwave_id}: {res_json['error']}")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
plan = res_json.get("cook_plan", res_json)
|
plan = res_json.get("cook_plan", res_json)
|
||||||
|
|
||||||
c_time = plan.get("cook_time_seconds")
|
c_time = plan.get("cook_time_seconds")
|
||||||
@@ -316,7 +352,7 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
|
|
||||||
if c_time is None or c_power is None or c_temp is None:
|
if c_time is None or c_power is None or c_temp is None:
|
||||||
print(f"[{microwave_id}] ❌ Invalid plan received: {res_json}")
|
print(f"[{microwave_id}] ❌ Invalid plan received: {res_json}")
|
||||||
microwave_states[microwave_id] = MicrowaveState.DONE
|
microwave_states[microwave_id] = None
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
print(f"[{microwave_id}] Cloud Plan Received! Starting microwave: {c_time}s @ {c_power}W")
|
||||||
@@ -335,6 +371,9 @@ async def request_cloud_cooking_plan(microwave_id, sensors_data):
|
|||||||
return
|
return
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Print stacktrace
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
|
print(f"[{microwave_id}] Cloud API Error (Attempt {attempt}/{max_retries}): {e}")
|
||||||
|
|
||||||
if attempt < max_retries:
|
if attempt < max_retries:
|
||||||
@@ -540,6 +579,11 @@ async def main():
|
|||||||
global async_event_queue
|
global async_event_queue
|
||||||
print("Orchestrateur prêt. Lancement des tâches...")
|
print("Orchestrateur prêt. Lancement des tâches...")
|
||||||
|
|
||||||
|
# Initialize the alert manager and set the callback
|
||||||
|
global alert_manager
|
||||||
|
alert_manager = AlertManager()
|
||||||
|
alert_manager.set_on_alert_callback(on_new_alert)
|
||||||
|
|
||||||
# Initialize SQLite database table
|
# Initialize SQLite database table
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import shared.config as config
|
|||||||
import shared.payloads as payloads
|
import shared.payloads as payloads
|
||||||
import shared.cookingState as cookingState
|
import shared.cookingState as cookingState
|
||||||
import shared.safeQueue as safeQueue
|
import shared.safeQueue as safeQueue
|
||||||
|
import shared.alerts as alerts
|
||||||
try:
|
try:
|
||||||
import shared.lora_device as lora_device
|
import shared.lora_device as lora_device
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
class AlertManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.alerts = []
|
||||||
|
self.on_alert_callback = None
|
||||||
|
|
||||||
|
def set_on_alert_callback(self, callback):
|
||||||
|
self.on_alert_callback = callback
|
||||||
|
|
||||||
|
def add_alert(self, alert):
|
||||||
|
self.alerts.append(alert)
|
||||||
|
if self.on_alert_callback:
|
||||||
|
self.on_alert_callback(alert)
|
||||||
|
|
||||||
|
def get_alerts(self):
|
||||||
|
return self.alerts
|
||||||
|
|
||||||
|
def clear_alerts(self):
|
||||||
|
self.alerts.clear()
|
||||||
|
|
||||||
|
class Alert:
|
||||||
|
def __init__(self, alertType: "AlertType", message: str):
|
||||||
|
self.alertType = alertType
|
||||||
|
self.message = message
|
||||||
|
self.timestamp = time.time()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"type": self.alertType,
|
||||||
|
"message": self.message,
|
||||||
|
"timestamp": self.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_json(self):
|
||||||
|
import json
|
||||||
|
return json.dumps(self.to_dict())
|
||||||
|
|
||||||
|
def from_json(json_string: str) -> "Alert":
|
||||||
|
import json
|
||||||
|
data = json.loads(json_string)
|
||||||
|
alert = Alert(data["type"], data["message"])
|
||||||
|
alert.timestamp = data["timestamp"]
|
||||||
|
return alert
|
||||||
|
|
||||||
|
class AlertType:
|
||||||
|
COOKING_SAFETY = "COOKING_SAFETY"
|
||||||
+33
-16
@@ -20,6 +20,8 @@ else:
|
|||||||
class BaseLoraDevice:
|
class BaseLoraDevice:
|
||||||
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
|
"""Base class providing automatic ACK generation, retries, and duplicate filtering."""
|
||||||
|
|
||||||
|
MAX_PAYLOAD_SIZE = 222 # Maximum safe LoRa payload size in bytes
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.processed_msg_ids = set()
|
self.processed_msg_ids = set()
|
||||||
self.received_acks = set()
|
self.received_acks = set()
|
||||||
@@ -225,6 +227,13 @@ if IS_MICROPYTHON:
|
|||||||
payload = payload.encode('utf-8')
|
payload = payload.encode('utf-8')
|
||||||
|
|
||||||
paquet_physique = bytes([group]) + payload
|
paquet_physique = bytes([group]) + payload
|
||||||
|
|
||||||
|
if len(paquet_physique) > self.MAX_PAYLOAD_SIZE:
|
||||||
|
raise ValueError(
|
||||||
|
f"[LoRa SPI] Payload exceeds MTU limit: {len(paquet_physique)} bytes "
|
||||||
|
f"(max allowed: {self.MAX_PAYLOAD_SIZE} bytes)."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.lora.send(paquet_physique)
|
self.lora.send(paquet_physique)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -290,18 +299,19 @@ else:
|
|||||||
# Initial configuration
|
# Initial configuration
|
||||||
self.configure(freq=868.1, sf=7, bw=125)
|
self.configure(freq=868.1, sf=7, bw=125)
|
||||||
|
|
||||||
def _send_at_cmd(self, cmd, wait_time=0.3):
|
def _send_at_cmd(self, cmd, timeout=1.5):
|
||||||
"""Sends AT command, draining unread serial noise first."""
|
"""Sends AT command and reads response until line received or timeout."""
|
||||||
# Drain any lingering lines (like 'Rssi= -4' or incoming data)
|
|
||||||
if self.ser.in_waiting > 0:
|
|
||||||
self.ser.read_all()
|
|
||||||
|
|
||||||
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
self.ser.write(f"{cmd}\r\n".encode('utf-8'))
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
resp = ""
|
resp = ""
|
||||||
while self.ser.in_waiting > 0:
|
while (time.time() - start) < timeout:
|
||||||
resp += self.ser.readline().decode('utf-8', errors='ignore')
|
if self.ser.in_waiting > 0:
|
||||||
|
line = self.ser.readline().decode('utf-8', errors='ignore')
|
||||||
|
resp += line
|
||||||
|
if "OK" in line or "ERROR" in line or "AT_BUSY" in line:
|
||||||
|
break
|
||||||
|
time.sleep(0.02)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
def configure(self, freq=868.1, sf=7, bw=125):
|
def configure(self, freq=868.1, sf=7, bw=125):
|
||||||
@@ -312,14 +322,14 @@ else:
|
|||||||
|
|
||||||
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
|
# Parameters: Freq, SF, BW, CR(0=4/5), Preamble(8), Header(1=Explicit), CRC(1=ON), IQ(0=Standard), NetMode(0=P2P), Power(14), SyncWord(18=0x12), Format(0), Type(1)
|
||||||
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
|
at_cfg_cmd = f"AT+CFG={freq_hz},{sf},{bw_code},0,8,1,1,0,0,14,18,0,1"
|
||||||
self._send_at_cmd(at_cfg_cmd, wait_time=0.2)
|
self._send_at_cmd(at_cfg_cmd, timeout=0.2)
|
||||||
|
|
||||||
# Fallback standalone commands
|
# Fallback standalone commands
|
||||||
self._send_at_cmd("AT+SYNCWORD=18", wait_time=0.1)
|
self._send_at_cmd("AT+SYNCWORD=18", timeout=0.1)
|
||||||
self._send_at_cmd("AT+PRECV=65535", wait_time=0.1)
|
self._send_at_cmd("AT+PRECV=65535", timeout=0.1)
|
||||||
self.ser.reset_input_buffer()
|
self.ser.reset_input_buffer()
|
||||||
|
|
||||||
def send(self, payload, group=None):
|
def send(self, payload, group=None, retries=0):
|
||||||
"""Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
|
"""Encodes payload into a HEX string and transmits via 4-parameter AT+SEND."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if group is None:
|
if group is None:
|
||||||
@@ -331,14 +341,20 @@ else:
|
|||||||
if isinstance(payload, str):
|
if isinstance(payload, str):
|
||||||
payload = payload.encode('utf-8')
|
payload = payload.encode('utf-8')
|
||||||
|
|
||||||
|
if len(payload) > self.MAX_PAYLOAD_SIZE:
|
||||||
|
raise ValueError(
|
||||||
|
f"[LoRa Serial] Payload exceeds MTU limit: {len(payload)} bytes "
|
||||||
|
f"(max allowed: {self.MAX_PAYLOAD_SIZE} bytes)."
|
||||||
|
)
|
||||||
|
|
||||||
hex_payload = payload.hex()
|
hex_payload = payload.hex()
|
||||||
|
|
||||||
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
|
log(f"[RPi LoRa Serial] Transmitting HEX string: {hex_payload}")
|
||||||
|
|
||||||
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
|
# Format: AT+SEND=<group>,<payload_string>,<confirm>,<retries>
|
||||||
cmd = f"AT+SEND={group},{hex_payload},0,3"
|
cmd = f"AT+SEND={group},{hex_payload},0,{retries}"
|
||||||
|
|
||||||
resp = self._send_at_cmd(cmd, wait_time=0.3)
|
resp = self._send_at_cmd(cmd, timeout=0.3)
|
||||||
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
|
log(f"[RPi LoRa Serial] AT+SEND response: {resp}")
|
||||||
|
|
||||||
def receive_packet(self, timeout_ms=500):
|
def receive_packet(self, timeout_ms=500):
|
||||||
@@ -424,4 +440,5 @@ class LoraCommands:
|
|||||||
PING = "ping"
|
PING = "ping"
|
||||||
COOKING_STATE_UPDATE = "cooking_state_update"
|
COOKING_STATE_UPDATE = "cooking_state_update"
|
||||||
TOGGLE_PAUSE = "toggle_pause"
|
TOGGLE_PAUSE = "toggle_pause"
|
||||||
TOGGLE_DEFROST = "toggle_defrost"
|
TOGGLE_DEFROST = "toggle_defrost"
|
||||||
|
NEW_ALERT = "new_alert"
|
||||||
+13
-1
@@ -1,5 +1,11 @@
|
|||||||
from time import time
|
from time import time
|
||||||
from shared.deviceTypes import DEVICE_TYPES
|
from shared.deviceTypes import DEVICE_TYPES
|
||||||
|
from shared.alerts import Alert, AlertType
|
||||||
|
import shared.config as config
|
||||||
|
try:
|
||||||
|
from shared.lora_device import LoraCommands
|
||||||
|
except ImportError:
|
||||||
|
pass # No need
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -65,4 +71,10 @@ def telemetry_payload(device_id, microwave_states, button_state, cloud_alert, gp
|
|||||||
"ambient_humidity": ambient_humidity,
|
"ambient_humidity": ambient_humidity,
|
||||||
"connected_components": connected_components,
|
"connected_components": connected_components,
|
||||||
"logs": logs
|
"logs": logs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def lora_new_alert(alert: Alert):
|
||||||
|
return {
|
||||||
|
"action": LoraCommands.NEW_ALERT,
|
||||||
|
"alert_type": alert.alertType
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user