Загрузка данных


# -*- 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