Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a976dfa3fa | ||
|
|
ae8f756749 | ||
|
|
c3ed599198 | ||
|
|
38362be824 | ||
|
|
2f82cc36b4 | ||
|
|
260d8fad8f | ||
|
|
9babcfabc1 | ||
|
|
152ecc6e2b | ||
|
|
a96cdb859d | ||
|
|
e2cec738c8 | ||
|
|
f505f3c23b |
@@ -69,7 +69,7 @@ CHECK STORAGE
|
|||||||
|
|
||||||
### 4. DisplayController、OLED 与 RGB
|
### 4. DisplayController、OLED 与 RGB
|
||||||
|
|
||||||
每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即抢占一次 OLED。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。历史 power flags 只进入 diagnose 和一次性 Journal 记录。
|
每个当前故障都是 DisplayController 中的一个 active request,包含稳定 ID、priority、severity、OLED 显示数据、首次发现时间、最后变化时间和元数据。监控只提交或清除 request,同一轮采样作为一个原子批次,全部更新后只仲裁一次。DisplayController 始终只选一个最高优先级 owner;同一 ID 更新原请求而不重复入队,恢复时删除请求,同优先级新 request 排队而不抢占,owner 恢复后按 priority、first_seen、ID 重新仲裁。OLED 与 RGB 分别由专属工作线程驱动,监控主循环只更新共享显示状态;OLED 在主页和全部当前告警页之间轮播,告警页右上角显示 `当前页/告警总数`,新 owner 会立即唤醒 OLED 线程。告警页显示时 RGB 同步表达该页对象;回到主页时 RGB 保持最高优先级 active owner,全部恢复后才 OFF。相邻 OLED 页面需要的 RGB mode 相同时,RGB 线程不写寄存器、不重置动画相位。 风扇与 RGB 共用 MCU 0x0D;RGB 正在告警闪烁时,风扇档位命令排到灭灯阶段写入,并在写入后重新确认 RGB OFF,以 `FAN_LEVEL` 事件记录实际写入。历史 power flags 只进入 diagnose 和一次性 Journal 记录。
|
||||||
|
|
||||||
### 5. RGB 正常状态与告警引擎
|
### 5. RGB 正常状态与告警引擎
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,10 +1,10 @@
|
|||||||
96dfd20c3ad16ca62a46d66eb5ffb82b1f7f987e66c4dae5310609b1989b7dae README.md
|
4ac63c19d60ed3bb9d804727fe3d373a338f8203a2730a63876fb1845574822c README.md
|
||||||
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
72c46162c33f9587c6ccb01ad53fbaaf4ecec8cd21014b909e87e8707e253e36 app/oled_font_5x7.bin
|
||||||
d0fa68951097a90eb6f51b36e56dc935d8fd7632c3de0ad6bff0b448960a7c03 app/pigway_pi_control.py
|
e925e9b4720b24f2c802cdb746cfa9e2f5af1de84a7533acb09ab1efcce73a82 app/pigway_pi_control.py
|
||||||
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py
|
034931590d83969f9f24222cc57800ab193588beea3d3dcedb98f1690000370d app/pigway_pi_control_api.py
|
||||||
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf
|
ee0f682e8016f75f906d974f00c3e3340edddf30447d441ddfd61a7a34c9e83c config/pigway-pi-control.conf
|
||||||
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh
|
f9fbb4defa45b43abe0ce66973b1f4d553369b3a43c01d04f206c573f7dc96ca install.sh
|
||||||
4f6e8db3698a62f57823d3298a0e6814593de6fa3d31e4751b7ea4a76e553e35 systemd/pigway-pi-control.service
|
33a681e896cae05b9e9d5f89c5387036c9c1ea48d4db937ae03e5cdb7322705d systemd/pigway-pi-control.service
|
||||||
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
aa21cef9a06593371ccff005335b6c0c826fbf4b5c48e4255b5f4b75ce6e9217 systemd/pigway-pi-control-api.service
|
||||||
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
9f59e7313ee58e687ae6dd29d175c67082674254ac5c5f54168b2735b64c20a4 uninstall.sh
|
||||||
3a40109f11bea2d44e9d61bed4a90499a5047c4fc534f97aaa90becbf9e57017 web/index.html
|
27454feccb83ad23342fa9d66caed60d568beeed4862852df98a3bc82bf5b6e3 web/index.html
|
||||||
|
|||||||
+81
-36
@@ -98,6 +98,9 @@ def bus_write(method,*args):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
fan_level=0
|
fan_level=0
|
||||||
|
fan_initialized=False
|
||||||
|
pending_fan=None
|
||||||
|
pending_fan_lock=threading.Lock()
|
||||||
prev_cpu=None
|
prev_cpu=None
|
||||||
page=0; page_since=time.monotonic()
|
page=0; page_since=time.monotonic()
|
||||||
cpu_hi=mem_hi=0
|
cpu_hi=mem_hi=0
|
||||||
@@ -180,13 +183,36 @@ def fan_target(t):
|
|||||||
if t<FAN_T[4]: return 4
|
if t<FAN_T[4]: return 4
|
||||||
return 5
|
return 5
|
||||||
def update_fan(t):
|
def update_fan(t):
|
||||||
global fan_level
|
global fan_level,fan_initialized,pending_fan
|
||||||
want=fan_target(t); old=fan_level
|
want=fan_target(t); old=fan_level if fan_initialized else None
|
||||||
if want>fan_level: fan_level=want
|
if want>fan_level: fan_level=want
|
||||||
elif want<fan_level:
|
elif want<fan_level:
|
||||||
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
|
boundary = FAN_T[max(0,fan_level-1)] - FAN_HYST
|
||||||
if t < boundary: fan_level-=1
|
if t < boundary: fan_level-=1
|
||||||
if old!=fan_level: mcu(0x08,FAN_REG[fan_level])
|
if not fan_initialized or old!=fan_level:
|
||||||
|
with pending_fan_lock:
|
||||||
|
pending_fan=(old,fan_level,float(t))
|
||||||
|
fan_initialized=True
|
||||||
|
|
||||||
|
def apply_pending_fan(keep_rgb_off=False):
|
||||||
|
global pending_fan
|
||||||
|
with pending_fan_lock:
|
||||||
|
command=pending_fan
|
||||||
|
pending_fan=None
|
||||||
|
if command is None: return
|
||||||
|
old,new,temp=command
|
||||||
|
try:
|
||||||
|
mcu(0x08,FAN_REG[new])
|
||||||
|
# Some revisions of the shared MCU briefly expose their previous RGB
|
||||||
|
# state after a fan-register write. When the flash engine is already
|
||||||
|
# in its dark phase, immediately reassert OFF without changing phase.
|
||||||
|
if keep_rgb_off: mcu(0x07,0x00)
|
||||||
|
except Exception:
|
||||||
|
with pending_fan_lock:
|
||||||
|
if pending_fan is None: pending_fan=command
|
||||||
|
raise
|
||||||
|
log("INFO","FAN_LEVEL",old=FAN_NAME[old] if old is not None else "UNKNOWN",
|
||||||
|
new=FAN_NAME[new],temp_c=f"{temp:.1f}",register=f"0x{FAN_REG[new]:02x}")
|
||||||
|
|
||||||
def cpu_pct():
|
def cpu_pct():
|
||||||
global prev_cpu
|
global prev_cpu
|
||||||
@@ -493,9 +519,12 @@ def rgb_flash(mode,on):
|
|||||||
if not on:
|
if not on:
|
||||||
mcu(0x07,0x00)
|
mcu(0x07,0x00)
|
||||||
else:
|
else:
|
||||||
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
# Keep the seller's selector -> R -> G -> B protocol contiguous. A
|
||||||
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
# visible delay between these writes lets the MCU render stale partial
|
||||||
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
# channel data, which appears as a brief unrelated color or chase.
|
||||||
|
mcu(0x00,0xff)
|
||||||
|
mcu(0x01,channels[0])
|
||||||
|
mcu(0x02,channels[1])
|
||||||
mcu(0x03,channels[2])
|
mcu(0x03,channels[2])
|
||||||
_last_rgb=state
|
_last_rgb=state
|
||||||
|
|
||||||
@@ -503,10 +532,10 @@ def rgb_static(channels,state_name):
|
|||||||
global _last_rgb
|
global _last_rgb
|
||||||
state=(state_name,channels)
|
state=(state_name,channels)
|
||||||
if _last_rgb==state: return
|
if _last_rgb==state: return
|
||||||
mcu(0x07,0x00); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
mcu(0x07,0x00)
|
||||||
mcu(0x00,0xff); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
mcu(0x00,0xff)
|
||||||
mcu(0x01,channels[0]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
mcu(0x01,channels[0])
|
||||||
mcu(0x02,channels[1]); time.sleep(RGB_WRITE_DELAY_MS/1000.0)
|
mcu(0x02,channels[1])
|
||||||
mcu(0x03,channels[2])
|
mcu(0x03,channels[2])
|
||||||
_last_rgb=state
|
_last_rgb=state
|
||||||
|
|
||||||
@@ -579,10 +608,12 @@ class DisplayController:
|
|||||||
self.page_started=now
|
self.page_started=now
|
||||||
self.batch_depth=0
|
self.batch_depth=0
|
||||||
self.stop_event=threading.Event()
|
self.stop_event=threading.Event()
|
||||||
|
self.oled_wake=threading.Event()
|
||||||
|
self.oled_thread=threading.Thread(target=self._oled_worker,name="oled",daemon=True)
|
||||||
self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True)
|
self.rgb_thread=threading.Thread(target=self._rgb_worker,name="rgb",daemon=True)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
oled_init(); self.refresh(); rgb_off(); self.rgb_thread.start()
|
oled_init(); rgb_off(); self.oled_thread.start(); self.rgb_thread.start()
|
||||||
|
|
||||||
def update_home(self,cpu,temp,mem,disk):
|
def update_home(self,cpu,temp,mem,disk):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -620,7 +651,7 @@ class DisplayController:
|
|||||||
if self.batch_depth==0:
|
if self.batch_depth==0:
|
||||||
self._arbitrate_locked()
|
self._arbitrate_locked()
|
||||||
if self.current_oled_page_id not in self.requests:
|
if self.current_oled_page_id not in self.requests:
|
||||||
self._show_page_locked(self.current_owner_id)
|
self._select_page_locked(self.current_owner_id)
|
||||||
else: self._sync_rgb_locked()
|
else: self._sync_rgb_locked()
|
||||||
|
|
||||||
def begin_update(self):
|
def begin_update(self):
|
||||||
@@ -633,7 +664,7 @@ class DisplayController:
|
|||||||
if self.batch_depth==0:
|
if self.batch_depth==0:
|
||||||
self._arbitrate_locked()
|
self._arbitrate_locked()
|
||||||
if self.current_oled_page_id not in self.requests:
|
if self.current_oled_page_id not in self.requests:
|
||||||
self._show_page_locked(self.current_owner_id)
|
self._select_page_locked(self.current_owner_id)
|
||||||
else: self._sync_rgb_locked()
|
else: self._sync_rgb_locked()
|
||||||
|
|
||||||
def _arbitrate_locked(self):
|
def _arbitrate_locked(self):
|
||||||
@@ -651,23 +682,18 @@ class DisplayController:
|
|||||||
self.current_rgb_mode=request["rgb_mode"]
|
self.current_rgb_mode=request["rgb_mode"]
|
||||||
self.owner_since=time.monotonic()
|
self.owner_since=time.monotonic()
|
||||||
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
|
log("INFO","DISPLAY_OWNER",old=old,new=request["id"],priority=request["priority"])
|
||||||
self._show_page_locked(request["id"])
|
self._select_page_locked(request["id"])
|
||||||
|
|
||||||
def _ordered_alerts_locked(self):
|
def _ordered_alerts_locked(self):
|
||||||
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
|
return sorted((r for r in self.requests.values() if r["id"]!="NORMAL_HOME"),
|
||||||
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
key=lambda r:(-r["priority"],r["first_seen"],r["id"]))
|
||||||
|
|
||||||
def _show_page_locked(self,page_id):
|
def _select_page_locked(self,page_id,wake=True):
|
||||||
alerts=self._ordered_alerts_locked()
|
|
||||||
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
|
request=self.requests.get(page_id,self.requests["NORMAL_HOME"])
|
||||||
self.current_oled_page_id=request["id"]
|
self.current_oled_page_id=request["id"]
|
||||||
self.page_started=time.monotonic()
|
self.page_started=time.monotonic()
|
||||||
self._sync_rgb_locked()
|
self._sync_rgb_locked()
|
||||||
if request["id"]=="NORMAL_HOME":
|
if wake: self.oled_wake.set()
|
||||||
render_home(*request["metadata"]["home"])
|
|
||||||
else:
|
|
||||||
alert_ids=[a["id"] for a in alerts]
|
|
||||||
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
|
||||||
|
|
||||||
def _sync_rgb_locked(self):
|
def _sync_rgb_locked(self):
|
||||||
# Alert pages show that alert's object color. HOME keeps the highest-
|
# Alert pages show that alert's object color. HOME keeps the highest-
|
||||||
@@ -677,24 +703,23 @@ class DisplayController:
|
|||||||
rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"])
|
rgb_request=self.requests.get(self.current_owner_id,self.requests["NORMAL_HOME"])
|
||||||
if rgb_request is None:
|
if rgb_request is None:
|
||||||
rgb_request=self.requests["NORMAL_HOME"]
|
rgb_request=self.requests["NORMAL_HOME"]
|
||||||
self.current_rgb_mode=rgb_request["rgb_mode"]
|
wanted_mode=rgb_request["rgb_mode"]
|
||||||
|
if wanted_mode!=self.current_rgb_mode:
|
||||||
|
self.current_rgb_mode=wanted_mode
|
||||||
|
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
alerts=self._ordered_alerts_locked()
|
alerts=self._ordered_alerts_locked()
|
||||||
pages=["NORMAL_HOME"]+[a["id"] for a in alerts]
|
pages=["NORMAL_HOME"]+[a["id"] for a in alerts]
|
||||||
if self.current_oled_page_id not in pages:
|
if self.current_oled_page_id not in pages:
|
||||||
self._show_page_locked(self.current_owner_id)
|
self._select_page_locked(self.current_owner_id,wake=False)
|
||||||
return
|
elif len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL:
|
||||||
if len(pages)>1 and time.monotonic()-self.page_started>=PAGE_INTERVAL:
|
|
||||||
pos=pages.index(self.current_oled_page_id)
|
pos=pages.index(self.current_oled_page_id)
|
||||||
self._show_page_locked(pages[(pos+1)%len(pages)])
|
self._select_page_locked(pages[(pos+1)%len(pages)],wake=False)
|
||||||
return
|
request=dict(self.requests[self.current_oled_page_id])
|
||||||
request=self.requests[self.current_oled_page_id]
|
alert_ids=[a["id"] for a in alerts]
|
||||||
if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"])
|
if request["id"]=="NORMAL_HOME": render_home(*request["metadata"]["home"])
|
||||||
else:
|
else: render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
||||||
alert_ids=[a["id"] for a in alerts]
|
|
||||||
render_alert(request,alert_ids.index(request["id"])+1,len(alerts))
|
|
||||||
|
|
||||||
def owner_snapshot(self):
|
def owner_snapshot(self):
|
||||||
with self.lock: return dict(self.requests[self.current_owner_id])
|
with self.lock: return dict(self.requests[self.current_owner_id])
|
||||||
@@ -710,6 +735,19 @@ class DisplayController:
|
|||||||
for a in alerts],
|
for a in alerts],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _oled_worker(self):
|
||||||
|
previous_error=None
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
self.oled_wake.clear()
|
||||||
|
try:
|
||||||
|
self.refresh()
|
||||||
|
if previous_error is not None: log("INFO","OLED_RECOVERED")
|
||||||
|
previous_error=None
|
||||||
|
except Exception as e:
|
||||||
|
if repr(e)!=previous_error: log("ERROR","OLED_ERROR",error=repr(e))
|
||||||
|
previous_error=repr(e)
|
||||||
|
self.oled_wake.wait(OLED_REFRESH)
|
||||||
|
|
||||||
def _rgb_worker(self):
|
def _rgb_worker(self):
|
||||||
previous_error=None; applied_mode="OFF"; applied_flash=False
|
previous_error=None; applied_mode="OFF"; applied_flash=False
|
||||||
flash_on=None; animation_started=time.monotonic()
|
flash_on=None; animation_started=time.monotonic()
|
||||||
@@ -744,6 +782,11 @@ class DisplayController:
|
|||||||
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
|
rgb_static(NORMAL_COLOR,"normal_flash_on") if on else rgb_off()
|
||||||
else: rgb_flash(applied_mode,on)
|
else: rgb_flash(applied_mode,on)
|
||||||
flash_on=on
|
flash_on=on
|
||||||
|
# Fan and RGB share the same MCU. During an alert flash, send
|
||||||
|
# fan changes only while RGB is already dark so MCU-internal
|
||||||
|
# register handling cannot expose a transient unrelated color.
|
||||||
|
if not applied_flash or flash_on is False:
|
||||||
|
with bus_lock: apply_pending_fan(keep_rgb_off=applied_flash)
|
||||||
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
if previous_error is not None: log("INFO","RGB_RECOVERED")
|
||||||
previous_error=None
|
previous_error=None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -754,6 +797,8 @@ class DisplayController:
|
|||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
self.oled_wake.set()
|
||||||
|
if self.oled_thread.is_alive(): self.oled_thread.join()
|
||||||
if self.rgb_thread.is_alive(): self.rgb_thread.join()
|
if self.rgb_thread.is_alive(): self.rgb_thread.join()
|
||||||
for name,action in (("rgb",rgb_off),("oled_clear",lambda:oled_show([("","")]*4)),
|
for name,action in (("rgb",rgb_off),("oled_clear",lambda:oled_show([("","")]*4)),
|
||||||
("oled_off",lambda:cmd(0xAE))):
|
("oled_off",lambda:cmd(0xAE))):
|
||||||
@@ -808,7 +853,7 @@ args=parser.parse_args()
|
|||||||
if args.diagnose: raise SystemExit(diagnose())
|
if args.diagnose: raise SystemExit(diagnose())
|
||||||
|
|
||||||
watch_state=[]
|
watch_state=[]
|
||||||
last_sensor=last_render=last_status=0.0
|
last_sensor=last_status=0.0
|
||||||
last_net=last_watch=last_health=0.0
|
last_net=last_watch=last_health=0.0
|
||||||
health={}; fs_readonly=False
|
health={}; fs_readonly=False
|
||||||
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
agent={"last_sensor_ok":0.0,"last_network_ok":0.0,"last_watch_ok":0.0,"last_health_ok":0.0,"loop_errors":0,"i2c_errors":0}
|
||||||
@@ -818,6 +863,9 @@ latest=(0.0,0.0,0.0,0.0)
|
|||||||
display=DisplayController()
|
display=DisplayController()
|
||||||
try:
|
try:
|
||||||
bus=SMBus(1)
|
bus=SMBus(1)
|
||||||
|
# Quench the MCU's power-on RGB effect before OLED initialization and all
|
||||||
|
# other startup work. The display controller remains the sole RGB owner.
|
||||||
|
rgb_off()
|
||||||
FONT5=Path(FONT_PATH).read_bytes()
|
FONT5=Path(FONT_PATH).read_bytes()
|
||||||
display.start(); apply_dark_mode()
|
display.start(); apply_dark_mode()
|
||||||
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
log("INFO","START",version=APP_VERSION,uptime=uptime_seconds())
|
||||||
@@ -868,9 +916,6 @@ try:
|
|||||||
display.begin_update()
|
display.begin_update()
|
||||||
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
try: update_active_alerts(c,t,m,d,sample_elapsed)
|
||||||
finally: display.end_update()
|
finally: display.end_update()
|
||||||
if now-last_render >= OLED_REFRESH:
|
|
||||||
display.refresh()
|
|
||||||
last_render=now
|
|
||||||
if now-last_status >= 1.0:
|
if now-last_status >= 1.0:
|
||||||
write_status(c,t,m,d); last_status=now
|
write_status(c,t,m,d); last_status=now
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
Description=PIGWay Raspberry Pi OLED/Fan/RGB Monitor
|
||||||
After=multi-user.target network.target
|
After=local-fs.target systemd-modules-load.service
|
||||||
Wants=network.target
|
Before=multi-user.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
|||||||
+27
-19
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user