Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,12 @@ TG_CHAT_ID=123456789

# Optional: HTTP timeout in seconds for Telegram/Discord requests (must be positive, default 10)
# REQUEST_TIMEOUT=30

# Optional: seconds between each speed test + device scan cycle (default 1800 = 30 min)
# SLEEP_TIME=1800
# Optional: how many cycles between detailed AI reports with graph (default 8, i.e. ~4 hours at the default SLEEP_TIME)
# REPORT_CYCLE_COUNT=8
# Optional: sets Ollama's num_ctx per-request via extra_body, to stop a local model's
# default context window from silently truncating a long prompt + a day of history.
# No effect on cloud OpenAI. Leave unset unless using a local/self-hosted AI backend.
# AI_CONTEXT_SIZE=8192
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ Every 4 hours, it delivers a **detailed report** complete with a 24-hour trend g

## Features & Workflow

Every 30 minutes (`SLEEP_TIME` in `main.py`, default 1800 seconds):
Every `SLEEP_TIME` seconds (default 1800 = 30 min, configurable):

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.
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 `REPORT_CYCLE_COUNT` cycles (default 8, i.e. ~4h), generates a **24-hour trend graph** via `matplotlib` alongside a sarcastic LLM analysis of network load and speed fluctuations.

---

Expand Down Expand Up @@ -129,6 +129,9 @@ cp .env.example .env
| `DISCORD_WEBHOOK_URL` | Discord channel webhook URL — required if `NOTIFIER=discord` |
| `DB_PATH` | SQLite database file path (e.g. `metrics.sql`) |
| `REQUEST_TIMEOUT` | *Optional.* HTTP timeout in seconds for Telegram/Discord requests (positive integer, default `30`) |
| `SLEEP_TIME` | *Optional.* Seconds between each speed test + device scan cycle (positive integer, default `1800`) |
| `REPORT_CYCLE_COUNT` | *Optional.* How many cycles between detailed AI reports with graph (positive integer, default `8`) |
| `AI_CONTEXT_SIZE` | *Optional.* Sets Ollama's `num_ctx` per-request, to stop a local model's default context window from silently truncating a long prompt + a day of history. No effect on cloud OpenAI — leave unset unless self-hosting the AI backend. |

> [!TIP]
> **You're not locked into OpenAI.** `ai.py` talks to any OpenAI-compatible endpoint, so a local inference server (e.g. [Ollama](https://ollama.com), LM Studio) works too — just point `AI_BASE_URL` at it. For report quality that holds up, use a model with **at least ~7B parameters**; a solid local pick is **Gemma 4 12B at 4-bit (QAT) quantization** (`gemma4:12b-it-qat` via Ollama), which fits comfortably on 16GB of RAM.
Expand All @@ -144,6 +147,9 @@ uv run main.py
> [!TIP]
> Run the bot inside `tmux`/`screen` or set it up as a system service (`systemd`/`launchd`) to keep it running 24/7 in the background.

> [!TIP]
> Pass `--test-ai` (`uv run main.py --test-ai`) to force the very first cycle to run the full detailed report (AI commentary + graph + notifier delivery) immediately, then resume the normal `REPORT_CYCLE_COUNT` schedule automatically — no config to remember to revert afterward. Useful for verifying your AI backend and notifier work without waiting for the regular cadence.

---

## Notifications: Telegram or Discord
Expand Down
38 changes: 28 additions & 10 deletions ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,43 @@ def init(cls, api_key: str, model: str, base_url: str) -> "Client":
return cls(OpenAI(api_key=api_key, base_url=base_url), model)


def send_message(self, message:str, system_prompt: str) -> str:
def send_message(
self,
message: str,
system_prompt: str,
temperature: float = 0.9,
context_size: int | None = None,
) -> str:
self._validate_str(message, "message")

# Ollama's default context window (often 2048-4096 tokens depending
# on the model) is easy to exceed once the system prompt plus a
# day's worth of historical readings are combined — and unlike a
# clear error, exceeding it just silently truncates the prompt
# (typically from the start), which can quietly drop persona/format
# instructions while leaving the raw data intact. Passing num_ctx
# via extra_body raises this per-request for Ollama specifically.
# This is a no-op / harmless on real OpenAI's API since it's only
# added when context_size is explicitly set (e.g. for a local
# Ollama backend), not unconditionally on every request.
extra_body = {}
if context_size is not None:
extra_body["options"] = {"num_ctx": context_size}

response = self.conn.chat.completions.create(
model=self.model,
temperature=temperature,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": message}
])

],
extra_body=extra_body or None,
)

if response.choices[0].message.content is not None:
return response.choices[0].message.content

raise RuntimeError("AI response is empty")

def close(self):
self.conn.close()





self.conn.close()
53 changes: 51 additions & 2 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
logger = logging.getLogger(__name__)

DEFAULT_REQUEST_TIMEOUT = 30
DEFAULT_SLEEP_TIME = 1800
DEFAULT_REPORT_CYCLE_COUNT = 8

class Config:
def __init__(
Expand All @@ -18,7 +20,11 @@ def __init__(
tg_bot_token: str = "",
tg_chat_id: str = "",
discord_webhook_url: str = "",
request_timeout: int = DEFAULT_REQUEST_TIMEOUT
request_timeout: int = DEFAULT_REQUEST_TIMEOUT,
sleep_time: int = DEFAULT_SLEEP_TIME,
report_cycle_count: int = DEFAULT_REPORT_CYCLE_COUNT,
test_ai: bool = False,
ai_context_size: int | None = None,
):
self.ai_api_key: str = ai_api_key
self.db_path: str = db_path
Expand All @@ -29,6 +35,10 @@ def __init__(
self.tg_chat_id: str = tg_chat_id
self.discord_webhook_url: str = discord_webhook_url
self.request_timeout: int = request_timeout
self.sleep_time: int = sleep_time
self.report_cycle_count: int = report_cycle_count
self.test_ai: bool = test_ai
self.ai_context_size: int | None = ai_context_size

@staticmethod
def _parse_args():
Expand All @@ -39,6 +49,15 @@ def _parse_args():
default=".env",
help="Path to the .env file (default: .env)"
)
parser.add_argument(
"--test-ai",
action="store_true",
help="Force the very first cycle to run the full detailed report "
"(AI commentary + graph + notifier delivery), then continue on "
"the normal REPORT_CYCLE_COUNT schedule for every cycle after. "
"Useful for verifying the AI backend and notifier work without "
"waiting for the regular cadence or permanently changing config."
)
return parser.parse_args()

@classmethod
Expand Down Expand Up @@ -72,6 +91,35 @@ def init(cls):
if request_timeout <= 0:
raise RuntimeError(f"REQUEST_TIMEOUT must be positive, got: {request_timeout}")

try:
sleep_time = int(os.getenv("SLEEP_TIME", DEFAULT_SLEEP_TIME))
except ValueError:
raise RuntimeError(f"SLEEP_TIME must be an integer number of seconds, got: {os.getenv('SLEEP_TIME')!r}")
if sleep_time <= 0:
raise RuntimeError(f"SLEEP_TIME must be positive, got: {sleep_time}")

try:
report_cycle_count = int(os.getenv("REPORT_CYCLE_COUNT", DEFAULT_REPORT_CYCLE_COUNT))
except ValueError:
raise RuntimeError(f"REPORT_CYCLE_COUNT must be an integer, got: {os.getenv('REPORT_CYCLE_COUNT')!r}")
if report_cycle_count <= 0:
raise RuntimeError(f"REPORT_CYCLE_COUNT must be positive, got: {report_cycle_count}")

# Optional and unset by default — only meaningful for local
# OpenAI-compatible servers like Ollama, whose default context
# window can silently truncate a long system prompt + a day's
# worth of history once combined. Left as None, nothing extra is
# sent, so cloud OpenAI usage is unaffected.
ai_context_size_raw = os.getenv("AI_CONTEXT_SIZE")
ai_context_size: int | None = None
if ai_context_size_raw is not None and ai_context_size_raw.strip() != "":
try:
ai_context_size = int(ai_context_size_raw)
except ValueError:
raise RuntimeError(f"AI_CONTEXT_SIZE must be an integer, got: {ai_context_size_raw!r}")
if ai_context_size <= 0:
raise RuntimeError(f"AI_CONTEXT_SIZE must be positive, got: {ai_context_size}")

if notifier == "telegram":
if tg_bot_token.strip() == "":
raise RuntimeError("TG_BOT_TOKEN not found or empty in environment")
Expand All @@ -84,5 +132,6 @@ def init(cls):
return cls(
ai_key, db_path, model, base_url, notifier,
tg_bot_token, tg_chat_id, discord_webhook_url,
request_timeout,
request_timeout, sleep_time, report_cycle_count,
args.test_ai, ai_context_size,
)
18 changes: 12 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,6 @@

<b>Current status:</b> {status_text}"""

SLEEP_TIME = 1800

log = logging.getLogger("netmon")


Expand Down Expand Up @@ -153,7 +151,15 @@ def main():
t = tg.Bot.init(conf.tg_bot_token, conf.tg_chat_id, conf.request_timeout)
r = runner.Runner()

counter = 0
# Normally starts at 0 and climbs to conf.report_cycle_count before the
# first detailed report fires. --test-ai starts it already at threshold
# so the very first cycle exercises the AI + graph + notifier path; the
# detailed-report branch resets counter back to 0 on completion, so
# every cycle after that follows the normal schedule automatically —
# no config to remember to revert afterward.
counter = conf.report_cycle_count if conf.test_ai else 0
if conf.test_ai:
log.info("--test-ai passed: forcing a detailed AI report on the first cycle, then resuming normal schedule.")

with (
sqlite.DB.init(conf.db_path) as database,
Expand All @@ -173,7 +179,7 @@ def main():
database.add_speedtest(speedtest)
log.info(f"Speedtest has been added: {speedtest}")

if counter >= 8: #send a detailed report with graph every 4 hours
if counter >= conf.report_cycle_count: #send a detailed report with graph every N cycles
metrics, device_counts = database.get_metrics_with_device_counts()

user_message = ""
Expand All @@ -193,7 +199,7 @@ def main():

t.send_chat_action(ChatAction.TYPING)
try:
report = netmon_ai.send_message(user_message, REPORT_SYSTEM_PROMPT)
report = netmon_ai.send_message(user_message, REPORT_SYSTEM_PROMPT, context_size=conf.ai_context_size)
report = report.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
except Exception as e:
# AI backend down/unreachable/misconfigured: don't lose the
Expand Down Expand Up @@ -242,7 +248,7 @@ def main():

counter += 1

time.sleep(SLEEP_TIME)
time.sleep(conf.sleep_time)

if __name__ == "__main__":
main()