mirror of
https://gitlab.com/iscmt/homehub4000-prometheus-exporter.git
synced 2026-04-04 17:22:24 -04:00
Prometheus exporter for HomeHub 4000 router
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
|
*.db
|
||||||
|
.venv/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
debug_html/
|
||||||
|
session-*.md
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install playwright dependencies and curl for health checks
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||||
|
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||||
|
libgbm1 libasound2 libpango-1.0-0 libcairo2 curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
# Copy and install
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN uv sync --frozen && uv run playwright install chromium
|
||||||
|
|
||||||
|
ENV EXPORTER_PORT=9100
|
||||||
|
ENV HEADLESS_BROWSER=true
|
||||||
|
EXPOSE 9100
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:9100/metrics || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["uv", "run", "python", "src/exporter.py"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 HomeHub Exporter Contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# HomeHub 4000 Prometheus Exporter
|
||||||
|
|
||||||
|
Prometheus exporter for the [Virgin Plus Vincent](https://www.virginplus.ca/en/support/internet/modem-info-vincent.html) / [Bell HomeHub 4000](https://support.bell.ca/internet/products/home-hub-4000-modem) router. Uses Playwright to scrape the admin UI and expose metrics for Prometheus. Device events are stored in SQLite so other tools can query them independently of Prometheus.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Grafana dashboard (included)</strong></summary>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Architecture diagram</strong></summary>
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph exporter.py
|
||||||
|
direction TB
|
||||||
|
prom[Prometheus Server]
|
||||||
|
scraper[router_scraper.py]
|
||||||
|
poller[router_log_fetcher.py]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Router["HomeHub 4000"]
|
||||||
|
mon["/monitoring"]
|
||||||
|
stats["/statistics"]
|
||||||
|
logs["/logs"]
|
||||||
|
end
|
||||||
|
|
||||||
|
scraper --> mon
|
||||||
|
scraper --> stats
|
||||||
|
poller --> logs
|
||||||
|
poller --> db[(SQLite)]
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
uv sync
|
||||||
|
uv run playwright install chromium
|
||||||
|
|
||||||
|
# Run
|
||||||
|
ROUTER_PASSWORD="your_password" uv run python src/exporter.py
|
||||||
|
|
||||||
|
# Check
|
||||||
|
curl http://localhost:9100/metrics | grep homehub_
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
echo "ROUTER_PASSWORD=your_password" > .env
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
- Exporter: http://localhost:9100/metrics
|
||||||
|
- Prometheus: http://localhost:9090
|
||||||
|
- Grafana: http://localhost:3000 (pre-configured dashboard included)
|
||||||
|
|
||||||
|
Default Grafana credentials are `admin/admin`. Override with `GRAFANA_ADMIN_USER` and `GRAFANA_ADMIN_PASSWORD` in `.env`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `ROUTER_PASSWORD` | (required) | Router admin password |
|
||||||
|
| `ROUTER_IP` | 192.168.2.1 | Router IP address |
|
||||||
|
| `EXPORTER_PORT` | 9100 | Metrics port |
|
||||||
|
| `SCRAPE_INTERVAL` | 60 | Seconds between scrapes |
|
||||||
|
| `LOG_POLL_INTERVAL` | 75 | Seconds between log polls |
|
||||||
|
| `HEADLESS_BROWSER` | true | Run browser headless |
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
All metrics prefixed with `homehub_`. Covers system health (CPU, memory, load), network interfaces (status, throughput, errors), connected devices, router uptime, and log statistics. See the Grafana dashboard screenshot above or query `curl localhost:9100/metrics | grep homehub_` for the full list.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync # includes dev dependencies
|
||||||
|
uv run pytest
|
||||||
|
uv run ruff check src/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- Set `HEADLESS_BROWSER=false` to watch the browser and debug scraping issues
|
||||||
|
- Delete `virgin_monitor.db` to reset device history
|
||||||
|
|
||||||
|
Tested on Virgin Plus Vincent (Bell HomeHub 4000), firmware 2.13, UI 7.3.29.
|
||||||
7
deploy/.env.example
Normal file
7
deploy/.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Router settings
|
||||||
|
ROUTER_PASSWORD=your_router_password_here
|
||||||
|
ROUTER_IP=192.168.2.1
|
||||||
|
|
||||||
|
# Grafana credentials (defaults to admin/admin if not set)
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=changeme
|
||||||
55
deploy/docker-compose.yml
Normal file
55
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
services:
|
||||||
|
exporter:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: homehub-exporter
|
||||||
|
ports:
|
||||||
|
- "9100:9100"
|
||||||
|
environment:
|
||||||
|
- ROUTER_IP=${ROUTER_IP:-192.168.2.1}
|
||||||
|
- ROUTER_PASSWORD=${ROUTER_PASSWORD}
|
||||||
|
- EXPORTER_PORT=9100
|
||||||
|
- HEADLESS_BROWSER=true
|
||||||
|
- SCRAPE_INTERVAL=60
|
||||||
|
- LOG_POLL_INTERVAL=75
|
||||||
|
- DB_PATH=/app/data/virgin_monitor.db
|
||||||
|
volumes:
|
||||||
|
- exporter-data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
# For accessing router on host network
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: homehub-prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
depends_on:
|
||||||
|
- exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: homehub-grafana
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin}
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./grafana:/etc/grafana/provisioning:ro
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
exporter-data:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
11
deploy/grafana/dashboards/dashboards.yml
Normal file
11
deploy/grafana/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'Virgin Router'
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
710
deploy/grafana/dashboards/grafana-dashboard.json
Normal file
710
deploy/grafana/dashboards/grafana-dashboard.json
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 0},
|
||||||
|
"id": 101,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Devices",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 0, "y": 1},
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_connected_devices", "refId": "A"}],
|
||||||
|
"title": "Connected",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "yellow", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 4, "x": 4, "y": 1},
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_disconnected_devices", "refId": "A"}],
|
||||||
|
"title": "Disconnected",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {"type": "linear"},
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": true
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 8, "x": 0, "y": 5},
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"legend": {"calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "none"}
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "homehub_connected_devices", "legendFormat": "Connected", "refId": "A"},
|
||||||
|
{"expr": "homehub_disconnected_devices", "legendFormat": "Disconnected", "refId": "B"}
|
||||||
|
],
|
||||||
|
"title": "Device History",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
|
||||||
|
"mappings": [
|
||||||
|
{"options": {"0": {"color": "red", "index": 0, "text": "Offline"}}, "type": "value"},
|
||||||
|
{"options": {"1": {"color": "green", "index": 1, "text": "Online"}}, "type": "value"}
|
||||||
|
],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}]},
|
||||||
|
{"matcher": {"id": "byName", "options": "Uptime"}, "properties": [{"id": "unit", "value": "s"}]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 8, "w": 16, "x": 8, "y": 1},
|
||||||
|
"id": 7,
|
||||||
|
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "homehub_device_connected", "format": "table", "instant": true, "refId": "A"},
|
||||||
|
{"expr": "homehub_device_uptime_seconds", "format": "table", "instant": true, "refId": "B"}
|
||||||
|
],
|
||||||
|
"title": "Device Status",
|
||||||
|
"transformations": [
|
||||||
|
{"id": "joinByField", "options": {"byField": "device_name", "mode": "outer"}},
|
||||||
|
{"id": "groupBy", "options": {"fields": {"device_name": {"aggregations": [], "operation": "groupby"}, "mac_address": {"aggregations": ["firstNotNull"], "operation": "aggregate"}, "mac_address 1": {"aggregations": ["firstNotNull"], "operation": "aggregate"}, "Value #A": {"aggregations": ["firstNotNull"], "operation": "aggregate"}, "Value #B": {"aggregations": ["firstNotNull"], "operation": "aggregate"}}}},
|
||||||
|
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "instance": true, "instance 1": true, "instance 2": true, "job": true, "job 1": true, "job 2": true, "mac_address 1 (firstNotNull)": true}, "indexByName": {"device_name": 0, "mac_address (firstNotNull)": 1, "Value #A (firstNotNull)": 2, "Value #B (firstNotNull)": 3}, "renameByName": {"Value #A (firstNotNull)": "Status", "Value #B (firstNotNull)": "Uptime", "device_name": "Device", "mac_address (firstNotNull)": "mac_address"}}}
|
||||||
|
],
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 9},
|
||||||
|
"id": 100,
|
||||||
|
"panels": [],
|
||||||
|
"title": "System Health",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"mappings": [],
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{"color": "green", "value": null},
|
||||||
|
{"color": "yellow", "value": 60},
|
||||||
|
{"color": "red", "value": 85}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 5, "w": 4, "x": 0, "y": 10},
|
||||||
|
"id": 11,
|
||||||
|
"options": {
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"showThresholdLabels": false,
|
||||||
|
"showThresholdMarkers": true
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_cpu_usage_ratio * 100", "refId": "A"}],
|
||||||
|
"title": "CPU Usage",
|
||||||
|
"type": "gauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"mappings": [],
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{"color": "green", "value": null},
|
||||||
|
{"color": "yellow", "value": 70},
|
||||||
|
{"color": "red", "value": 90}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unit": "percent"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 5, "w": 4, "x": 4, "y": 10},
|
||||||
|
"id": 12,
|
||||||
|
"options": {
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"showThresholdLabels": false,
|
||||||
|
"showThresholdMarkers": true
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "100 - (homehub_memory_free_bytes / homehub_memory_total_bytes * 100)", "refId": "A"}],
|
||||||
|
"title": "Memory Usage",
|
||||||
|
"type": "gauge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 20,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {"type": "linear"},
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": true
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 5, "w": 6, "x": 11, "y": 10},
|
||||||
|
"id": 13,
|
||||||
|
"options": {
|
||||||
|
"legend": {"calcs": ["mean", "lastNotNull"], "displayMode": "list", "placement": "bottom", "showLegend": true},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "desc"}
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "homehub_load_average_1m", "legendFormat": "1m", "refId": "A"},
|
||||||
|
{"expr": "homehub_load_average_5m", "legendFormat": "5m", "refId": "B"},
|
||||||
|
{"expr": "homehub_load_average_15m", "legendFormat": "15m", "refId": "C"}
|
||||||
|
],
|
||||||
|
"title": "Load Average",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "decbytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 5, "w": 5, "x": 17, "y": 10},
|
||||||
|
"id": 14,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "horizontal",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "value_and_name"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "homehub_memory_free_bytes", "legendFormat": "Free", "refId": "A"},
|
||||||
|
{"expr": "homehub_memory_total_bytes - homehub_memory_free_bytes", "legendFormat": "Used", "refId": "B"}
|
||||||
|
],
|
||||||
|
"title": "Memory",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"mappings": [
|
||||||
|
{"options": {"0": {"color": "red", "index": 0, "text": "Down"}}, "type": "value"},
|
||||||
|
{"options": {"1": {"color": "green", "index": 1, "text": "OK"}}, "type": "value"}
|
||||||
|
],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 5, "w": 2, "x": 22, "y": 10},
|
||||||
|
"id": 15,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_scrape_success", "refId": "A"}],
|
||||||
|
"title": "Scraper",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 15},
|
||||||
|
"id": 102,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Network Interfaces",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"custom": {"align": "auto", "cellOptions": {"type": "auto"}, "inspect": false},
|
||||||
|
"mappings": [
|
||||||
|
{"options": {"0": {"color": "red", "index": 0, "text": "Down"}}, "type": "value"},
|
||||||
|
{"options": {"1": {"color": "green", "index": 1, "text": "Up"}}, "type": "value"}
|
||||||
|
],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{"matcher": {"id": "byName", "options": "Status"}, "properties": [{"id": "custom.cellOptions", "value": {"type": "color-background"}}]},
|
||||||
|
{"matcher": {"id": "byName", "options": "Sent"}, "properties": [{"id": "unit", "value": "decbytes"}]},
|
||||||
|
{"matcher": {"id": "byName", "options": "Received"}, "properties": [{"id": "unit", "value": "decbytes"}]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 8, "w": 10, "x": 2, "y": 16},
|
||||||
|
"id": 16,
|
||||||
|
"options": {"cellHeight": "sm", "footer": {"countRows": false, "fields": "", "reducer": ["sum"], "show": false}, "showHeader": true, "sortBy": [{"desc": true, "displayName": "Status"}]},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "homehub_interface_up", "format": "table", "instant": true, "refId": "A"},
|
||||||
|
{"expr": "homehub_interface_bytes_sent", "format": "table", "instant": true, "refId": "B"},
|
||||||
|
{"expr": "homehub_interface_bytes_received", "format": "table", "instant": true, "refId": "C"}
|
||||||
|
],
|
||||||
|
"title": "Interface Status",
|
||||||
|
"transformations": [
|
||||||
|
{"id": "seriesToColumns", "options": {"byField": "interface"}},
|
||||||
|
{"id": "organize", "options": {"excludeByName": {"Time": true, "Time 1": true, "Time 2": true, "Time 3": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "__name__ 3": true, "instance": true, "instance 1": true, "instance 2": true, "instance 3": true, "job": true, "job 1": true, "job 2": true, "job 3": true}, "renameByName": {"Value #A": "Status", "Value #B": "Sent", "Value #C": "Received", "interface": "Interface"}}}
|
||||||
|
],
|
||||||
|
"type": "table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"fillOpacity": 20,
|
||||||
|
"lineInterpolation": "smooth",
|
||||||
|
"lineWidth": 2,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {"type": "linear"},
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": true
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||||
|
"id": 17,
|
||||||
|
"options": {
|
||||||
|
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "desc"}
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "rate(homehub_interface_bytes_sent{interface=~\"fibre|10g|lan_1\"}[5m])", "legendFormat": "{{interface}} TX", "refId": "A"},
|
||||||
|
{"expr": "rate(homehub_interface_bytes_received{interface=~\"fibre|10g|lan_1\"}[5m])", "legendFormat": "{{interface}} RX", "refId": "B"}
|
||||||
|
],
|
||||||
|
"title": "Interface Throughput (Active)",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": {"h": 1, "w": 24, "x": 0, "y": 24},
|
||||||
|
"id": 103,
|
||||||
|
"panels": [],
|
||||||
|
"title": "Logs & Events",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {"hideFrom": {"legend": false, "tooltip": false, "viz": false}},
|
||||||
|
"mappings": []
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 3, "w": 4, "x": 0, "y": 25},
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_logs_total", "refId": "A"}],
|
||||||
|
"title": "Total Logs",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "orange", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 3, "w": 4, "x": 4, "y": 25},
|
||||||
|
"id": 18,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_logs_last_hour", "refId": "A"}],
|
||||||
|
"title": "Logs (Last Hour)",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "purple", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 3, "w": 4, "x": 8, "y": 25},
|
||||||
|
"id": 19,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_wifi_connects_total", "refId": "A"}],
|
||||||
|
"title": "Total Connects",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "semi-dark-red", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 3, "w": 4, "x": 12, "y": 25},
|
||||||
|
"id": 20,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "auto"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_wifi_disconnects_total", "refId": "A"}],
|
||||||
|
"title": "Total Disconnects",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "s"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 5, "w": 3, "x": 8, "y": 10},
|
||||||
|
"id": 21,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_router_uptime_seconds", "refId": "A"}],
|
||||||
|
"title": "Router Uptime",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]},
|
||||||
|
"unit": "decbytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 2, "x": 0, "y": 16},
|
||||||
|
"id": 22,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_interface_bytes_received{interface=\"fibre\"}", "refId": "A"}],
|
||||||
|
"title": "Data In",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "thresholds"},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "orange", "value": null}]},
|
||||||
|
"unit": "decbytes"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 4, "w": 2, "x": 0, "y": 20},
|
||||||
|
"id": 23,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "auto",
|
||||||
|
"orientation": "auto",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_interface_bytes_sent{interface=\"fibre\"}", "refId": "A"}],
|
||||||
|
"title": "Data Out",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {"hideFrom": {"legend": false, "tooltip": false, "viz": false}},
|
||||||
|
"mappings": []
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 6, "w": 6, "x": 0, "y": 28},
|
||||||
|
"id": 6,
|
||||||
|
"options": {
|
||||||
|
"legend": {"displayMode": "list", "placement": "right", "showLegend": true},
|
||||||
|
"pieType": "donut",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"tooltip": {"mode": "single", "sort": "none"}
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [{"expr": "homehub_logs_by_module", "legendFormat": "{{module}}", "refId": "A"}],
|
||||||
|
"title": "Logs by Module",
|
||||||
|
"type": "piechart"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {"hideFrom": {"legend": false, "tooltip": false, "viz": false}},
|
||||||
|
"mappings": []
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{"matcher": {"id": "byName", "options": "Error"}, "properties": [{"id": "color", "value": {"fixedColor": "red", "mode": "fixed"}}]},
|
||||||
|
{"matcher": {"id": "byName", "options": "Warning"}, "properties": [{"id": "color", "value": {"fixedColor": "yellow", "mode": "fixed"}}]},
|
||||||
|
{"matcher": {"id": "byName", "options": "Info"}, "properties": [{"id": "color", "value": {"fixedColor": "green", "mode": "fixed"}}]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 6, "w": 6, "x": 6, "y": 28},
|
||||||
|
"id": 9,
|
||||||
|
"options": {
|
||||||
|
"legend": {"displayMode": "list", "placement": "right", "showLegend": true},
|
||||||
|
"pieType": "donut",
|
||||||
|
"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false},
|
||||||
|
"tooltip": {"mode": "single", "sort": "none"}
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "homehub_logs_by_level{level=\"INF\"}", "legendFormat": "Info", "refId": "A"},
|
||||||
|
{"expr": "homehub_logs_by_level{level=\"ERR\"}", "legendFormat": "Error", "refId": "B"},
|
||||||
|
{"expr": "homehub_logs_by_level{level=\"WRN\"}", "legendFormat": "Warning", "refId": "C"}
|
||||||
|
],
|
||||||
|
"title": "Logs by Level",
|
||||||
|
"type": "piechart"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"color": {"mode": "palette-classic"},
|
||||||
|
"custom": {
|
||||||
|
"axisCenteredZero": false,
|
||||||
|
"axisLabel": "",
|
||||||
|
"axisPlacement": "auto",
|
||||||
|
"barAlignment": 0,
|
||||||
|
"drawStyle": "bars",
|
||||||
|
"fillOpacity": 50,
|
||||||
|
"lineInterpolation": "linear",
|
||||||
|
"lineWidth": 1,
|
||||||
|
"pointSize": 5,
|
||||||
|
"scaleDistribution": {"type": "linear"},
|
||||||
|
"showPoints": "never",
|
||||||
|
"spanNulls": false,
|
||||||
|
"stacking": {"group": "A", "mode": "none"}
|
||||||
|
},
|
||||||
|
"mappings": [],
|
||||||
|
"thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]},
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{"matcher": {"id": "byName", "options": "Connections"}, "properties": [{"id": "color", "value": {"fixedColor": "green", "mode": "fixed"}}]},
|
||||||
|
{"matcher": {"id": "byName", "options": "Disconnections"}, "properties": [{"id": "color", "value": {"fixedColor": "red", "mode": "fixed"}}]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gridPos": {"h": 6, "w": 12, "x": 12, "y": 28},
|
||||||
|
"id": 8,
|
||||||
|
"options": {
|
||||||
|
"legend": {"calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "none"}
|
||||||
|
},
|
||||||
|
"pluginVersion": "10.0.0",
|
||||||
|
"targets": [
|
||||||
|
{"expr": "increase(homehub_wifi_connects_total[5m])", "legendFormat": "Connections", "refId": "A"},
|
||||||
|
{"expr": "increase(homehub_wifi_disconnects_total[5m])", "legendFormat": "Disconnections", "refId": "B"}
|
||||||
|
],
|
||||||
|
"title": "WiFi Events (5m)",
|
||||||
|
"type": "timeseries"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"tags": ["vincent", "homehub-4000", "router"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"current": {"selected": false, "text": "Prometheus", "value": "PBFA97CFB590B2093"},
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"multi": false,
|
||||||
|
"name": "DS_PROMETHEUS",
|
||||||
|
"options": [],
|
||||||
|
"query": "prometheus",
|
||||||
|
"queryValue": "",
|
||||||
|
"refresh": 1,
|
||||||
|
"regex": "",
|
||||||
|
"skipUrlSync": false,
|
||||||
|
"type": "datasource"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {"from": "now-6h", "to": "now"},
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "",
|
||||||
|
"title": "Vincent / HomeHub 4000 Router Monitor",
|
||||||
|
"uid": "vincent-homehub-4000-router-monitor",
|
||||||
|
"version": 3
|
||||||
|
}
|
||||||
9
deploy/grafana/datasources/prometheus.yml
Normal file
9
deploy/grafana/datasources/prometheus.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
14
deploy/prometheus.yml
Normal file
14
deploy/prometheus.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
- job_name: 'homehub'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['exporter:9100']
|
||||||
|
scrape_interval: 30s
|
||||||
|
scrape_timeout: 15s
|
||||||
BIN
docs/grafana-dashboard.png
Normal file
BIN
docs/grafana-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
24
pyproject.toml
Normal file
24
pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[project]
|
||||||
|
name = "homehub-exporter"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Prometheus exporter for the HomeHub 4000 router"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { file = "LICENSE" }
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"beautifulsoup4>=4.12",
|
||||||
|
"playwright>=1.50",
|
||||||
|
"prometheus-client>=0.21",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=4.0",
|
||||||
|
"ruff>=0.3",
|
||||||
|
"ty>=0.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
65
src/config.py
Normal file
65
src/config.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Configuration settings for router scraping."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
def get_chrome_binary() -> Optional[str]:
|
||||||
|
"""Get Chrome binary path from environment."""
|
||||||
|
return os.getenv("CHROME_BINARY")
|
||||||
|
|
||||||
|
|
||||||
|
def dump_html(name: str, html: str, output_dir: Optional[str] = None) -> Path:
|
||||||
|
"""Dump HTML content to a timestamped file for debugging."""
|
||||||
|
target_dir = Path(output_dir or tempfile.gettempdir())
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filepath = target_dir / f"{name}_{int(time.time())}.html"
|
||||||
|
filepath.write_text(html)
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RouterSettings:
|
||||||
|
"""Settings for router scraping."""
|
||||||
|
router_ip: str
|
||||||
|
router_password: str
|
||||||
|
headless: bool
|
||||||
|
scrape_interval: int
|
||||||
|
log_fetch_timeout: int
|
||||||
|
debug: bool
|
||||||
|
debug_output_dir: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "RouterSettings":
|
||||||
|
"""Load settings from environment variables."""
|
||||||
|
password = os.getenv("ROUTER_PASSWORD", "")
|
||||||
|
if not password:
|
||||||
|
raise ValueError("ROUTER_PASSWORD is required")
|
||||||
|
return cls(
|
||||||
|
router_ip=os.getenv("ROUTER_IP", "192.168.2.1"),
|
||||||
|
router_password=password,
|
||||||
|
headless=os.getenv("HEADLESS_BROWSER", "true").lower() == "true",
|
||||||
|
scrape_interval=int(os.getenv("SCRAPE_INTERVAL", "60")),
|
||||||
|
log_fetch_timeout=int(os.getenv("TABLE_WAIT_TIMEOUT", "90")),
|
||||||
|
debug=os.getenv("SCRAPER_DEBUG", "false").lower() == "true",
|
||||||
|
debug_output_dir=os.getenv("SCRAPER_DEBUG_DIR", tempfile.gettempdir()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_browser_context(settings: RouterSettings):
|
||||||
|
"""Create a new Playwright browser context. Caller must manage lifecycle."""
|
||||||
|
playwright = sync_playwright().start()
|
||||||
|
chrome_binary = get_chrome_binary()
|
||||||
|
browser = playwright.chromium.launch(
|
||||||
|
headless=settings.headless,
|
||||||
|
executable_path=chrome_binary,
|
||||||
|
)
|
||||||
|
context = browser.new_context(viewport={"width": 1024, "height": 768})
|
||||||
|
return playwright, browser, context
|
||||||
200
src/database.py
Normal file
200
src/database.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite helper for storing router logs from the Advanced Tools page.
|
||||||
|
Only the router_logs table is managed here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
|
||||||
|
class VirginMonitorDatabase:
|
||||||
|
"""Minimal SQLite wrapper for router logs."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "virgin_monitor.db"):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self._initialize_database()
|
||||||
|
self.logger.info("Database initialized: %s", self.db_path)
|
||||||
|
|
||||||
|
def _initialize_database(self):
|
||||||
|
"""Create router_logs table if it doesn't exist."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS router_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
log_timestamp TEXT NOT NULL,
|
||||||
|
level TEXT,
|
||||||
|
module TEXT,
|
||||||
|
message TEXT,
|
||||||
|
raw_datetime TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(log_timestamp, module, message)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_router_logs_ts ON router_logs (log_timestamp)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_router_logs_module ON router_logs (module)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _get_connection(self):
|
||||||
|
"""Context manager for database connections."""
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def save_router_logs(self, logs: List[Dict[str, Any]]) -> int:
|
||||||
|
"""
|
||||||
|
Save router system logs from advancedtools/logs page.
|
||||||
|
Skips entries already stored (based on timestamp, module, message).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logs: List of dicts with keys: log_timestamp (ISO), level, module, message, raw_datetime
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of new rows inserted.
|
||||||
|
"""
|
||||||
|
if not logs:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
for entry in logs:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO router_logs (log_timestamp, level, module, message, raw_datetime)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
entry.get("log_timestamp"),
|
||||||
|
entry.get("level"),
|
||||||
|
entry.get("module"),
|
||||||
|
entry.get("message"),
|
||||||
|
entry.get("raw_datetime", entry.get("log_timestamp")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Saved %d new router log entries (incoming: %d)", inserted, len(logs)
|
||||||
|
)
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
def get_device_log_rows(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch log rows used for device status parsing."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT log_timestamp, message FROM router_logs ORDER BY log_timestamp ASC"
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_wifi_log_messages(self) -> List[str]:
|
||||||
|
"""Fetch WiFi log messages for connect/disconnect counts."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT message FROM router_logs WHERE module = 'WIFI'")
|
||||||
|
return [row["message"] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_log_counts(self) -> Dict[str, Any]:
|
||||||
|
"""Aggregate counts by level/module and overall totals."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT level, COUNT(*) as cnt FROM router_logs GROUP BY level")
|
||||||
|
by_level = {row["level"] or "UNKNOWN": row["cnt"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT module, COUNT(*) as cnt FROM router_logs GROUP BY module"
|
||||||
|
)
|
||||||
|
by_module = {
|
||||||
|
row["module"] or "UNKNOWN": row["cnt"] for row in cur.fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) as cnt FROM router_logs")
|
||||||
|
total = cur.fetchone()["cnt"]
|
||||||
|
|
||||||
|
one_hour_ago = (datetime.now() - timedelta(hours=1)).isoformat()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM router_logs WHERE log_timestamp > ?",
|
||||||
|
(one_hour_ago,),
|
||||||
|
)
|
||||||
|
last_hour = cur.fetchone()["cnt"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"by_level": by_level,
|
||||||
|
"by_module": by_module,
|
||||||
|
"total": total,
|
||||||
|
"last_hour": last_hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
def max_log_id(self) -> int:
|
||||||
|
"""Get the current maximum router_logs.id (0 if table empty)."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COALESCE(MAX(id), 0) FROM router_logs")
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
def get_logs_since_id(self, since_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get logs with id greater than since_id.
|
||||||
|
Returns a list of dicts ordered ascending by id.
|
||||||
|
"""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, log_timestamp, level, module, message FROM router_logs WHERE id > ? ORDER BY id ASC",
|
||||||
|
(since_id,),
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
def get_last_boot_time(self) -> Optional[str]:
|
||||||
|
"""Get the timestamp of the most recent boot event."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT log_timestamp FROM router_logs WHERE message LIKE '%TR69 event found : 1 BOOT%' ORDER BY log_timestamp DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row["log_timestamp"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Simple smoke test
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
||||||
|
)
|
||||||
|
db = VirginMonitorDatabase("test.db")
|
||||||
|
sample = [
|
||||||
|
{
|
||||||
|
"log_timestamp": datetime.now().isoformat(),
|
||||||
|
"level": "INF",
|
||||||
|
"module": "TEST",
|
||||||
|
"message": "Sample log entry",
|
||||||
|
"raw_datetime": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
inserted = db.save_router_logs(sample)
|
||||||
|
print(f"Inserted {inserted} sample rows into test.db")
|
||||||
472
src/exporter.py
Normal file
472
src/exporter.py
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prometheus exporter for the Vincent/HomeHub 4000 router."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from prometheus_client import start_http_server, REGISTRY
|
||||||
|
from prometheus_client.core import GaugeMetricFamily
|
||||||
|
from prometheus_client.registry import Collector
|
||||||
|
|
||||||
|
from config import RouterSettings
|
||||||
|
from database import VirginMonitorDatabase
|
||||||
|
from router_log_fetcher import RouterLogFetcher
|
||||||
|
from router_scraper import RouterScraper
|
||||||
|
from router_status import (
|
||||||
|
build_status,
|
||||||
|
parse_events,
|
||||||
|
DeviceStatus,
|
||||||
|
CONNECT_PHRASE,
|
||||||
|
DISCONNECT_PHRASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LogPoller:
|
||||||
|
"""Background thread that periodically fetches router logs and saves to database."""
|
||||||
|
|
||||||
|
def __init__(self, db: VirginMonitorDatabase, interval: int = 60):
|
||||||
|
self.db = db
|
||||||
|
self.interval = interval
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._fetcher: Optional[RouterLogFetcher] = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
settings = RouterSettings.from_env()
|
||||||
|
self._fetcher = RouterLogFetcher(settings, self.db)
|
||||||
|
except ValueError as e:
|
||||||
|
self.logger.warning("Log polling disabled: %s", e)
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
self.logger.info("Started log poller (interval: %ds)", self.interval)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
|
||||||
|
def _poll_loop(self):
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if self._fetcher is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
logs = self._fetcher.fetch_logs()
|
||||||
|
if logs:
|
||||||
|
inserted = self.db.save_router_logs(logs)
|
||||||
|
self.logger.info("Log poll: %d new entries", inserted)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("Log poll failed: %s", e)
|
||||||
|
self._stop_event.wait(self.interval)
|
||||||
|
|
||||||
|
|
||||||
|
class VirginRouterCollector(Collector):
|
||||||
|
"""Custom collector that queries the database on each scrape."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str = "virgin_monitor.db",
|
||||||
|
scraper: Optional[RouterScraper] = None,
|
||||||
|
):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.scraper = scraper
|
||||||
|
self._db: Optional[VirginMonitorDatabase] = None
|
||||||
|
|
||||||
|
def _get_db(self) -> Optional[VirginMonitorDatabase]:
|
||||||
|
if not self.db_path.exists():
|
||||||
|
return None
|
||||||
|
if self._db is None:
|
||||||
|
self._db = VirginMonitorDatabase(str(self.db_path))
|
||||||
|
return self._db
|
||||||
|
|
||||||
|
def _get_device_status(self, db: VirginMonitorDatabase) -> Dict[str, DeviceStatus]:
|
||||||
|
rows = db.get_device_log_rows()
|
||||||
|
events = list(parse_events(rows, allow_missing_timestamp=True))
|
||||||
|
return build_status(events)
|
||||||
|
|
||||||
|
def _get_log_counts(self, db: VirginMonitorDatabase) -> Dict[str, Any]:
|
||||||
|
return db.get_log_counts()
|
||||||
|
|
||||||
|
def _get_wifi_event_counts(self, db: VirginMonitorDatabase):
|
||||||
|
connects = 0
|
||||||
|
disconnects = 0
|
||||||
|
for message in db.get_wifi_log_messages():
|
||||||
|
msg = (message or "").lower()
|
||||||
|
if CONNECT_PHRASE in msg:
|
||||||
|
connects += 1
|
||||||
|
elif DISCONNECT_PHRASE in msg:
|
||||||
|
disconnects += 1
|
||||||
|
|
||||||
|
return {"connects": connects, "disconnects": disconnects}
|
||||||
|
|
||||||
|
def collect(self):
|
||||||
|
db = self._get_db()
|
||||||
|
if not db:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
devices = self._get_device_status(db)
|
||||||
|
connected_count = sum(1 for d in devices.values() if d.is_connected)
|
||||||
|
disconnected_count = len(devices) - connected_count
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_connected_devices",
|
||||||
|
"Number of currently connected WiFi devices",
|
||||||
|
)
|
||||||
|
g.add_metric([], connected_count)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_disconnected_devices",
|
||||||
|
"Number of currently disconnected WiFi devices",
|
||||||
|
)
|
||||||
|
g.add_metric([], disconnected_count)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_total_devices",
|
||||||
|
"Total unique devices seen",
|
||||||
|
)
|
||||||
|
g.add_metric([], len(devices))
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_device_connected",
|
||||||
|
"Device connection status (1=connected, 0=disconnected)",
|
||||||
|
labels=["device_name", "mac_address"],
|
||||||
|
)
|
||||||
|
for dev in devices.values():
|
||||||
|
g.add_metric(
|
||||||
|
[dev.name, dev.mac],
|
||||||
|
1 if dev.is_connected else 0,
|
||||||
|
)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_device_uptime_seconds",
|
||||||
|
"Seconds since device connected (only for connected devices)",
|
||||||
|
labels=["device_name", "mac_address"],
|
||||||
|
)
|
||||||
|
for dev in devices.values():
|
||||||
|
if dev.is_connected and dev.last_connected_time:
|
||||||
|
uptime = (datetime.now() - dev.last_connected_time).total_seconds()
|
||||||
|
g.add_metric([dev.name, dev.mac], uptime)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
log_counts = self._get_log_counts(db)
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_logs_total",
|
||||||
|
"Total number of router log entries",
|
||||||
|
)
|
||||||
|
g.add_metric([], log_counts["total"])
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_logs_last_hour",
|
||||||
|
"Number of log entries in the last hour",
|
||||||
|
)
|
||||||
|
g.add_metric([], log_counts["last_hour"])
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_logs_by_level",
|
||||||
|
"Log entries by severity level",
|
||||||
|
labels=["level"],
|
||||||
|
)
|
||||||
|
for level, count in log_counts["by_level"].items():
|
||||||
|
g.add_metric([level], count)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_logs_by_module",
|
||||||
|
"Log entries by module",
|
||||||
|
labels=["module"],
|
||||||
|
)
|
||||||
|
for module, count in log_counts["by_module"].items():
|
||||||
|
g.add_metric([module], count)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
wifi_counts = self._get_wifi_event_counts(db)
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_wifi_connects_total",
|
||||||
|
"Total WiFi connection events",
|
||||||
|
)
|
||||||
|
g.add_metric([], wifi_counts["connects"])
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_wifi_disconnects_total",
|
||||||
|
"Total WiFi disconnection events",
|
||||||
|
)
|
||||||
|
g.add_metric([], wifi_counts["disconnects"])
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_last_scrape_timestamp",
|
||||||
|
"Unix timestamp of last successful scrape",
|
||||||
|
)
|
||||||
|
g.add_metric([], now)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
last_boot = db.get_last_boot_time()
|
||||||
|
if last_boot:
|
||||||
|
try:
|
||||||
|
boot_time = datetime.fromisoformat(last_boot)
|
||||||
|
uptime_seconds = (datetime.now() - boot_time).total_seconds()
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_router_uptime_seconds",
|
||||||
|
"Seconds since last router boot",
|
||||||
|
)
|
||||||
|
g.add_metric([], uptime_seconds)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_router_last_boot_timestamp",
|
||||||
|
"Unix timestamp of last router boot",
|
||||||
|
)
|
||||||
|
g.add_metric([], boot_time.timestamp())
|
||||||
|
yield g
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.scraper:
|
||||||
|
yield from self._collect_router_metrics()
|
||||||
|
|
||||||
|
def _collect_router_metrics(self):
|
||||||
|
if self.scraper is None:
|
||||||
|
return
|
||||||
|
data = self.scraper.get_data()
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_scrape_success",
|
||||||
|
"Whether the last router scrape succeeded (1=success, 0=failure)",
|
||||||
|
)
|
||||||
|
g.add_metric([], 1 if data.scrape_success else 0)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_router_scrape_timestamp",
|
||||||
|
"Unix timestamp of last router page scrape",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.last_scrape_time)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_memory_free_bytes",
|
||||||
|
"Free physical memory in bytes",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.memory_free_mb * 1024 * 1024)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_memory_total_bytes",
|
||||||
|
"Total physical memory in bytes",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.memory_total_mb * 1024 * 1024)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_flash_available_bytes",
|
||||||
|
"Available flash storage in bytes",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.flash_available_mb * 1024 * 1024)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_flash_used_bytes",
|
||||||
|
"Used flash storage in bytes",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.flash_used_mb * 1024 * 1024)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_cpu_usage_ratio",
|
||||||
|
"CPU usage ratio (0.0-1.0)",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.cpu_usage_percent / 100.0)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_load_average_1m",
|
||||||
|
"1-minute load average",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.load_1m)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_load_average_5m",
|
||||||
|
"5-minute load average",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.load_5m)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_load_average_15m",
|
||||||
|
"15-minute load average",
|
||||||
|
)
|
||||||
|
g.add_metric([], data.monitoring.load_15m)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_up",
|
||||||
|
"Interface status (1=UP, 0=DOWN/DORMANT)",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], 1 if iface.status == "UP" else 0)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_bytes_sent",
|
||||||
|
"Bytes sent on interface",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], iface.bytes_sent)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_bytes_received",
|
||||||
|
"Bytes received on interface",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], iface.bytes_received)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_packets_sent",
|
||||||
|
"Packets sent on interface",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], iface.packets_sent)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_packets_received",
|
||||||
|
"Packets received on interface",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], iface.packets_received)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_errors_sent",
|
||||||
|
"Send errors on interface",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], iface.errors_sent)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
g = GaugeMetricFamily(
|
||||||
|
"homehub_interface_errors_received",
|
||||||
|
"Receive errors on interface",
|
||||||
|
labels=["interface"],
|
||||||
|
)
|
||||||
|
for name, iface in data.interfaces.items():
|
||||||
|
g.add_metric([name], iface.errors_received)
|
||||||
|
yield g
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Prometheus exporter for Virgin Media router metrics"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=int(os.environ.get("EXPORTER_PORT", 9100)),
|
||||||
|
help="Port to expose metrics on (default: 9100)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--db",
|
||||||
|
default=os.environ.get("DB_PATH", "virgin_monitor.db"),
|
||||||
|
help="Path to SQLite database (default: virgin_monitor.db)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-scraper",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable live router scraping (only use database metrics)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-interval",
|
||||||
|
type=int,
|
||||||
|
default=int(os.environ.get("LOG_POLL_INTERVAL", 75)),
|
||||||
|
help="Seconds between log polls (default: 75)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-log-poller",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable log polling (only use live scraper metrics)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db = VirginMonitorDatabase(args.db)
|
||||||
|
|
||||||
|
scraper = None
|
||||||
|
if not args.no_scraper:
|
||||||
|
try:
|
||||||
|
scraper = RouterScraper()
|
||||||
|
scraper.start_background_refresh()
|
||||||
|
logger.info("Router scraper started")
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning("Router scraper disabled: %s", e)
|
||||||
|
scraper = None
|
||||||
|
|
||||||
|
log_poller = None
|
||||||
|
if not args.no_log_poller:
|
||||||
|
log_poller = LogPoller(db, interval=args.log_interval)
|
||||||
|
log_poller.start()
|
||||||
|
|
||||||
|
REGISTRY.register(VirginRouterCollector(args.db, scraper=scraper))
|
||||||
|
|
||||||
|
logger.info("Starting Prometheus exporter on port %d", args.port)
|
||||||
|
logger.info("Database: %s", args.db)
|
||||||
|
logger.info("Router scraping: %s", "enabled" if scraper else "disabled")
|
||||||
|
logger.info("Log polling: %s", "enabled" if log_poller else "disabled")
|
||||||
|
logger.info("Metrics available at http://localhost:%d/metrics", args.port)
|
||||||
|
|
||||||
|
start_http_server(args.port)
|
||||||
|
|
||||||
|
def shutdown(signum, frame):
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
if scraper:
|
||||||
|
scraper.stop_background_refresh()
|
||||||
|
if log_poller:
|
||||||
|
log_poller.stop()
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
132
src/router_log_fetcher.py
Normal file
132
src/router_log_fetcher.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetches router logs from the advancedtools/logs page."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from bs4.element import Tag
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
from playwright.sync_api import Error as PlaywrightError
|
||||||
|
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||||
|
|
||||||
|
from config import RouterSettings, dump_html, get_chrome_binary
|
||||||
|
from database import VirginMonitorDatabase
|
||||||
|
from router_scraper import handle_admin_login
|
||||||
|
|
||||||
|
|
||||||
|
class RouterLogFetcher:
|
||||||
|
"""Fetch and parse router system logs."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Optional[RouterSettings] = None,
|
||||||
|
database: Optional[VirginMonitorDatabase] = None,
|
||||||
|
):
|
||||||
|
self.settings = settings or RouterSettings.from_env()
|
||||||
|
self.database = database
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def fetch_logs(self) -> List[Dict[str, str]]:
|
||||||
|
"""Open the logs page, parse the table, and return structured rows."""
|
||||||
|
logs_url = f"http://{self.settings.router_ip}/?c=advancedtools/logs"
|
||||||
|
table_wait = self.settings.log_fetch_timeout * 1000
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sync_playwright() as playwright:
|
||||||
|
chrome_binary = get_chrome_binary()
|
||||||
|
browser = playwright.chromium.launch(
|
||||||
|
headless=self.settings.headless,
|
||||||
|
executable_path=chrome_binary,
|
||||||
|
)
|
||||||
|
context = browser.new_context(viewport={"width": 1024, "height": 768})
|
||||||
|
page = context.new_page()
|
||||||
|
page.set_default_timeout(15000)
|
||||||
|
page.set_default_navigation_timeout(60000)
|
||||||
|
|
||||||
|
page_html = ""
|
||||||
|
try:
|
||||||
|
self.logger.info("Fetching logs from %s", logs_url)
|
||||||
|
page.goto(logs_url, wait_until="domcontentloaded")
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
if handle_admin_login(page, self.settings.router_password, self.logger):
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
if (typeof loadSystemLogs === 'function') loadSystemLogs();
|
||||||
|
if (typeof refreshLogs === 'function') refreshLogs();
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
except PlaywrightError as e:
|
||||||
|
self.logger.debug("Could not trigger log table refresh: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("#systemLogTable tbody tr", timeout=table_wait)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
self.logger.warning("Log table rows not detected within timeout")
|
||||||
|
|
||||||
|
page_html = page.content()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
context.close()
|
||||||
|
except PlaywrightError as e:
|
||||||
|
self.logger.debug("Error closing log browser context: %s", e)
|
||||||
|
try:
|
||||||
|
browser.close()
|
||||||
|
except PlaywrightError as e:
|
||||||
|
self.logger.debug("Error closing log browser: %s", e)
|
||||||
|
|
||||||
|
if not page_html:
|
||||||
|
return []
|
||||||
|
|
||||||
|
parsed = self._parse_table(page_html)
|
||||||
|
if not parsed and self.settings.debug:
|
||||||
|
dump_html("router_logs_debug", page_html, self.settings.debug_output_dir)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
self.logger.error("Failed to fetch router logs: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_table(self, html: str) -> List[Dict[str, str]]:
|
||||||
|
"""Parse HTML table rows into structured dicts."""
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
rows = soup.select("#systemLogTable tbody tr")
|
||||||
|
parsed = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
date_text = self._text_or_none(row.select_one(".dateTime"))
|
||||||
|
level = row.get("level") or self._text_or_none(row.select_one(".level"))
|
||||||
|
module = row.get("module") or self._text_or_none(row.select_one(".module"))
|
||||||
|
message = self._text_or_none(row.select_one(".message"))
|
||||||
|
|
||||||
|
iso_timestamp = date_text
|
||||||
|
if date_text:
|
||||||
|
try:
|
||||||
|
iso_timestamp = datetime.strptime(date_text, "%Y-%m-%d %H:%M:%S").isoformat()
|
||||||
|
except ValueError:
|
||||||
|
iso_timestamp = date_text
|
||||||
|
|
||||||
|
parsed.append({
|
||||||
|
"log_timestamp": iso_timestamp,
|
||||||
|
"raw_datetime": date_text,
|
||||||
|
"level": level,
|
||||||
|
"module": module,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.logger.info("Parsed %s log rows", len(parsed))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _text_or_none(el: Optional[Tag]) -> Optional[str]:
|
||||||
|
return el.get_text(strip=True) if el else None
|
||||||
397
src/router_scraper.py
Normal file
397
src/router_scraper.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Router scraper for HomeHub 4000 monitoring/statistics pages."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from playwright.sync_api import Error as PlaywrightError
|
||||||
|
from playwright.sync_api import Page
|
||||||
|
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||||
|
|
||||||
|
from config import RouterSettings, create_browser_context, dump_html
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MonitoringData:
|
||||||
|
memory_free_mb: float = 0.0
|
||||||
|
memory_total_mb: float = 0.0
|
||||||
|
flash_available_mb: float = 0.0
|
||||||
|
flash_used_mb: float = 0.0
|
||||||
|
cpu_usage_percent: float = 0.0
|
||||||
|
load_1m: float = 0.0
|
||||||
|
load_5m: float = 0.0
|
||||||
|
load_15m: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InterfaceStats:
|
||||||
|
name: str
|
||||||
|
status: str = "UNKNOWN"
|
||||||
|
bytes_sent: int = 0
|
||||||
|
bytes_received: int = 0
|
||||||
|
packets_sent: int = 0
|
||||||
|
packets_received: int = 0
|
||||||
|
errors_sent: int = 0
|
||||||
|
errors_received: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScrapedData:
|
||||||
|
monitoring: MonitoringData = field(default_factory=MonitoringData)
|
||||||
|
interfaces: Dict[str, InterfaceStats] = field(default_factory=dict)
|
||||||
|
last_scrape_time: float = 0.0
|
||||||
|
scrape_success: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def handle_admin_login(
|
||||||
|
page: Page,
|
||||||
|
password: str,
|
||||||
|
logger: Optional[logging.Logger] = None,
|
||||||
|
log_warnings: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Handle admin login popup if present."""
|
||||||
|
try:
|
||||||
|
login_popup = page.locator("#login-popup")
|
||||||
|
if not login_popup.count():
|
||||||
|
header_login = page.locator("#headerLogin")
|
||||||
|
if not header_login.count():
|
||||||
|
return False
|
||||||
|
header_login.first.click()
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("#login-popup", timeout=10000)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const popup = document.getElementById('login-popup');
|
||||||
|
if (popup) {
|
||||||
|
popup.style.display = 'block';
|
||||||
|
popup.style.visibility = 'visible';
|
||||||
|
popup.style.opacity = '1';
|
||||||
|
popup.style.zIndex = '9999';
|
||||||
|
popup.classList.add('show');
|
||||||
|
popup.classList.remove('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pwd_input = page.wait_for_selector(
|
||||||
|
"#login-popup input[name='admin-password'], #login-popup #password",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
if logger and log_warnings:
|
||||||
|
logger.warning("Password input not found after forcing popup visible")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if pwd_input is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pwd_input.fill(password)
|
||||||
|
except PlaywrightError:
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
(password) => {
|
||||||
|
const field = document.querySelector("#login-popup input[name='admin-password']")
|
||||||
|
|| document.querySelector('#login-popup #password');
|
||||||
|
if (field) {
|
||||||
|
field.value = password;
|
||||||
|
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
password,
|
||||||
|
)
|
||||||
|
|
||||||
|
login_btn = page.locator("#loginButton")
|
||||||
|
if login_btn.count():
|
||||||
|
login_btn.first.click()
|
||||||
|
else:
|
||||||
|
page.evaluate("document.getElementById('loginButton')?.click();")
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("#login-popup", state="hidden", timeout=15000)
|
||||||
|
return True
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
if logger and log_warnings:
|
||||||
|
logger.warning("Login popup did not disappear after submit")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
if logger:
|
||||||
|
logger.warning("Admin login handling failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class RouterScraper:
|
||||||
|
"""Scrapes router monitoring and statistics pages."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Optional[RouterSettings] = None):
|
||||||
|
self.settings = settings or RouterSettings.from_env()
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self._data = ScrapedData()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._playwright = None
|
||||||
|
self._browser = None
|
||||||
|
self._context = None
|
||||||
|
|
||||||
|
def get_data(self) -> ScrapedData:
|
||||||
|
with self._lock:
|
||||||
|
return copy.deepcopy(self._data)
|
||||||
|
|
||||||
|
def start_background_refresh(self):
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._refresh_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
self.logger.info("Started background scraper (interval: %ss)", self.settings.scrape_interval)
|
||||||
|
|
||||||
|
def stop_background_refresh(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
self._close_browser()
|
||||||
|
|
||||||
|
def _close_browser(self):
|
||||||
|
if self._context:
|
||||||
|
try:
|
||||||
|
self._context.close()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("Error closing browser context: %s", e)
|
||||||
|
if self._browser:
|
||||||
|
try:
|
||||||
|
self._browser.close()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("Error closing browser: %s", e)
|
||||||
|
if self._playwright:
|
||||||
|
try:
|
||||||
|
self._playwright.stop()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("Error stopping playwright: %s", e)
|
||||||
|
self._playwright = None
|
||||||
|
self._browser = None
|
||||||
|
self._context = None
|
||||||
|
|
||||||
|
def _ensure_browser(self):
|
||||||
|
if self._browser is None:
|
||||||
|
self._playwright, self._browser, self._context = create_browser_context(self.settings)
|
||||||
|
self.logger.info("Browser started")
|
||||||
|
|
||||||
|
def _refresh_loop(self):
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self._refresh_data()
|
||||||
|
except PlaywrightError as exc:
|
||||||
|
self.logger.warning("Scrape failed: %s", exc)
|
||||||
|
self._close_browser()
|
||||||
|
with self._lock:
|
||||||
|
self._data.scrape_success = False
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("Scrape failed unexpectedly: %s", exc)
|
||||||
|
self._close_browser()
|
||||||
|
with self._lock:
|
||||||
|
self._data.scrape_success = False
|
||||||
|
self._stop_event.wait(self.settings.scrape_interval)
|
||||||
|
|
||||||
|
def _refresh_data(self):
|
||||||
|
self._ensure_browser()
|
||||||
|
if self._context is None:
|
||||||
|
return
|
||||||
|
page = self._context.new_page()
|
||||||
|
page.set_default_timeout(15000)
|
||||||
|
page.set_default_navigation_timeout(60000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
monitoring_url = f"http://{self.settings.router_ip}/?c=advancedtools/monitoring"
|
||||||
|
self.logger.info("Scraping %s", monitoring_url)
|
||||||
|
page.goto(monitoring_url, wait_until="domcontentloaded")
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
if handle_admin_login(page, self.settings.router_password, self.logger, log_warnings=False):
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("#Monitoring-View", timeout=15000)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
self.logger.warning("Monitoring view not found")
|
||||||
|
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
monitoring_html = page.content()
|
||||||
|
if self.settings.debug:
|
||||||
|
dump_html("router_monitoring", monitoring_html, self.settings.debug_output_dir)
|
||||||
|
monitoring_data = self._parse_monitoring_page(monitoring_html)
|
||||||
|
|
||||||
|
# Close any popup
|
||||||
|
close_btn = page.locator("#closePopup")
|
||||||
|
if close_btn.count():
|
||||||
|
try:
|
||||||
|
close_btn.first.click()
|
||||||
|
page.wait_for_timeout(1000)
|
||||||
|
except PlaywrightError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stats_url = f"http://{self.settings.router_ip}/?c=advancedtools/statistics"
|
||||||
|
self.logger.info("Scraping %s", stats_url)
|
||||||
|
page.goto(stats_url, wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.wait_for_selector("#Statistics-View, .statisticsTable, table.dataTable", timeout=15000)
|
||||||
|
except PlaywrightTimeoutError:
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
stats_html = page.content()
|
||||||
|
if self.settings.debug:
|
||||||
|
dump_html("router_statistics", stats_html, self.settings.debug_output_dir)
|
||||||
|
interfaces = self._parse_statistics_page(stats_html)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._data.monitoring = monitoring_data
|
||||||
|
self._data.interfaces = interfaces
|
||||||
|
self._data.last_scrape_time = time.time()
|
||||||
|
self._data.scrape_success = True
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Scrape complete: CPU=%s%%, Memory=%s/%sMB, Interfaces=%s",
|
||||||
|
monitoring_data.cpu_usage_percent,
|
||||||
|
monitoring_data.memory_free_mb,
|
||||||
|
monitoring_data.memory_total_mb,
|
||||||
|
len(interfaces),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
page.close()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("Error closing page: %s", e)
|
||||||
|
|
||||||
|
def _parse_monitoring_page(self, html: str) -> MonitoringData:
|
||||||
|
data = MonitoringData()
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
text = soup.get_text()
|
||||||
|
|
||||||
|
mem_free = re.search(r"Free:\s*([\d.]+)\s*MB", text)
|
||||||
|
mem_total = re.search(r"Total:\s*([\d.]+)\s*MB", text)
|
||||||
|
if mem_free:
|
||||||
|
data.memory_free_mb = float(mem_free.group(1))
|
||||||
|
if mem_total:
|
||||||
|
data.memory_total_mb = float(mem_total.group(1))
|
||||||
|
|
||||||
|
flash_avail = re.search(r"Available:\s*([\d.]+)\s*MB", text)
|
||||||
|
flash_used = re.search(r"Used:\s*([\d.]+)\s*MB", text)
|
||||||
|
if flash_avail:
|
||||||
|
data.flash_available_mb = float(flash_avail.group(1))
|
||||||
|
if flash_used:
|
||||||
|
data.flash_used_mb = float(flash_used.group(1))
|
||||||
|
|
||||||
|
cpu = re.search(r"Usage:\s*(\d+)%", text)
|
||||||
|
if cpu:
|
||||||
|
data.cpu_usage_percent = float(cpu.group(1))
|
||||||
|
|
||||||
|
load_1m = re.search(r"Last minute:\s*([\d.]+)", text)
|
||||||
|
load_5m = re.search(r"Last 5 minutes:\s*([\d.]+)", text)
|
||||||
|
load_15m = re.search(r"Last 15 minutes:\s*([\d.]+)", text)
|
||||||
|
if load_1m:
|
||||||
|
data.load_1m = float(load_1m.group(1))
|
||||||
|
if load_5m:
|
||||||
|
data.load_5m = float(load_5m.group(1))
|
||||||
|
if load_15m:
|
||||||
|
data.load_15m = float(load_15m.group(1))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _parse_statistics_page(self, html: str) -> Dict[str, InterfaceStats]:
|
||||||
|
"""Parse interface statistics from HTML table."""
|
||||||
|
interfaces = {}
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
for table_id in ["statsTable-Ethernet", "statsTable-WiFi"]:
|
||||||
|
table = soup.find("table", id=table_id)
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
|
||||||
|
thead = table.find("thead")
|
||||||
|
if not thead:
|
||||||
|
continue
|
||||||
|
|
||||||
|
header_cells = thead.find_all("th")
|
||||||
|
interface_columns = []
|
||||||
|
for i, th in enumerate(header_cells):
|
||||||
|
if i == 0:
|
||||||
|
continue
|
||||||
|
iface_name = th.get_text(strip=True)
|
||||||
|
normalized = self._normalize_interface_name(iface_name)
|
||||||
|
interface_columns.append((i, normalized))
|
||||||
|
|
||||||
|
for col_idx, normalized in interface_columns:
|
||||||
|
if normalized and normalized not in interfaces:
|
||||||
|
interfaces[normalized] = InterfaceStats(name=normalized)
|
||||||
|
|
||||||
|
tbody = table.find("tbody")
|
||||||
|
if not tbody:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in tbody.find_all("tr"):
|
||||||
|
row_name = row.get("name", "")
|
||||||
|
cells = row.find_all("td")
|
||||||
|
|
||||||
|
for col_idx, normalized in interface_columns:
|
||||||
|
if not normalized or col_idx >= len(cells):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cell = cells[col_idx]
|
||||||
|
value_span = cell.find("span", class_="value") or cell.find("span")
|
||||||
|
value = value_span.get_text(strip=True) if value_span else cell.get_text(strip=True)
|
||||||
|
|
||||||
|
iface = interfaces[normalized]
|
||||||
|
if row_name == "status":
|
||||||
|
iface.status = value.upper() if value else "UNKNOWN"
|
||||||
|
elif row_name == "bytesSent":
|
||||||
|
iface.bytes_sent = self._parse_int(value)
|
||||||
|
elif row_name == "bytesReceived":
|
||||||
|
iface.bytes_received = self._parse_int(value)
|
||||||
|
elif row_name == "packetsSent":
|
||||||
|
iface.packets_sent = self._parse_int(value)
|
||||||
|
elif row_name == "packetsReceived":
|
||||||
|
iface.packets_received = self._parse_int(value)
|
||||||
|
elif row_name == "errorsSent":
|
||||||
|
iface.errors_sent = self._parse_int(value)
|
||||||
|
elif row_name == "errorsReceived":
|
||||||
|
iface.errors_received = self._parse_int(value)
|
||||||
|
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
def _normalize_interface_name(self, name: str) -> Optional[str]:
|
||||||
|
name = name.lower().strip().replace("\xa0", " ")
|
||||||
|
mappings = {
|
||||||
|
"lan 1": "lan_1",
|
||||||
|
"lan 2": "lan_2",
|
||||||
|
"lan 3": "lan_3",
|
||||||
|
"lan 4": "lan_4",
|
||||||
|
"10g": "10g",
|
||||||
|
"fibre": "fibre",
|
||||||
|
"2.4 ghz": "wifi_2.4ghz",
|
||||||
|
"5.0 ghz radio 1": "wifi_5ghz_radio1",
|
||||||
|
"5.0 ghz radio 2": "wifi_5ghz_radio2",
|
||||||
|
"guest": "wifi_guest",
|
||||||
|
}
|
||||||
|
return mappings.get(name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_int(value: str) -> int:
|
||||||
|
try:
|
||||||
|
return int(value.replace(",", "").strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0
|
||||||
78
src/router_status.py
Normal file
78
src/router_status.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse router logs to determine device connection status."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Generator, Iterable, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_RE = re.compile(r"<(?P<name>.+?) \[(?P<mac>[^\]]+)\]>")
|
||||||
|
CONNECT_PHRASE = "has successfully connected"
|
||||||
|
DISCONNECT_PHRASE = "was disconnected"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceStatus:
|
||||||
|
name: str
|
||||||
|
mac: str
|
||||||
|
last_event: Optional[str] = None # "connected" or "disconnected"
|
||||||
|
last_event_time: Optional[datetime] = None
|
||||||
|
last_connected_time: Optional[datetime] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self.last_event == "connected"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(ts_str: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not ts_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(ts_str)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_events(rows: List[Dict[str, str]], allow_missing_timestamp: bool = False) -> Generator[Dict[str, Any], None, None]:
|
||||||
|
"""Parse log rows into connect/disconnect events."""
|
||||||
|
for row in rows:
|
||||||
|
msg = row.get("message", "")
|
||||||
|
ts_str = row.get("log_timestamp")
|
||||||
|
ts = parse_timestamp(ts_str)
|
||||||
|
|
||||||
|
match = DEVICE_RE.search(msg)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
if not ts and not allow_missing_timestamp:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = match.group("name").strip()
|
||||||
|
mac = match.group("mac").strip()
|
||||||
|
text_lower = msg.lower()
|
||||||
|
|
||||||
|
if CONNECT_PHRASE in text_lower:
|
||||||
|
yield {"name": name, "mac": mac, "event": "connected", "time": ts}
|
||||||
|
elif DISCONNECT_PHRASE in text_lower:
|
||||||
|
yield {"name": name, "mac": mac, "event": "disconnected", "time": ts}
|
||||||
|
|
||||||
|
|
||||||
|
def build_status(events: Iterable[Dict[str, Any]]) -> Dict[str, DeviceStatus]:
|
||||||
|
"""Build current device status from events."""
|
||||||
|
devices: Dict[str, DeviceStatus] = {}
|
||||||
|
for ev in events:
|
||||||
|
device = devices.get(ev["mac"])
|
||||||
|
if not device:
|
||||||
|
device = DeviceStatus(name=ev["name"], mac=ev["mac"])
|
||||||
|
devices[ev["mac"]] = device
|
||||||
|
|
||||||
|
device.name = ev["name"]
|
||||||
|
device.last_event = ev["event"]
|
||||||
|
device.last_event_time = ev["time"]
|
||||||
|
if ev["event"] == "connected":
|
||||||
|
device.last_connected_time = ev["time"]
|
||||||
|
|
||||||
|
return devices
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
16
tests/conftest.py
Normal file
16
tests/conftest.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from database import VirginMonitorDatabase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db():
|
||||||
|
"""Create a temporary database for testing."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
db_path = f.name
|
||||||
|
db = VirginMonitorDatabase(db_path)
|
||||||
|
yield db
|
||||||
|
Path(db_path).unlink(missing_ok=True)
|
||||||
63
tests/test_database.py
Normal file
63
tests/test_database.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveRouterLogs:
|
||||||
|
def test_inserts_new_entries(self, temp_db):
|
||||||
|
logs = [
|
||||||
|
{
|
||||||
|
"log_timestamp": "2024-01-15T10:30:00",
|
||||||
|
"level": "INF",
|
||||||
|
"module": "WIFI",
|
||||||
|
"message": "Device connected",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
inserted = temp_db.save_router_logs(logs)
|
||||||
|
assert inserted == 1
|
||||||
|
|
||||||
|
def test_deduplicates_entries(self, temp_db):
|
||||||
|
logs = [
|
||||||
|
{
|
||||||
|
"log_timestamp": "2024-01-15T10:30:00",
|
||||||
|
"level": "INF",
|
||||||
|
"module": "WIFI",
|
||||||
|
"message": "Device connected",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
temp_db.save_router_logs(logs)
|
||||||
|
inserted = temp_db.save_router_logs(logs)
|
||||||
|
assert inserted == 0
|
||||||
|
|
||||||
|
def test_empty_list_returns_zero(self, temp_db):
|
||||||
|
assert temp_db.save_router_logs([]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLogCounts:
|
||||||
|
def test_empty_database(self, temp_db):
|
||||||
|
counts = temp_db.get_log_counts()
|
||||||
|
assert counts["total"] == 0
|
||||||
|
assert counts["by_level"] == {}
|
||||||
|
assert counts["by_module"] == {}
|
||||||
|
|
||||||
|
def test_with_data(self, temp_db):
|
||||||
|
logs = [
|
||||||
|
{"log_timestamp": datetime.now().isoformat(), "level": "INF", "module": "WIFI", "message": "msg1"},
|
||||||
|
{"log_timestamp": datetime.now().isoformat(), "level": "INF", "module": "WIFI", "message": "msg2"},
|
||||||
|
{"log_timestamp": datetime.now().isoformat(), "level": "ERR", "module": "SYS", "message": "msg3"},
|
||||||
|
]
|
||||||
|
temp_db.save_router_logs(logs)
|
||||||
|
counts = temp_db.get_log_counts()
|
||||||
|
assert counts["total"] == 3
|
||||||
|
assert counts["by_level"]["INF"] == 2
|
||||||
|
assert counts["by_level"]["ERR"] == 1
|
||||||
|
assert counts["by_module"]["WIFI"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDeviceLogRows:
|
||||||
|
def test_returns_logs_for_parsing(self, temp_db):
|
||||||
|
logs = [
|
||||||
|
{"log_timestamp": "2024-01-15T10:30:00", "level": "INF", "module": "WIFI", "message": "test message"},
|
||||||
|
]
|
||||||
|
temp_db.save_router_logs(logs)
|
||||||
|
rows = temp_db.get_device_log_rows()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["message"] == "test message"
|
||||||
32
tests/test_parsing_utils.py
Normal file
32
tests/test_parsing_utils.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from router_scraper import RouterScraper
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseInt:
|
||||||
|
def test_simple_number(self):
|
||||||
|
assert RouterScraper._parse_int("123") == 123
|
||||||
|
|
||||||
|
def test_with_commas(self):
|
||||||
|
assert RouterScraper._parse_int("1,234,567") == 1234567
|
||||||
|
|
||||||
|
def test_with_whitespace(self):
|
||||||
|
assert RouterScraper._parse_int(" 456 ") == 456
|
||||||
|
|
||||||
|
def test_invalid_returns_zero(self):
|
||||||
|
assert RouterScraper._parse_int("not a number") == 0
|
||||||
|
assert RouterScraper._parse_int("") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeInterfaceName:
|
||||||
|
def test_lan_ports(self):
|
||||||
|
scraper = RouterScraper.__new__(RouterScraper)
|
||||||
|
assert scraper._normalize_interface_name("LAN 1") == "lan_1"
|
||||||
|
assert scraper._normalize_interface_name("lan 2") == "lan_2"
|
||||||
|
|
||||||
|
def test_wifi_bands(self):
|
||||||
|
scraper = RouterScraper.__new__(RouterScraper)
|
||||||
|
assert scraper._normalize_interface_name("2.4 GHz") == "wifi_2.4ghz"
|
||||||
|
assert scraper._normalize_interface_name("5.0 GHz Radio 1") == "wifi_5ghz_radio1"
|
||||||
|
|
||||||
|
def test_unknown_returns_none(self):
|
||||||
|
scraper = RouterScraper.__new__(RouterScraper)
|
||||||
|
assert scraper._normalize_interface_name("Unknown Interface") is None
|
||||||
73
tests/test_router_status.py
Normal file
73
tests/test_router_status.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from router_status import parse_timestamp, parse_events, build_status
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTimestamp:
|
||||||
|
def test_iso_format(self):
|
||||||
|
result = parse_timestamp("2024-01-15T10:30:00")
|
||||||
|
assert result == datetime(2024, 1, 15, 10, 30, 0)
|
||||||
|
|
||||||
|
def test_strptime_format(self):
|
||||||
|
result = parse_timestamp("2024-01-15 10:30:00")
|
||||||
|
assert result == datetime(2024, 1, 15, 10, 30, 0)
|
||||||
|
|
||||||
|
def test_invalid_returns_none(self):
|
||||||
|
assert parse_timestamp("not a date") is None
|
||||||
|
assert parse_timestamp("") is None
|
||||||
|
assert parse_timestamp(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseEvents:
|
||||||
|
def test_extracts_connected_device(self):
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"log_timestamp": "2024-01-15T10:30:00",
|
||||||
|
"message": "<MyPhone [AA:BB:CC:DD:EE:FF]> has successfully connected to WiFi",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
events = list(parse_events(rows))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["name"] == "MyPhone"
|
||||||
|
assert events[0]["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||||
|
assert events[0]["event"] == "connected"
|
||||||
|
|
||||||
|
def test_extracts_disconnected_device(self):
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"log_timestamp": "2024-01-15T10:30:00",
|
||||||
|
"message": "<Laptop [11:22:33:44:55:66]> was disconnected from WiFi",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
events = list(parse_events(rows))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["event"] == "disconnected"
|
||||||
|
|
||||||
|
def test_skips_missing_timestamp(self):
|
||||||
|
rows = [{"message": "<Device [AA:BB:CC:DD:EE:FF]> has successfully connected"}]
|
||||||
|
events = list(parse_events(rows))
|
||||||
|
assert len(events) == 0
|
||||||
|
|
||||||
|
def test_allows_missing_timestamp_when_flagged(self):
|
||||||
|
rows = [{"message": "<Device [AA:BB:CC:DD:EE:FF]> has successfully connected"}]
|
||||||
|
events = list(parse_events(rows, allow_missing_timestamp=True))
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildStatus:
|
||||||
|
def test_tracks_connection(self):
|
||||||
|
events = [
|
||||||
|
{"name": "Phone", "mac": "AA:BB:CC:DD:EE:FF", "event": "connected", "time": datetime(2024, 1, 15, 10, 0)},
|
||||||
|
]
|
||||||
|
status = build_status(events)
|
||||||
|
assert "AA:BB:CC:DD:EE:FF" in status
|
||||||
|
assert status["AA:BB:CC:DD:EE:FF"].is_connected is True
|
||||||
|
|
||||||
|
def test_tracks_disconnection(self):
|
||||||
|
events = [
|
||||||
|
{"name": "Phone", "mac": "AA:BB:CC:DD:EE:FF", "event": "connected", "time": datetime(2024, 1, 15, 10, 0)},
|
||||||
|
{"name": "Phone", "mac": "AA:BB:CC:DD:EE:FF", "event": "disconnected", "time": datetime(2024, 1, 15, 11, 0)},
|
||||||
|
]
|
||||||
|
status = build_status(events)
|
||||||
|
assert status["AA:BB:CC:DD:EE:FF"].is_connected is False
|
||||||
|
assert status["AA:BB:CC:DD:EE:FF"].last_connected_time == datetime(2024, 1, 15, 10, 0)
|
||||||
421
uv.lock
generated
Normal file
421
uv.lock
generated
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
toml = [
|
||||||
|
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "homehub-exporter"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "playwright" },
|
||||||
|
{ name = "prometheus-client" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.12" },
|
||||||
|
{ name = "playwright", specifier = ">=1.50" },
|
||||||
|
{ name = "prometheus-client", specifier = ">=0.21" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=4.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.3" },
|
||||||
|
{ name = "ty", specifier = ">=0.0.9" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "playwright"
|
||||||
|
version = "1.57.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet" },
|
||||||
|
{ name = "pyee" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prometheus-client"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/23/53/3edb5d68ecf6b38fcbcc1ad28391117d2a322d9a1a3eff04bfdb184d8c3b/prometheus_client-0.23.1.tar.gz", hash = "sha256:6ae8f9081eaaaf153a2e959d2e6c4f4fb57b12ef76c8c7980202f1e57b48b2ce", size = 80481, upload-time = "2025-09-18T20:47:25.043Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/db/14bafcb4af2139e046d03fd00dea7873e48eafe18b7d2797e73d6681f210/prometheus_client-0.23.1-py3-none-any.whl", hash = "sha256:dd1913e6e76b59cfe44e7a4b83e01afc9873c1bdfd2ed8739f1e76aeca115f99", size = 61145, upload-time = "2025-09-18T20:47:23.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyee"
|
||||||
|
version = "13.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage", extra = ["toml"] },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.14.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ty"
|
||||||
|
version = "0.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user