Загрузка данных
# -*- coding: utf-8 -*-
__id__ = "fg_team_scan"
__name__ = "FG_TEAM_SCAN"
__version__ = "1.0.0"
__author__ = "FG TEAM"
__description__ = "Мощный BLE-сканер: сканирование, GATT, чтение/запись, BLE-режимы"
__icon__ = "rounded:radar"
__permissions__ = ["bluetooth", "files"]
import json
import time
MAX_DEVICES = 500
REFRESH_INTERVAL = 0.5
def _safe_int(v, d=0):
try:
return int(v)
except Exception:
return d
def _safe_str(v, d=""):
try:
if v is None:
return d
return str(v)
except Exception:
return d
def _json_load(s):
try:
if isinstance(s, str):
return json.loads(s)
return s
except Exception:
return None
def _rssi_bars(rssi):
r = _safe_int(rssi, -100)
if r >= -55:
return 4
if r >= -70:
return 3
if r >= -85:
return 2
return 1
def _rssi_color(rssi):
r = _safe_int(rssi, -100)
if r >= -55:
return "success"
if r >= -70:
return "primary"
if r >= -85:
return "warning"
return "error"
def _bars_str(n):
n = max(0, min(4, int(n)))
return "▮" * n + "▯" * (4 - n)
def _fmt_time(ts):
try:
return time.strftime("%H:%M:%S", time.localtime(ts))
except Exception:
return "--:--:--"
class FgTeamScan(BasePlugin):
def __init__(self):
super().__init__()
self._ready = False
self.scan_id = "fg_main"
self.scanning = False
self.devices = {}
self.selected_mac = None
self.search = ""
self.sort_mode = "rssi"
self.min_rssi = -100
self.hide_named_only = False
self._last_refresh = 0.0
self.gatt_services = []
self.gatt_connected = False
self.gatt_mac = None
self.log_lines = []
# ================= LIFECYCLE =================
def on_plugin_load(self):
try:
self.add_module(
title="FG_TEAM_SCAN",
description="BLE-сканер: скан, GATT, чтение/запись",
icon="radar",
screen="main",
section="BLUETOOTH",
order=10,
)
saved = self.get_setting("devices", "{}")
data = _json_load(saved)
if isinstance(data, dict):
self.devices = data
try:
self.register_ble_mode(
"fg_scan_mode",
"FG Тихое сканирование",
"Ускоренное BLE-сканирование FG",
"radar",
)
except Exception:
pass
self._ready = True
self._log("Плагин загружен")
except Exception as e:
self._log("load err: " + str(e))
def on_plugin_unload(self):
self._ready = False
try:
self._stop_scan()
except Exception:
pass
try:
if self.gatt_mac:
self._call("gattDisconnect", self.gatt_mac)
except Exception:
pass
try:
self._save_devices()
except Exception:
pass
# ================= HELPERS =================
def _log(self, msg):
try:
t = time.strftime("%H:%M:%S")
self.log_lines.append("[%s] %s" % (t, msg))
if len(self.log_lines) > 300:
self.log_lines = self.log_lines[-300:]
except Exception:
pass
def _throttled_refresh(self):
now = time.time()
if now - self._last_refresh >= REFRESH_INTERVAL:
self._last_refresh = now
try:
self.refresh()
except Exception:
pass
def _save_devices(self):
try:
small = {}
for mac, d in self.devices.items():
small[mac] = {
"name": d.get("name", ""),
"mac": mac,
"rssi": d.get("rssi", -100),
"first": d.get("first", 0),
"last": d.get("last", 0),
"count": d.get("count", 0),
}
self.set_setting("devices", json.dumps(small, ensure_ascii=False))
except Exception:
pass
# ================= SCAN =================
def _start_scan(self):
if self.scanning:
return
device = None
try:
device = self.api.getDevice()
except Exception:
device = None
started = False
# Вариант 1: device.bleStartScan
try:
if device is not None:
fn = getattr(device, "bleStartScan", None)
if callable(fn):
try:
fn(self.scan_id, self._on_ble)
started = True
except TypeError:
try:
fn(self.scan_id, self._on_ble, 350, MAX_DEVICES)
started = True
except Exception as e:
self._log("dev.bleStartScan (3): " + str(e))
except Exception as e:
self._log("dev.bleStartScan: " + str(e))
# Вариант 2: api.bleStartScan
if not started:
try:
fn = getattr(self.api, "bleStartScan", None)
if callable(fn):
try:
fn(self.scan_id, self._on_ble, 350, MAX_DEVICES)
started = True
except TypeError:
try:
fn(self.scan_id, self._on_ble)
started = True
except Exception as e:
self._log("api.bleStartScan (2): " + str(e))
except Exception as e:
self._log("api.bleStartScan: " + str(e))
if started:
self.scanning = True
self._log("Сканирование запущено")
else:
self.toast("Не удалось запустить сканирование")
try:
self.refresh()
except Exception:
pass
def _stop_scan(self):
if not self.scanning:
return
try:
device = self.api.getDevice()
fn = getattr(device, "stopBleScan", None)
if callable(fn):
try:
fn(self.scan_id)
except Exception:
pass
except Exception:
pass
try:
fn = getattr(self.api, "bleStopScan", None)
if callable(fn):
try:
fn(self.scan_id)
except Exception:
pass
except Exception:
pass
self.scanning = False
self._log("Сканирование остановлено")
self._save_devices()
try:
self.refresh()
except Exception:
pass
def _on_ble(self, data):
try:
obj = _json_load(data) if isinstance(data, str) else data
if obj is None:
return
items = obj if isinstance(obj, list) else [obj]
now = time.time()
changed = False
for it in items:
if not isinstance(it, dict):
continue
mac = _safe_str(it.get("address") or it.get("mac") or it.get("device"))
if not mac:
continue
name = _safe_str(it.get("name") or it.get("deviceName") or "")
rssi = _safe_int(it.get("rssi"), -100)
if mac in self.devices:
d = self.devices[mac]
d["rssi"] = rssi
d["last"] = now
d["count"] = int(d.get("count", 0)) + 1
if name and not d.get("name"):
d["name"] = name
else:
if len(self.devices) >= MAX_DEVICES:
weakest = None
for k, v in self.devices.items():
if weakest is None or v.get("rssi", -100) < self.devices[weakest].get("rssi", -100):
weakest = k
if weakest and rssi > self.devices[weakest].get("rssi", -100):
self.devices.pop(weakest, None)
else:
continue
self.devices[mac] = {
"name": name,
"mac": mac,
"rssi": rssi,
"first": now,
"last": now,
"count": 1,
}
changed = True
if changed:
self._throttled_refresh()
except Exception as e:
self._log("scan cb: " + str(e))
# ================= FILTER / SORT =================
def _filtered_sorted(self):
items = list(self.devices.values())
q = (self.search or "").strip().lower()
if q:
items = [d for d in items
if q in _safe_str(d.get("name")).lower()
or q in _safe_str(d.get("mac")).lower()]
items = [d for d in items if _safe_int(d.get("rssi"), -100) >= self.min_rssi]
if self.hide_named_only:
items = [d for d in items if _safe_str(d.get("name"))]
if self.sort_mode == "rssi":
items.sort(key=lambda d: _safe_int(d.get("rssi"), -100), reverse=True)
elif self.sort_mode == "name":
items.sort(key=lambda d: _safe_str(d.get("name")).lower())
else:
items.sort(key=lambda d: d.get("last", 0), reverse=True)
return items
# ================= MAIN SCREEN =================
def screen_main(self, ui, api, state):
try:
status = "Сканирование..." if self.scanning else "Остановлено"
total = len(self.devices)
sort_options = ["По сигналу", "По имени", "По времени"]
sort_map = {"rssi": "По сигналу", "name": "По имени", "time": "По времени"}
children = [
ui.text("FG_TEAM_SCAN", "headlineSmall"),
ui.text("Найдено устройств: %d" % total, "bodyMedium", color="muted"),
ui.text(status, "labelLarge",
color="success" if self.scanning else "muted"),
ui.row([
ui.button(
"СТОП" if self.scanning else "СТАРТ",
on_click=self._toggle_scan,
fill_max_width=True,
style="tonal" if self.scanning else "filled",
),
ui.button("Очистить", on_click=self._clear,
fill_max_width=True, style="outlined"),
], spacing=8),
ui.text_field(
value=self.search,
on_change=self._on_search,
label="Поиск по имени или MAC",
),
ui.dropdown(
sort_options,
selected=sort_map.get(self.sort_mode, "По сигналу"),
on_select=self._on_sort,
label="Сортировка",
),
ui.slider(
value=self.min_rssi,
on_change=self._on_min_rssi,
minimum=-100,
maximum=-30,
title="Мин. RSSI: %d дБм" % self.min_rssi,
steps=69,
),
ui.switch(
checked=self.hide_named_only,
on_change=self._on_hide_named,
title="Только с именем",
subtitle="Скрыть безымянные устройства",
),
ui.row([
ui.button("Экспорт", on_click=self._export,
fill_max_width=True, style="outlined"),
ui.button("Режимы", on_click=lambda: self.navigate("modes"),
fill_max_width=True, style="outlined"),
], spacing=8),
ui.divider(),
]
items = self._filtered_sorted()
if not items:
children.append(ui.text("Пусто. Нажмите СТАРТ.", "bodyMedium", color="muted"))
else:
for d in items[:MAX_DEVICES]:
children.append(self._device_card(ui, d))
return ui.scaffold(
title="FG_TEAM_SCAN",
content=ui.lazy_column(children, padding=12, spacing=8),
show_back=False,
)
except Exception as e:
return ui.scaffold(title="Ошибка", content=ui.text(str(e)))
def _device_card(self, ui, d):
mac = d.get("mac", "")
name = d.get("name") or "Без имени"
rssi = _safe_int(d.get("rssi"), -100)
bars = _bars_str(_rssi_bars(rssi))
color = _rssi_color(rssi)
subtitle = "%s · %s дБм · пакетов: %d · %s" % (
mac, rssi, d.get("count", 0), _fmt_time(d.get("last", 0))
)
return ui.card([
ui.row([
ui.text(name, "titleMedium", max_lines=1),
ui.text(bars, "titleMedium", color=color),
], spacing=8, fill_max_width=True),
ui.text(subtitle, "bodySmall", color="muted"),
ui.row([
ui.button("Подключить", on_click=lambda m=mac: self._open_device(m),
fill_max_width=True),
ui.button("Инфо", on_click=lambda m=mac: self._quick_log(m),
fill_max_width=True, style="outlined"),
], spacing=8),
], padding=12)
# ================= MAIN HANDLERS =================
def _toggle_scan(self):
if self.scanning:
self._stop_scan()
else:
self._start_scan()
def _clear(self):
self.devices = {}
self._save_devices()
try:
self.refresh()
except Exception:
pass
def _on_search(self, v):
self.search = _safe_str(v)
try:
self.refresh()
except Exception:
pass
def _on_sort(self, v):
val = _safe_str(v)
if val == "По имени":
self.sort_mode = "name"
elif val == "По времени":
self.sort_mode = "time"
else:
self.sort_mode = "rssi"
try:
self.refresh()
except Exception:
pass
def _on_min_rssi(self, v):
try:
self.min_rssi = int(float(v))
except Exception:
self.min_rssi = -100
try:
self.refresh()
except Exception:
pass
def _on_hide_named(self, v):
self.hide_named_only = bool(v)
try:
self.refresh()
except Exception:
pass
def _quick_log(self, mac):
d = self.devices.get(mac) or {}
self.toast("%s · RSSI %s · пакетов %s" % (
d.get("name") or mac,
d.get("rssi", "?"),
d.get("count", 0),
))
def _export(self):
try:
lines = ["FG_TEAM_SCAN — " + time.strftime("%Y-%m-%d %H:%M:%S")]
lines.append("=" * 40)
for d in self._filtered_sorted():
lines.append("%s | %s | %s дБм | пакетов: %s | %s" % (
d.get("name") or "Без имени",
d.get("mac", ""),
d.get("rssi", ""),
d.get("count", 0),
_fmt_time(d.get("last", 0)),
))
text = "\n".join(lines)
fname = "fg_team_scan_%s.txt" % time.strftime("%Y%m%d_%H%M%S")
ok = False
try:
ok = bool(self.api.getAndroid().filesWrite(fname, text))
except Exception as e:
self._log("write err: " + str(e))
if ok:
self.toast("Сохранено: " + fname)
else:
self.toast("Не удалось сохранить")
except Exception as e:
self.toast("Ошибка экспорта: " + str(e))
# ================= DEVICE SCREEN =================
def _open_device(self, mac):
self.selected_mac = mac
self.gatt_services = []
self.gatt_connected = False
try:
self.navigate("device")
except Exception:
pass
def screen_device(self, ui, api, state):
mac = self.selected_mac or ""
d = self.devices.get(mac) or {}
name = d.get("name") or "Устройство"
content = [
ui.text(name, "headlineSmall"),
ui.text(mac or "—", "bodyMedium", color="muted"),
ui.text("Статус: " + ("Подключено" if self.gatt_connected else "Не подключено"),
"labelLarge",
color="success" if self.gatt_connected else "muted"),
ui.row([
ui.button("Подключить", on_click=self._gatt_connect,
fill_max_width=True, enabled=not self.gatt_connected),
ui.button("Отключить", on_click=self._gatt_disconnect,
fill_max_width=True, style="outlined",
enabled=self.gatt_connected),
], spacing=8),
ui.row([
ui.button("Сервисы", on_click=self._gatt_discover,
fill_max_width=True, style="outlined",
enabled=self.gatt_connected),
ui.button("RSSI", on_click=self._gatt_rssi,
fill_max_width=True, style="outlined",
enabled=self.gatt_connected),
], spacing=8),
ui.divider(),
]
if not self.gatt_services:
content.append(ui.text("Нет данных GATT. Подключитесь и нажмите «Сервисы».",
"bodyMedium", color="muted"))
else:
for s in self.gatt_services:
cards = [
ui.text(s.get("uuid", ""), "titleSmall", max_lines=1),
ui.text("Характеристик: %d" % len(s.get("chars", [])),
"bodySmall", color="muted"),
]
for c in s.get("chars", []):
cards.append(ui.card([
ui.text(c.get("uuid", ""), "bodyMedium", max_lines=1),
ui.text("Свойства: " + _safe_str(c.get("props", "—")),
"bodySmall", color="muted"),
ui.row([
ui.button("Читать", on_click=lambda cc=c: self._gatt_read(cc),
fill_max_width=True, enabled=self.gatt_connected),
ui.button("Запись", on_click=lambda cc=c: self._gatt_write(cc),
fill_max_width=True, style="outlined",
enabled=self.gatt_connected),
ui.button("Notify", on_click=lambda cc=c: self._gatt_notify(cc),
fill_max_width=True, style="outlined",
enabled=self.gatt_connected),
], spacing=6),
], padding=8))
content.append(ui.card(cards, padding=12))
content.append(ui.divider())
content.append(ui.text("Журнал:", "labelLarge"))
log = "\n".join(self.log_lines[-30:]) or "—"
content.append(ui.log_panel(log, max_height=180))
return ui.scaffold(
title="Устройство",
content=ui.lazy_column(content, padding=12, spacing=8),
)
# ================= GATT =================
def _dev(self):
try:
return self.api.getDevice()
except Exception:
return None
def _call(self, name, *args, **kwargs):
device = self._dev()
if device is None:
return None
fn = getattr(device, name, None)
if not callable(fn):
self._log("Нет метода: " + name)
return None
try:
return fn(*args, **kwargs)
except Exception as e:
self._log("%s err: %s" % (name, e))
return None
def _gatt_connect(self):
mac = self.selected_mac
if not mac:
return
r = self._call("gattConnect", mac)
if r is None:
r = self._call("gattConnect", mac, self._on_gatt)
self.gatt_connected = True
self.gatt_mac = mac
self._log("GATT connect → " + _safe_str(r)[:120])
try:
self.refresh()
except Exception:
pass
def _on_gatt(self, data):
try:
obj = _json_load(data) if isinstance(data, str) else data
self._log("GATT: " + _safe_str(obj)[:200])
self._throttled_refresh()
except Exception:
pass
def _gatt_disconnect(self):
mac = self.gatt_mac or self.selected_mac
if mac:
self._call("gattDisconnect", mac)
self.gatt_connected = False
self.gatt_services = []
self._log("GATT disconnect")
try:
self.refresh()
except Exception:
pass
def _gatt_discover(self):
mac = self.gatt_mac or self.selected_mac
if not mac:
return
self._call("gattDiscoverServices", mac)
js = self._call("gattServicesJson", mac)
parsed = _json_load(js) if js else None
if isinstance(parsed, list):
self.gatt_services = self._normalize_services(parsed)
elif isinstance(parsed, dict) and "services" in parsed:
self.gatt_services = self._normalize_services(parsed["services"])
else:
self._log("gattServicesJson → " + _safe_str(js)[:200])
try:
self.refresh()
except Exception:
pass
def _normalize_services(self, raw):
out = []
try:
for s in raw:
if not isinstance(s, dict):
continue
chars = []
for c in (s.get("characteristics") or s.get("chars") or []):
if not isinstance(c, dict):
continue
props = c.get("properties") or c.get("props") or ""
if isinstance(props, list):
props = " ".join([_safe_str(p) for p in props])
chars.append({
"uuid": _safe_str(c.get("uuid")),
"props": props,
"raw": c,
})
out.append({
"uuid": _safe_str(s.get("uuid")),
"chars": chars,
"raw": s,
})
except Exception as e:
self._log("normalize: " + str(e))
return out
def _gatt_read(self, char):
mac = self.gatt_mac or self.selected_mac
uuid = char.get("uuid")
if not (mac and uuid):
return
r = self._call("gattRead", mac, uuid)
parsed = _json_load(r) if r else None
if isinstance(parsed, dict) and "value" in parsed:
self._log("READ %s → %s" % (uuid[:8], parsed["value"]))
else:
self._log("READ %s → %s" % (uuid[:8], _safe_str(r)[:120]))
try:
self.refresh()
except Exception:
pass
def _gatt_write(self, char):
mac = self.gatt_mac or self.selected_mac
uuid = char.get("uuid")
if not (mac and uuid):
return
r = self._call("gattWrite", mac, uuid, "01")
if r is None:
r = self._call("gattWrite", mac, uuid, "0x01")
self._log("WRITE %s → %s" % (uuid[:8], _safe_str(r)[:120]))
try:
self.refresh()
except Exception:
pass
def _gatt_notify(self, char):
mac = self.gatt_mac or self.selected_mac
uuid = char.get("uuid")
if not (mac and uuid):
return
r = self._call("gattEnableNotifications", mac, uuid, True)
self._log("NOTIFY %s → %s" % (uuid[:8], _safe_str(r)[:120]))
try:
self.refresh()
except Exception:
pass
def _gatt_rssi(self):
mac = self.gatt_mac or self.selected_mac
if not mac:
return
r = self._call("gattReadRssi", mac)
self._log("RSSI → " + _safe_str(r)[:120])
try:
self.refresh()
except Exception:
pass
# ================= BLE MODES =================
def screen_modes(self, ui, api, state):
content = [
ui.text("BLE-режимы FG", "headlineSmall"),
ui.text("Дополнительные режимы FG_TEAM_SCAN", "bodyMedium", color="muted"),
ui.card([
ui.text("FG Тихое сканирование", "titleMedium"),
ui.text("Ускоренное сканирование с малым интервалом",
"bodySmall", color="muted"),
ui.row([
ui.button("Запустить", on_click=self._mode_start, fill_max_width=True),
ui.button("Стоп", on_click=self._mode_stop,
fill_max_width=True, style="outlined"),
], spacing=8),
], padding=12),
ui.divider(),
ui.text("Журнал:", "labelLarge"),
ui.log_panel("\n".join(self.log_lines[-20:]) or "—", max_height=160),
]
return ui.scaffold(
title="BLE-режимы",
content=ui.lazy_column(content, padding=12, spacing=8),
)
def _mode_start(self):
self._start_scan()
self.toast("Режим FG запущен")
def _mode_stop(self):
self._stop_scan()
self.toast("Режим FG остановлен")
def on_ble_mode_start(self, payload):
try:
if _safe_str(payload.get("modeId")) == "fg_scan_mode":
self._start_scan()
except Exception:
pass
def on_ble_mode_stop(self, payload):
try:
if _safe_str(payload.get("modeId")) == "fg_scan_mode":
self._stop_scan()
except Exception:
pass
def on_ble_mode_delay_changed(self, payload):
pass