From ec8ab837b10f4bdd2b18d69bc02fa5d1c7f76f08 Mon Sep 17 00:00:00 2001 From: stahlz Date: Thu, 5 Mar 2026 19:25:04 +0000 Subject: [PATCH] Upload files to "bgw210-prom-collector" --- .../bgw210-700_prom_collector.py | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 bgw210-prom-collector/bgw210-700_prom_collector.py diff --git a/bgw210-prom-collector/bgw210-700_prom_collector.py b/bgw210-prom-collector/bgw210-700_prom_collector.py new file mode 100644 index 0000000..39c32b8 --- /dev/null +++ b/bgw210-prom-collector/bgw210-700_prom_collector.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +""" +bgw210-700_prom_collector.py +============================= + +Prometheus collector script to pull stats from AT&T / Arris BGW210-700 and +expose them as Prometheus metrics. + +Tested With +----------- + +* AT&T/Arris BGW210-700 (Software 1.5.12 / Hardware 02001C0046004D) + +Requirements +------------ + +- requests +- lxml +- prometheus-client + +Usage +----- + +Run as a standalone HTTP server: + python3 bgw210-700_prom_collector.py --port 8000 --ip 192.168.1.254 + +Then scrape metrics from http://localhost:8000/metrics + +License +------- + +Copyright 2025 Jason Antman +Free for any use provided that patches are submitted back to me. + +The latest version of this script can be found at: + + +CHANGELOG +--------- + +2025-05-29 Jason Antman : + - initial version of script based on bgw210-700_to_graphite.py +""" + +import logging +import sys +import argparse +import re +import time +from urllib.parse import urlparse + +import requests +from lxml import etree +from prometheus_client import start_http_server, Gauge, Counter, REGISTRY +from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily + +FORMAT = "[%(asctime)s %(levelname)s] %(message)s" +logging.basicConfig(level=logging.WARNING, format=FORMAT) +logger = logging.getLogger() + + +class BGW210700Collector: + """ + Prometheus collector for BGW210-700 modem metrics + """ + + def __init__(self, modem_ip): + """ + Initialize the collector + + :param modem_ip: modem IP address + :type modem_ip: str + """ + self.modem_ip = modem_ip + self.stats_getter = BGW210700StatsGetter(modem_ip) + + def collect(self): + """ + Collect metrics from the modem and yield them + """ + try: + stats = self.stats_getter.get_stats() + + # Create gauge metrics + connection_status = GaugeMetricFamily( + 'bgw210_connection_status', + 'Broadband connection status (1=up, 0=down)' + ) + connection_status.add_metric([], stats.get('connection', 0)) + yield connection_status + + uptime_seconds = GaugeMetricFamily( + 'bgw210_uptime_seconds', + 'Time since last reboot in seconds' + ) + uptime_seconds.add_metric([], stats.get('uptime_sec', 0)) + yield uptime_seconds + + line_speed_mbps = GaugeMetricFamily( + 'bgw210_line_speed_mbps', + 'Current line speed in Mbps' + ) + line_speed_mbps.add_metric([], stats.get('line_speed', 0)) + yield line_speed_mbps + + line_state = GaugeMetricFamily( + 'bgw210_line_state', + 'Line state (1=up, 0=down)' + ) + line_state.add_metric([], stats.get('line_state', 0)) + yield line_state + + duplex_status = GaugeMetricFamily( + 'bgw210_duplex_status', + 'Duplex status (1=full, 0.5=half)' + ) + duplex_status.add_metric([], stats.get('duplex', 0)) + yield duplex_status + + # Create counter metrics for IPv4 + ipv4_rx_bytes = CounterMetricFamily( + 'bgw210_ipv4_rx_bytes_total', + 'IPv4 received bytes total' + ) + ipv4_rx_bytes.add_metric([], stats.get('ipv4.rx_bytes', 0)) + yield ipv4_rx_bytes + + ipv4_tx_bytes = CounterMetricFamily( + 'bgw210_ipv4_tx_bytes_total', + 'IPv4 transmitted bytes total' + ) + ipv4_tx_bytes.add_metric([], stats.get('ipv4.tx_bytes', 0)) + yield ipv4_tx_bytes + + ipv4_rx_packets = CounterMetricFamily( + 'bgw210_ipv4_rx_packets_total', + 'IPv4 received packets total' + ) + ipv4_rx_packets.add_metric([], stats.get('ipv4.rx_packets', 0)) + yield ipv4_rx_packets + + ipv4_tx_packets = CounterMetricFamily( + 'bgw210_ipv4_tx_packets_total', + 'IPv4 transmitted packets total' + ) + ipv4_tx_packets.add_metric([], stats.get('ipv4.tx_packets', 0)) + yield ipv4_tx_packets + + ipv4_rx_unicast = CounterMetricFamily( + 'bgw210_ipv4_rx_unicast_total', + 'IPv4 received unicast packets total' + ) + ipv4_rx_unicast.add_metric([], stats.get('ipv4.rx_unicast', 0)) + yield ipv4_rx_unicast + + ipv4_tx_unicast = CounterMetricFamily( + 'bgw210_ipv4_tx_unicast_total', + 'IPv4 transmitted unicast packets total' + ) + ipv4_tx_unicast.add_metric([], stats.get('ipv4.tx_unicast', 0)) + yield ipv4_tx_unicast + + ipv4_rx_multicast = CounterMetricFamily( + 'bgw210_ipv4_rx_multicast_total', + 'IPv4 received multicast packets total' + ) + ipv4_rx_multicast.add_metric([], stats.get('ipv4.rx_multicast', 0)) + yield ipv4_rx_multicast + + ipv4_tx_multicast = CounterMetricFamily( + 'bgw210_ipv4_tx_multicast_total', + 'IPv4 transmitted multicast packets total' + ) + ipv4_tx_multicast.add_metric([], stats.get('ipv4.tx_multicast', 0)) + yield ipv4_tx_multicast + + ipv4_rx_errors = CounterMetricFamily( + 'bgw210_ipv4_rx_errors_total', + 'IPv4 receive errors total' + ) + ipv4_rx_errors.add_metric([], stats.get('ipv4.rx_errors', 0)) + yield ipv4_rx_errors + + ipv4_tx_errors = CounterMetricFamily( + 'bgw210_ipv4_tx_errors_total', + 'IPv4 transmit errors total' + ) + ipv4_tx_errors.add_metric([], stats.get('ipv4.tx_errors', 0)) + yield ipv4_tx_errors + + ipv4_rx_drops = CounterMetricFamily( + 'bgw210_ipv4_rx_drops_total', + 'IPv4 receive drops total' + ) + ipv4_rx_drops.add_metric([], stats.get('ipv4.rx_drops', 0)) + yield ipv4_rx_drops + + ipv4_tx_drops = CounterMetricFamily( + 'bgw210_ipv4_tx_drops_total', + 'IPv4 transmit drops total' + ) + ipv4_tx_drops.add_metric([], stats.get('ipv4.tx_drops', 0)) + yield ipv4_tx_drops + + ipv4_collisions = CounterMetricFamily( + 'bgw210_ipv4_collisions_total', + 'IPv4 collisions total' + ) + ipv4_collisions.add_metric([], stats.get('ipv4.collisions', 0)) + yield ipv4_collisions + + # Create counter metrics for IPv6 + ipv6_tx_packets = CounterMetricFamily( + 'bgw210_ipv6_tx_packets_total', + 'IPv6 transmitted packets total' + ) + ipv6_tx_packets.add_metric([], stats.get('ipv6.tx_packets', 0)) + yield ipv6_tx_packets + + ipv6_tx_errors = CounterMetricFamily( + 'bgw210_ipv6_tx_errors_total', + 'IPv6 transmit errors total' + ) + ipv6_tx_errors.add_metric([], stats.get('ipv6.tx_errors', 0)) + yield ipv6_tx_errors + + ipv6_tx_discards = CounterMetricFamily( + 'bgw210_ipv6_tx_discards_total', + 'IPv6 transmit discards total' + ) + ipv6_tx_discards.add_metric([], stats.get('ipv6.tx_discards', 0)) + yield ipv6_tx_discards + + logger.debug('Successfully collected metrics from modem') + + except Exception as e: + logger.error('Error collecting metrics from modem: %s', e) + # Yield a connection status metric set to 0 on error + connection_status = GaugeMetricFamily( + 'bgw210_connection_status', + 'Broadband connection status (1=up, 0=down)' + ) + connection_status.add_metric([], 0) + yield connection_status + + +class BGW210700StatsGetter: + """ + Class to get statistics from BGW210-700 modem + """ + + def __init__(self, ip): + """ + :param ip: modem IP address + :type ip: str + """ + logger.debug('Initializing BGW210700StatsGetter ip=%s', ip) + self.ip = ip + + def get_stats(self): + """ + Get statistics from modem; return a dict + + :return: stats + :rtype: dict + """ + res = {} + res.update(self._sysinfo()) + res.update(self._broadband_stats()) + return res + + def _get_page(self, url): + logger.debug('GET: %s', url) + r = requests.get(url, timeout=10) + r.raise_for_status() + tree = etree.HTML(r.text) + return tree + + def _do_kv_table(self, table): + res = {} + for tr in table.iter('tr'): + if len(tr) != 2 or tr[0].tag != 'th' or tr[1].tag != 'td': + continue + if tr[1].text is not None: + res[tr[0].text.strip()] = tr[1].text.strip() + return res + + def _broadband_stats(self): + tree = self._get_page( + 'http://%s/cgi-bin/broadbandstatistics.ha' % self.ip + ) + data = {} + for tbl in tree.iter('table'): + summary = tbl.get('summary', None) + if summary is None: + continue + data[summary] = self._do_kv_table(tbl) + + bs = 'Summary of the most important WAN information' + + # Parse line state correctly + line_state_text = data.get('Ethernet Statistics Table', {}).get('Current Status', 'Down') + line_state = 1 if line_state_text == 'Up' else 0 + + return { + 'connection': 1 if data[bs]['Broadband Connection'] == 'Up' else 0, + 'ipv6.tx_discards': int( + data['IPv6 Statistics Table']['Transmit Discards'] + ), + 'ipv6.tx_errors': int( + data['IPv6 Statistics Table']['Transmit Errors'] + ), + 'ipv6.tx_packets': int( + data['IPv6 Statistics Table']['Transmit Packets'] + ), + 'ipv4.rx_drops': int( + data['Ethernet IPv4 Statistics Table']['Receive Drops'] + ), + 'ipv4.tx_drops': int( + data['Ethernet IPv4 Statistics Table']['Transmit Drops'] + ), + 'ipv4.rx_unicast': int( + data['Ethernet IPv4 Statistics Table']['Receive Unicast'] + ), + 'ipv4.tx_bytes': int( + data['Ethernet IPv4 Statistics Table']['Transmit Bytes'] + ), + 'ipv4.rx_errors': int( + data['Ethernet IPv4 Statistics Table']['Receive Errors'] + ), + 'ipv4.rx_bytes': int( + data['Ethernet IPv4 Statistics Table']['Receive Bytes'] + ), + 'ipv4.tx_packets': int( + data['Ethernet IPv4 Statistics Table']['Transmit Packets'] + ), + 'ipv4.rx_packets': int( + data['Ethernet IPv4 Statistics Table']['Receive Packets'] + ), + 'ipv4.collisions': int( + data['Ethernet IPv4 Statistics Table']['Collisions'] + ), + 'ipv4.rx_multicast': int( + data['Ethernet IPv4 Statistics Table']['Receive Multicast'] + ), + 'ipv4.tx_multicast': int( + data['Ethernet IPv4 Statistics Table']['Transmit Multicast'] + ), + 'ipv4.tx_unicast': int( + data['Ethernet IPv4 Statistics Table']['Transmit Unicast'] + ), + 'ipv4.tx_errors': int( + data['Ethernet IPv4 Statistics Table']['Transmit Errors'] + ), + 'line_speed': int( + data['Ethernet Statistics Table']['Current Speed (Mbps)'] + ), + 'line_state': line_state, + 'duplex': 1 if data[ + 'Ethernet Statistics Table' + ]['Current Duplex'] == 'full' else 0.5 + } + + def _sysinfo(self): + tree = self._get_page('http://%s/cgi-bin/sysinfo.ha' % self.ip) + info = self._do_kv_table(tree.xpath('//table')[0]) + return { + 'uptime_sec': int(info['Time Since Last Reboot']) + } + + +def set_log_info(): + """set logger level to INFO""" + set_log_level_format(logging.INFO, + '%(asctime)s %(levelname)s:%(name)s:%(message)s') + + +def set_log_debug(): + """set logger level to DEBUG, and debug-level output format""" + set_log_level_format( + logging.DEBUG, + "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " + "%(name)s.%(funcName)s() ] %(message)s" + ) + + +def set_log_level_format(level, format): + """ + Set logger level and format. + + :param level: logging level; see the :py:mod:`logging` constants. + :type level: int + :param format: logging formatter format string + :type format: str + """ + formatter = logging.Formatter(fmt=format) + logger.handlers[0].setFormatter(formatter) + logger.setLevel(level) + + +def parse_args(argv): + p = argparse.ArgumentParser( + description='BGW210-700 Prometheus metrics collector' + ) + p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, + help='verbose output. specify twice for debug-level output.') + p.add_argument('-p', '--port', dest='port', type=int, + action='store', default=8000, + help='HTTP server port (default: 8000)') + p.add_argument('-i', '--ip', dest='modem_ip', action='store', type=str, + default='192.168.1.254', + help='Modem IP address (default: 192.168.1.254)') + p.add_argument('--host', dest='host', action='store', type=str, + default='0.0.0.0', + help='HTTP server bind address (default: 0.0.0.0)') + args = p.parse_args(argv) + return args + + +if __name__ == "__main__": + args = parse_args(sys.argv[1:]) + + # set logging level + if args.verbose > 1: + set_log_debug() + elif args.verbose > 0: + set_log_info() + + # Create and register the collector + collector = BGW210700Collector(args.modem_ip) + REGISTRY.register(collector) + + # Start the HTTP server + logger.info('Starting HTTP server on %s:%d', args.host, args.port) + start_http_server(args.port, addr=args.host) + + logger.info('Metrics available at http://%s:%d/metrics', args.host, args.port) + + try: + while True: + time.sleep(60) + except KeyboardInterrupt: + logger.info('Shutting down...') + sys.exit(0) \ No newline at end of file