diff --git a/README.md b/README.md
index 861969a..785d352 100644
--- a/README.md
+++ b/README.md
@@ -34,10 +34,10 @@ Every 4 hours, it delivers a **detailed report** complete with a 24-hour trend g
Every 30 minutes (`SLEEP_TIME` in `main.py`, default 1800 seconds):
1. **Speed Test:** Measures download/upload speeds, ping latency, ISP, and test server details using `speedtest-cli` (see [the note on measurement mode](#a-note-on-measurement-mode)).
-2. **LAN Scan:** Scans the local subnet using `nmap` ARP scan to count active connected devices.
-3. **Local Storage:** Saves metrics & device tallies directly to a local `metrics.sql` SQLite database.
+2. **LAN Scan:** Scans the local subnet using `nmap` ARP scan to identify active devices, including MAC address, vendor, and hostname where resolvable (see [Device Watch](#device-watch)).
+3. **Local Storage:** Saves metrics & device details directly to a local `metrics.sql` SQLite database.
4. **Status Alert:** Sends a concise status update to your chosen notifier (*"all good"* or *"line is dying"*).
-5. **24h AI Report:** Every 8th cycle (every 4h), generates a **24-hour trend graph** via `matplotlib` alongside a sarcastic LLM analysis of network load and speed fluctuations.
+5. **24h AI Report:** Every 8th cycle (every 4h), generates a **24-hour trend graph** via `matplotlib` alongside a sarcastic LLM analysis of network load, speed fluctuations, and any notable new devices on the network.
---
@@ -194,6 +194,14 @@ Since netmon exists to track *trends*, consistency matters more than peak number
---
+## Device Watch
+
+Every device scan records each device's MAC address, vendor (resolved from `nmap`'s built-in OUI database), and hostname where available. `nmap` can only resolve a MAC for hosts on the same local subnet it can ARP directly — off-subnet or otherwise hidden devices are still counted, just not identified.
+
+Using MAC address history, the detailed AI report includes a **Device Watch** section that flags any device whose MAC hasn't been seen on the network in the last 14 days, alongside a vendor-count breakdown of everything currently online. A device with no resolvable MAC is never flagged as new, since there's no reliable identity to compare against.
+
+---
+
## Example Output
### Hourly Short Status Update
@@ -238,6 +246,9 @@ Devices Online: 9
24-Hour Dynamics Analysis
Over the last 24 hours, the download speed averaged 140 Mbps, but we saw a massive drop to 20 Mbps at 8:00 PM right as device count jumped from 4 to 11 devices. Clearly, someone's hogging the bandwidth or the ISP's mice were busy chewing on the fiber line again. Latency remained stable except for a brief spike during peak hours.
+Device Watch
+One new gadget joined the party today: a device with no vendor or hostname info at all — worth a second glance. Everything else is the same suspects as always.
+
Data Transfer (Latest Test)
Downloaded: 160.0 MB
@@ -248,6 +259,9 @@ Uploaded: 70.0 MB
Expect periodic speed drops whenever local freeloaders stream 4K movies or the ISP potato infrastructure struggles.
```
+> [!NOTE]
+> The AI is only ever asked for three short text fields (the dynamics analysis, the Device Watch line, and the conclusion) — the surrounding HTML structure above is assembled deterministically in code, not generated by the model. This keeps report formatting consistent regardless of which LLM is behind `AI_BASE_URL`, including smaller local models that would otherwise struggle to reproduce a long literal template reliably.
+
---
## Project Structure
diff --git a/main.py b/main.py
index ddff21f..f2cbe2c 100644
--- a/main.py
+++ b/main.py
@@ -1,5 +1,6 @@
import signal
import sys
+import json
import logging
import graphs
import config as cfg
@@ -13,83 +14,77 @@
from notifier import ChatAction, Notifier
REPORT_SYSTEM_PROMPT = """
-You are a sarcastic, cynical network analyst bot. Your job is to output a short network speed test and 24-hour trend report in Telegram HTML format.
-You will receive a list of speed tests from the last 24 hours in chronological order (the last line is the latest test).
+You are a sarcastic, cynical network analyst bot.
-You must write the report in ENGLISH.
-You must follow the EXACT structure below. Do not deviate from this layout, header naming, or formatting.
+You will receive: (1) a chronological list of network speed test results from the last 24 hours, (2) a device vendor breakdown for the network (vendor name + how many devices of that vendor are currently online), and (3) a list of any devices that are NEW this cycle (their MAC address has not been seen on this network in the last 14 days), each with whatever vendor/hostname/IP is available.
-EXPECTED STRUCTURE:
-Network Speed Test Report (24h Analysis)
+Respond with ONLY a single raw JSON object -- no ```json code fences, no preamble, no explanation before or after it. Just the JSON object, starting with { and ending with }. It must have exactly these three string keys:
-Client: [Client ISP]
-Server: [Server Name]
+{
+ "dynamics_analysis": "2-3 short sentences analyzing speed/ping trends and drops over the given period. ONLY claim a link between device count and speed/latency swings if the numbers actually move together in the same window (e.g. speed visibly drops as device count rises). If device count swings while speed/ping stay flat, say plainly that device count does NOT explain it, and point at the ISP/line instead -- never invent a correlation the numbers don't support. If ping reads exactly 0.00 ms while download speed is very low, that means the real ping was too high to register and got floored to zero -- call it a red flag, not a strength, never a good sign. Do NOT blame server changes for fluctuations -- assume the server choice is optimal. Wrap key numbers in ... tags, e.g. 148.31 Mbps.",
+ "device_watch": "1-2 short sentences. If the NEW devices list you were given is empty, say so plainly (e.g. 'No new devices -- same suspects as always.'). Otherwise mention only devices from that NEW list, identified by vendor/hostname if given, otherwise by IP. Call out anything more suspicious than the rest -- e.g. a NEW device with no vendor or hostname info at all is more worth a second glance than a NEW device from a recognizable vendor. Never invent a device, vendor, hostname, or IP not given to you, and do not restate the full vendor breakdown here -- that's background context only, not something to list out.",
+ "conclusion": "Exactly 1 short, witty, sarcastic sentence summarizing the network's overall quality/reliability over the period."
+}
-Latest Test Metrics
-
-Download: [Download Speed] Mbps
-Upload: [Upload Speed] Mbps
-Ping: [Ping Latency] ms
-Devices Online: [Device Count]
-
+TONE (this matters more than anything else): sarcastic, informal, and funny throughout. Blame heavy users/leeches on the network or the ISP for problems -- e.g. "a bunch of idiots clogging the bandwidth", "the ISP dropping the ball", "mice chewing the optic fiber cables", "yet another gadget joining the freeloader party" -- but only when the data actually supports that story. A flat, neutral, corporate-analyst tone is a FAILED response even if the JSON is technically valid -- the personality is not optional decoration, it is the entire point of this bot. If in doubt, lean funnier and more informal, not safer and more clinical.
-24-Hour Dynamics Analysis
-[Analyze the dynamics, drops, and load of the network over the last 24 hours. Note any major drops in download/upload speeds or ping spikes.
-Also look at how the device count changed over the same period. ONLY claim a link between device count and speed/latency swings if the numbers actually move together (e.g. speed visibly drops in the same window device count rises). If device count swings around while speed/ping stay flat, say plainly that device count does NOT explain it this period, and point at the ISP/line instead. Never invent a correlation that isn't supported by the numbers.
-If ping reads exactly 0.00 ms while download speed is very low (a few Mbps or less), do NOT describe that as a good/perfect ping. That reading means the real ping was too high to register and got floored to zero — call it a red flag, not a strength.
-Use a sarcastic, informal tone when describing speed drops, latency spikes, or a sudden herd of new devices, blaming heavy users/leeches on the network or the ISP (e.g. "a bunch of idiots clogging the bandwidth", "ISP dropping the ball", "mice chewing the optic fiber cables", or "yet another gadget joining the freeloader party") — but only when the data actually supports that story.
-CRITICAL: Do NOT blame server changes for fluctuations. Assume the server choice is optimal and fluctuations reflect real network load, device count, or ISP issues.
-Wrap key numbers in tags, e.g., 148.31 Mbps, 15.18 ms, or 7 devices.]
+LENGTH LIMITS (hard requirements): "dynamics_analysis" under 500 characters, "device_watch" under 250 characters, "conclusion" under 150 characters.
-Data Transfer (Latest Test)
-
-Downloaded: [Downloaded MB] MB
-Uploaded: [Uploaded MB] MB
-
-
-Conclusion
-[A sarcastic, witty 1 short sentence summary of the network's overall quality and reliability over the past day.]
-
-
-TEMPLATE EXAMPLE OF THE OUTPUT:
-Network Speed Test Report (24h Analysis)
+Output ONLY the JSON object and nothing else -- no markdown formatting, no headers, no bullet points, no explanatory text, no restating of the raw data you were given.
+"""
-Client: nameserver
-Server: New York
+# The AI is only ever asked to produce the three free-text fields above --
+# never the surrounding HTML structure. This is deliberate: several
+# capable local models (tested: llama3.1:8b, qwen2.5:7b-instruct) reliably
+# abandon a long literal HTML template under a rich, multi-constraint
+# prompt and fall back to a generic "helpful assistant summarizing data"
+# response instead, even with a large context window. Handling the
+# skeleton in code guarantees correct, consistent formatting regardless of
+# which model is behind AI_BASE_URL, and only requires the model to
+# reliably produce three short strings in a JSON object -- a much easier
+# and more commonly well-supported task for small/local instruct models
+# than exact literal markup reproduction.
+REPORT_TEMPLATE_SHELL = """Network Speed Test Report (24h Analysis)
+
+Client: {client}
+Server: {server}
Latest Test Metrics
-Download: 140.3 Mbps
-Upload: 62.8 Mbps
-Ping: 15.2 ms
-Devices Online: 7
+Download: {download:.1f} Mbps
+Upload: {upload:.1f} Mbps
+Ping: {ping:.1f} ms
+Devices Online: {device_count}
24-Hour Dynamics Analysis
-Over the last 24 hours, the download speed averaged 140 Mbps, but we saw a massive drop to 20 Mbps at 8:00 PM right as device count jumped from 4 to 11 devices. Clearly, a bunch of idiots decided to stream 4K movies all at once, or the ISP's mice were busy chewing on the fiber line again. Latency remained stable except for a brief spike to 95 ms during the speed dip.
+{dynamics_analysis}
+
+Device Watch
+{device_watch}
Data Transfer (Latest Test)
-Downloaded: 160.0 MB
-Uploaded: 70.0 MB
+Downloaded: {download_mb:.1f} MB
+Uploaded: {upload_mb:.1f} MB
Conclusion
-Expect periodic speed deaths whenever the local leechers wake up or the ISP fails to maintain their potato infrastructure.
-
-
-CRITICAL RULES:
-1. Do NOT use
or
tags. For line breaks, use normal newlines.
-2. The entire report must be in English.
-3. Keep the "24-Hour Dynamics Analysis" to exactly 2-3 short sentences.
-4. Do NOT write any description text below the "Data Transfer (Latest Test)" pre-block.
-5. Keep the "Conclusion" to exactly 1 short sentence.
-6. Highlight all numeric metric values in the text using [Value].
-7. Do NOT output any markdown blocks like ```html. Output raw HTML tags directly.
-8. Make sure all HTML tags are closed correctly.
-9. Be sarcastic, informal, and funny when describing performance dips or network load.
-10. The entire output MUST be under 800 characters to ensure it easily fits within Telegram limits.
-"""
+{conclusion}"""
+
+# Defensive per-field caps applied in code regardless of what the prompt
+# asked for -- a safety net, not the primary mechanism, since the prompt's
+# own instructed limits should normally keep fields well under these.
+_DYNAMICS_ANALYSIS_MAX_CHARS = 600
+_DEVICE_WATCH_MAX_CHARS = 320
+_CONCLUSION_MAX_CHARS = 200
+
+
+def _clip(text: str, max_chars: int) -> str:
+ text = (text or "").strip()
+ if len(text) <= max_chars:
+ return text
+ return text[: max_chars - 1].rstrip() + "…"
REPORT_USER_TEMPLATE = """
Network speed test results:
@@ -105,6 +100,10 @@
- Devices online: {device_count}
"""
+VENDOR_COUNT_TEMPLATE = "- {vendor}: {count} device(s)"
+NEW_DEVICE_ENTRY_TEMPLATE = "- {ip}{vendor_part}{hostname_part}"
+
+
MINI_REPORT_TEMPLATE = """Network Status Update
Here is the latest snapshot of your internet speed:
@@ -191,20 +190,92 @@ def main():
device_count=device_count
) + "\n"
+ device_details = database.get_latest_devices_with_novelty()
+ if device_details:
+ vendor_counts: dict[str, int] = {}
+ for d in device_details:
+ if d["vendor"]:
+ key = d["vendor"]
+ elif d["mac"]:
+ key = "Unknown vendor"
+ else:
+ key = "Unidentified (off-subnet, no MAC resolved)"
+ vendor_counts[key] = vendor_counts.get(key, 0) + 1
+
+ vendor_lines = [
+ VENDOR_COUNT_TEMPLATE.format(vendor=vendor, count=count)
+ for vendor, count in sorted(vendor_counts.items(), key=lambda kv: -kv[1])
+ ]
+ user_message += "\nDevice vendor breakdown (currently online):\n" + "\n".join(vendor_lines) + "\n"
+
+ new_devices = [d for d in device_details if d["is_new"]]
+ if new_devices:
+ new_lines = []
+ for d in new_devices:
+ vendor_part = f" | Vendor: {d['vendor']}" if d["vendor"] else " | Vendor: unknown"
+ hostname_part = f" | Hostname: {d['hostname']}" if d["hostname"] else ""
+ new_lines.append(NEW_DEVICE_ENTRY_TEMPLATE.format(
+ ip=d["ip"],
+ vendor_part=vendor_part,
+ hostname_part=hostname_part,
+ ))
+ user_message += "\nNEW devices this cycle:\n" + "\n".join(new_lines) + "\n"
+ else:
+ user_message += "\nNEW devices this cycle: none.\n"
+ else:
+ user_message += "\nDevice vendor breakdown (currently online): none detected this cycle.\nNEW devices this cycle: none.\n"
+
t.send_chat_action(ChatAction.TYPING)
+ raw_response = None
try:
- report = netmon_ai.send_message(user_message, REPORT_SYSTEM_PROMPT)
- report = report.replace("
", "\n").replace("
", "\n").replace("
", "\n")
+ raw_response = netmon_ai.send_message(user_message, REPORT_SYSTEM_PROMPT)
+
+ # Defensive cleanup: some models wrap JSON in ```json
+ # fences despite being told not to -- strip those if present.
+ cleaned = raw_response.strip()
+ if cleaned.startswith("```"):
+ cleaned = cleaned.strip("`")
+ if cleaned.lower().startswith("json"):
+ cleaned = cleaned[4:]
+ cleaned = cleaned.strip()
+
+ parsed = json.loads(cleaned)
+
+ dynamics_analysis = _clip(str(parsed.get("dynamics_analysis", "")), _DYNAMICS_ANALYSIS_MAX_CHARS)
+ device_watch = _clip(str(parsed.get("device_watch", "")), _DEVICE_WATCH_MAX_CHARS)
+ conclusion = _clip(str(parsed.get("conclusion", "")), _CONCLUSION_MAX_CHARS)
+
+ if not dynamics_analysis or not device_watch or not conclusion:
+ raise ValueError(f"AI response missing one or more required fields: {parsed!r}")
+
except Exception as e:
- # AI backend down/unreachable/misconfigured: don't lose the
- # whole report, just send the graph with a plain notice
- # instead of a sarcastic AI-written one.
- log.error(f"AI report generation failed, sending graph without commentary: {e}")
- report = (
- "Network Speed Test Report (24h Analysis)\n\n"
- "AI commentary unavailable this cycle — the AI backend "
- "could not be reached. Raw graph data is attached below."
+ # Covers both AI backend failures (unreachable/misconfigured)
+ # and the model returning malformed/incomplete JSON -- either
+ # way, don't lose the whole report, just fall back to plain
+ # non-AI text for the three commentary fields. The skeleton
+ # itself is unaffected either way since it's built in code.
+ log.error(
+ f"AI report generation failed or returned invalid data: {e}\n"
+ f"Raw AI response was: {raw_response!r}",
+ exc_info=True,
)
+ dynamics_analysis = "AI commentary unavailable this cycle — the AI backend could not be reached or returned an unexpected response."
+ device_watch = "AI commentary unavailable this cycle."
+ conclusion = "Raw graph data is attached below."
+
+ report = REPORT_TEMPLATE_SHELL.format(
+ client=metric.client,
+ server=metric.server,
+ download=metric.download / 10**6,
+ upload=metric.upload / 10**6,
+ ping=metric.ping,
+ device_count=len(all_devices),
+ dynamics_analysis=dynamics_analysis,
+ device_watch=device_watch,
+ download_mb=metric.bytes_received / 10**6,
+ upload_mb=metric.bytes_sent / 10**6,
+ conclusion=conclusion,
+ )
t.send_chat_action(ChatAction.UPLOAD_PHOTO)
graph = graphs.NetmonGraph(metrics, device_counts)
diff --git a/models.py b/models.py
index f828c43..0273f0d 100644
--- a/models.py
+++ b/models.py
@@ -1,8 +1,9 @@
from dataclasses import dataclass
-import uuid
+import uuid
from uuid_extensions import uuid7str
from datetime import timezone
from datetime import datetime
+from typing import Optional
@dataclass(frozen=True, slots=True)
class NetworkMetric:
@@ -62,25 +63,34 @@ def create(
class NetworkDevice:
id: uuid.UUID
ip: str
- latency_ms: float
- timestamp: datetime
+ latency_ms: float
+ timestamp: datetime
+ mac: Optional[str] = None
+ vendor: Optional[str] = None
+ hostname: Optional[str] = None
@classmethod
def create(
cls,
ip: str,
latency_ms: float,
+ mac: Optional[str] = None,
+ vendor: Optional[str] = None,
+ hostname: Optional[str] = None,
) -> "NetworkDevice":
if not ip or not ip.strip():
raise ValueError("IP cannot be empty")
if latency_ms < 0:
raise ValueError("Latency must be non-negative")
-
+
return cls(
id=uuid.UUID(uuid7str()),
ip=ip,
latency_ms=latency_ms,
- timestamp=datetime.now(timezone.utc)
+ timestamp=datetime.now(timezone.utc),
+ mac=mac,
+ vendor=vendor,
+ hostname=hostname,
)
diff --git a/runner.py b/runner.py
index 3a5ab2a..14a4ccd 100644
--- a/runner.py
+++ b/runner.py
@@ -85,18 +85,45 @@ def run_speedtest(self) -> models.NetworkMetric:
@staticmethod
def _parse_device(host: ET.Element) -> models.NetworkDevice | None:
- addr = host.find("address")
status = host.find("status")
- if addr is None or status is None or status.get("state") != "up":
+ if status is None or status.get("state") != "up":
return None
+ ip: str | None = None
+ mac: str | None = None
+ vendor: str | None = None
+ for addr in host.findall("address"):
+ addrtype = addr.get("addrtype")
+ if addrtype == "ipv4":
+ ip = addr.get("addr")
+ elif addrtype == "mac":
+ # nmap only reports a MAC (and resolves vendor from its
+ # built-in OUI database) for hosts on the same local subnet
+ # it can ARP directly — this needs no extra scan flags,
+ # since the existing sudo ARP scan already returns it.
+ mac = addr.get("addr")
+ vendor = addr.get("vendor") or None
+
+ if not ip:
+ return None
+
+ hostname: str | None = None
+ hostnames_el = host.find("hostnames")
+ if hostnames_el is not None:
+ hostname_el = hostnames_el.find("hostname")
+ if hostname_el is not None:
+ hostname = hostname_el.get("name") or None
+
times = host.find("times")
srtt = times.get("srtt") if times is not None else None
latency_ms = round(int(srtt) / 1000, 2) if srtt is not None else 0
return models.NetworkDevice.create(
- ip=addr.get("addr") or "",
- latency_ms=latency_ms
+ ip=ip,
+ latency_ms=latency_ms,
+ mac=mac,
+ vendor=vendor,
+ hostname=hostname,
)
def run_devices_scan(self) -> list[models.NetworkDevice]:
diff --git a/sqlite.py b/sqlite.py
index a43a214..c9ffd34 100644
--- a/sqlite.py
+++ b/sqlite.py
@@ -56,9 +56,13 @@ def _create_schema(self):
CREATE TABLE IF NOT EXISTS device_scans (
id TEXT PRIMARY KEY,
ips TEXT NOT NULL,
- latencies TEXT NOT NULL
+ latencies TEXT NOT NULL,
+ macs TEXT NOT NULL DEFAULT '[]',
+ vendors TEXT NOT NULL DEFAULT '[]',
+ hostnames TEXT NOT NULL DEFAULT '[]'
);
""")
+ self._migrate_device_scans_columns()
self.conn.execute("""
CREATE TABLE IF NOT EXISTS speedtest (
@@ -68,6 +72,18 @@ def _create_schema(self):
);
""")
+ def _migrate_device_scans_columns(self):
+ # Databases created before mac/vendor/hostname tracking was added
+ # won't have these columns yet; add them in place so upgrading
+ # doesn't require recreating the database.
+ cursor = self.conn.execute("PRAGMA table_info(device_scans)")
+ existing_cols = {row[1] for row in cursor.fetchall()}
+ for col in ("macs", "vendors", "hostnames"):
+ if col not in existing_cols:
+ self.conn.execute(
+ f"ALTER TABLE device_scans ADD COLUMN {col} TEXT NOT NULL DEFAULT '[]'"
+ )
+
@contextmanager
def transaction(self):
depth = _tx_depth.get()
@@ -103,12 +119,15 @@ def add_devices(self, devices: list[models.NetworkDevice]) -> uuid.UUID:
scan_id = uuid.UUID(uuid7str())
ips = json.dumps([d.ip for d in devices])
latencies = json.dumps([d.latency_ms for d in devices])
+ macs = json.dumps([d.mac for d in devices])
+ vendors = json.dumps([d.vendor for d in devices])
+ hostnames = json.dumps([d.hostname for d in devices])
with self.transaction():
self.conn.execute("""
- INSERT INTO device_scans (id, ips, latencies)
- VALUES (?, ?, ?)
- """, (str(scan_id), ips, latencies))
+ INSERT INTO device_scans (id, ips, latencies, macs, vendors, hostnames)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """, (str(scan_id), ips, latencies, macs, vendors, hostnames))
return scan_id
def add_speedtest(self, speedtest: models.SpeedTest):
@@ -192,5 +211,63 @@ def get_metrics_with_device_counts(self) -> tuple[list[models.NetworkMetric], li
return metrics, device_counts
+ def get_latest_devices_with_novelty(self, lookback_days: int = 14) -> list[dict]:
+ """
+ Returns the most recent device scan's devices, each annotated with
+ whether its MAC address has been seen in any earlier scan within
+ the lookback window — i.e. whether it's new to the network.
+ Devices without a resolvable MAC (nmap only resolves MAC for hosts
+ on the same local subnet) are never flagged as new, since there's
+ no reliable identity to compare against.
+ """
+ try:
+ with closing(self.conn.cursor()) as cursor:
+ cursor.execute("""
+ SELECT id, ips, latencies, macs, vendors, hostnames
+ FROM device_scans
+ ORDER BY rowid DESC
+ LIMIT 1
+ """)
+ latest = cursor.fetchone()
+ if latest is None:
+ return []
+
+ latest_id = latest[0]
+ ips = json.loads(latest[1])
+ latencies = json.loads(latest[2])
+ macs = json.loads(latest[3])
+ vendors = json.loads(latest[4])
+ hostnames = json.loads(latest[5])
+
+ cursor.execute("""
+ SELECT ds.macs
+ FROM device_scans ds
+ JOIN speedtest st ON st.device_scans_id = ds.id
+ JOIN metrics m ON m.id = st.metrics_id
+ WHERE ds.id != ?
+ AND m.timestamp > DATETIME('now', ?)
+ """, (latest_id, f'-{lookback_days} days'))
+
+ known_macs: set[str] = set()
+ for (macs_json,) in cursor.fetchall():
+ for m in json.loads(macs_json):
+ if m:
+ known_macs.add(m)
+ except sqlite3.Error as e:
+ raise RuntimeError(f"Failed to get latest devices with novelty: {e}")
+
+ devices = []
+ for i, ip in enumerate(ips):
+ mac = macs[i] if i < len(macs) else None
+ devices.append({
+ "ip": ip,
+ "mac": mac,
+ "vendor": vendors[i] if i < len(vendors) else None,
+ "hostname": hostnames[i] if i < len(hostnames) else None,
+ "latency_ms": latencies[i] if i < len(latencies) else None,
+ "is_new": bool(mac) and mac not in known_macs,
+ })
+ return devices
+
def close(self):
self.conn.close()
\ No newline at end of file