diff --git a/.env b/.env new file mode 100644 index 0000000..f8cf67e --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +CCU_HOST=debmatic.fritz.box +CCU_HMIP_PORT=2010 +CCU_HM_PORT=2001 +EXPORTER_HMIP_PORT=9020 +EXPORTER_HM_PORT=9021 +SCRAPPING_INTERVAL=300 diff --git a/.github/workflows/python-build.yaml b/.github/workflows/python-build.yaml index a55a9c5..7c07c02 100644 --- a/.github/workflows/python-build.yaml +++ b/.github/workflows/python-build.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 diff --git a/Dockerfile b/Dockerfile index 3db1257..83039dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim-bookworm +FROM python:3.12-slim-bookworm COPY requirements.txt /tmp RUN pip3 install --no-cache-dir -r /tmp/requirements.txt diff --git a/README.md b/README.md index 6399250..a7332eb 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,6 @@ Feel free to open issues for unsupported items. ## Build -For multi-architecture builds (x86, arm, arm64), e.g. use `docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64 -t sfudeus/homematic_exporter:latest .` or use `build.sh`. +For multi-architecture builds (x86, arm, arm64), e.g. use `docker buildx build --platform linux/amd64,linux/arm/v7,linux/arm64 -t s0riak/homematic_exporter:latest .` or use `build.sh`. You can usually find an up-to-date image for amd64, arm and arm64 at sfudeus/homematic_exporter:latest in [docker hub](https://hub.docker.com/r/sfudeus/homematic_exporter). Additionally, they are tagged with their build date to have a stable reference. diff --git a/build.sh b/build.sh index dcc188d..a8a67e6 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash -REPO=sfudeus/homematic_exporter +REPO=s0riak/homematic_exporter docker buildx build --platform linux/amd64 --platform linux/arm/v7 --platform linux/arm64 -t $REPO:"$(date +%F)" -t $REPO:latest --push . diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..c424782 --- /dev/null +++ b/compose.yml @@ -0,0 +1,20 @@ +version: '3' + +services: + homematic_exporter_hmip: + build: ./ + container_name: homematic_exporter_hmip + restart: unless-stopped + command: --ccu_host ${CCU_HOST} --ccu_port ${CCU_HMIP_PORT} --port ${EXPORTER_HMIP_PORT} --interval ${SCRAPPING_INTERVAL} + + homematic_exporter_hm: + build: ./ + container_name: homematic_exporter_hm + restart: unless-stopped + command: --ccu_host ${CCU_HOST} --ccu_port ${CCU_HM_PORT} --port ${EXPORTER_HM_PORT} --interval ${SCRAPPING_INTERVAL} + +networks: + default: + name: monitoring_prometheus_grafana_default + external: true + diff --git a/exporter.py b/exporter.py index c533f7e..8ed0975 100755 --- a/exporter.py +++ b/exporter.py @@ -13,69 +13,13 @@ from http.server import HTTPServer from pprint import pformat import requests -from prometheus_client import Gauge, Counter, Enum, MetricsHandler, core, Summary +from prometheus_client import Gauge, Counter, Enum, MetricsHandler, core, Summary, start_http_server class HomematicMetricsProcessor(threading.Thread): - METRICS_NAMESPACE = 'homematic' - # Supported Homematic (BidcosRF and IP) device types - DEFAULT_SUPPORTED_TYPES = [ - 'HmIP-eTRV-2', - 'HmIP-eTRV-C', - 'HmIP-eTRV-C-2', - 'HmIP-FSM', - 'HmIP-MIOB', - 'HMIP-PS', - 'HMIP-PSM', - 'HmIP-RCV-1', - 'HmIP-STH', - 'HmIP-STHD', - 'HmIP-STHO', - 'HmIP-STE2-PCB', - 'HmIP-SWD', - 'HMIP-SWDO', - 'HmIP-SWSD', - 'HmIP-SWO-PL', - 'HmIP-SWO-PR', - 'HmIP-WTH-2', - 'HmIP-BSL', - 'HM-CC-RT-DN', - 'HM-Dis-EP-WM55', - 'HM-Dis-WM55', - 'HM-ES-PMSw1-Pl-DN-R5', - 'HM-ES-TX-WM', - 'HM-LC-Bl1-FM', - 'HM-LC-Dim1PWM-CV', - 'HM-LC-Dim1T-FM', - 'HM-LC-RGBW-WM', - 'HM-LC-Sw1-Pl-DN-R5', - 'HM-LC-Sw1-FM', - 'HM-LC-Sw2-FM', - 'HM-OU-CFM-Pl', - 'HM-OU-CFM-TW', - 'HM-PBI-4-FM', - 'HM-PB-2-WM55', - 'HM-PB-6-WM55', - 'HM-RC-P1', - 'HM-RC-4-2', - 'HM-RC-8', - 'HM-Sec-MDIR-2', - 'HM-Sec-SCo', - 'HM-Sec-SC-2', - 'HM-Sec-SD-2', - 'HM-Sec-TiS', - 'HM-Sen-LI-O', - 'HM-Sen-MDIR-O', - 'HM-Sen-MDIR-WM55', - 'HM-SwI-3-FM', - 'HM-TC-IT-WM-W-EU', - 'HM-WDS10-TH-O', - 'HM-WDS100-C6-O-2', - 'HM-WDS30-OT2-SM', - 'HM-WDS40-TH-I', - 'HM-WDS40-TH-I-2', - ] + # Unsupported Homematic (BidcosRF and IP) device types + UNDEFAULT_SUPPORTED_TYPES = [] # A list with channel numbers for devices where getParamset # never works, or only sometimes works (e.g. if the device sent @@ -106,7 +50,7 @@ class HomematicMetricsProcessor(threading.Thread): reload_names_active = False reload_names_interval = 30 # reload names every 60 gatherings mapped_names = {} - supported_device_types = DEFAULT_SUPPORTED_TYPES + unsupported_device_types = UNDEFAULT_SUPPORTED_TYPES channels_with_errors_allowed = DEFAULT_CHANNELS_WITH_ERRORS_ALLOWED device_count = None @@ -115,13 +59,18 @@ class HomematicMetricsProcessor(threading.Thread): def run(self): logging.info("Starting thread for data gathering") logging.info("Mapping {} devices with custom names".format(len(self.mapped_names))) - logging.info("Supporting {} device types: {}".format(len(self.supported_device_types), ",".join(self.supported_device_types))) - - gathering_counter = Counter('gathering_count', 'Amount of gathering runs', labelnames=['ccu'], namespace=self.METRICS_NAMESPACE) - error_counter = Counter('gathering_errors', 'Amount of failed gathering runs', labelnames=['ccu'], namespace=self.METRICS_NAMESPACE) + logging.info("The following {} device types are NOT supported: {}".format(len(self.unsupported_device_types), + ",".join( + self.unsupported_device_types))) + + gathering_counter = Counter('gathering_count', 'Amount of gathering runs', labelnames=['ccu'], + namespace=self.METRICS_NAMESPACE) + error_counter = Counter('gathering_errors', 'Amount of failed gathering runs', labelnames=['ccu'], + namespace=self.METRICS_NAMESPACE) generate_metrics_summary = Summary('generate_metrics_seconds', 'Time spent in gathering runs', labelnames=['ccu'], namespace=self.METRICS_NAMESPACE) - read_names_summary = Summary('read_names_seconds', 'Time spent reading names from CCU', labelnames=['ccu'], namespace=self.METRICS_NAMESPACE) + read_names_summary = Summary('read_names_seconds', 'Time spent reading names from CCU', labelnames=['ccu'], + namespace=self.METRICS_NAMESPACE) gathering_loop_counter = 1 @@ -170,8 +119,9 @@ def __init__(self, ccu_host, ccu_port, auth, gathering_interval, reload_names_in logging.info("Processing config file {}".format(config_filename)) config = json.load(config_file) self.mapped_names = config.get('device_mapping', {}) - self.supported_device_types = config.get('supported_device_types', self.DEFAULT_SUPPORTED_TYPES) - self.channels_with_errors_allowed = config.get('channels_with_errors_allowed', self.DEFAULT_CHANNELS_WITH_ERRORS_ALLOWED) + self.unsupported_device_types = config.get('unsupported_device_types', self.UNDEFAULT_SUPPORTED_TYPES) + self.channels_with_errors_allowed = config.get('channels_with_errors_allowed', + self.DEFAULT_CHANNELS_WITH_ERRORS_ALLOWED) self.ccu_host = ccu_host self.ccu_port = ccu_port @@ -182,7 +132,8 @@ def __init__(self, ccu_host, ccu_port, auth, gathering_interval, reload_names_in self.ccu_url = "http://{}:{}".format(ccu_host, ccu_port) self.gathering_interval = int(gathering_interval) self.reload_names_interval = int(reload_names_interval) - self.devicecount = Gauge('devicecount', 'Number of processed/supported devices', labelnames=['ccu'], namespace=self.METRICS_NAMESPACE) + self.devicecount = Gauge('devicecount', 'Number of processed/supported devices', labelnames=['ccu'], + namespace=self.METRICS_NAMESPACE) def generate_metrics(self): logging.info("Gathering metrics") @@ -193,14 +144,16 @@ def generate_metrics(self): devParentAddress = device.get('PARENT') devAddress = device.get('ADDRESS') if devParentAddress == '': - if devType in self.supported_device_types: + if not devType in self.unsupported_device_types: devChildcount = len(device.get('CHILDREN')) - logging.info("Found top-level device {} of type {} with {} children".format(devAddress, devType, devChildcount)) + logging.info("Found top-level device {} of type {} with {} children".format(devAddress, devType, + devChildcount)) logging.debug(pformat(device)) else: logging.info("Found unsupported top-level device {} of type {}".format(devAddress, devType)) - if devParentType in self.supported_device_types: - logging.debug("Found device {} of type {} in supported parent type {}".format(devAddress, devType, devParentType)) + if not devParentType in self.unsupported_device_types: + logging.debug( + "Found device {} of type {} in supported parent type {}".format(devAddress, devType, devParentType)) logging.debug(pformat(device)) allowFailedChannel = False @@ -216,18 +169,21 @@ def generate_metrics(self): paramset = self.fetch_param_set(devAddress) except xmlrpc.client.Fault: if allowFailedChannel: - logging.debug("Error reading paramset for device {} of type {} in parent type {} (expected)".format( - devAddress, devType, devParentType)) + logging.debug( + "Error reading paramset for device {} of type {} in parent type {} (expected)".format( + devAddress, devType, devParentType)) else: - logging.debug("Error reading paramset for device {} of type {} in parent type {} (unexpected)".format( - devAddress, devType, devParentType)) + logging.debug( + "Error reading paramset for device {} of type {} in parent type {} (unexpected)".format( + devAddress, devType, devParentType)) raise for key in paramsetDescription: paramDesc = paramsetDescription.get(key) paramType = paramDesc.get('TYPE') if paramType in ['FLOAT', 'INTEGER', 'BOOL']: - self.process_single_value(devAddress, devType, devParentAddress, devParentType, paramType, key, paramset.get(key)) + self.process_single_value(devAddress, devType, devParentAddress, devParentType, paramType, + key, paramset.get(key)) elif paramType == 'ENUM': logging.debug("Found {}: desc: {} key: {}".format(paramType, paramDesc, paramset.get(key))) self.process_enum(devAddress, devType, devParentAddress, devParentType, @@ -235,7 +191,8 @@ def generate_metrics(self): else: # ATM Unsupported like HEATING_CONTROL_HMIP.PARTY_TIME_START, # HEATING_CONTROL_HMIP.PARTY_TIME_END, COMBINED_PARAMETER or ACTION - logging.debug("Unknown paramType {}, desc: {}, key: {}".format(paramType, paramDesc, paramset.get(key))) + logging.debug("Unknown paramType {}, desc: {}, key: {}".format(paramType, paramDesc, + paramset.get(key))) if paramset: logging.debug("ParamsetDescription for {}".format(devAddress)) @@ -269,14 +226,15 @@ def is_default_device_address(self, deviceAddress): return re.match("^[0-9a-f]{14}:[0-9]+$", deviceAddress, re.IGNORECASE) def resolve_mapped_name(self, deviceAddress, parentDeviceAddress): - if deviceAddress in self.mapped_names and not self.is_default_device_address(deviceAddress): + if deviceAddress in self.mapped_names: # and not self.is_default_device_address(deviceAddress): return self.mapped_names[deviceAddress] elif parentDeviceAddress in self.mapped_names: return self.mapped_names[parentDeviceAddress] else: return deviceAddress - def process_single_value(self, deviceAddress, deviceType, parentDeviceAddress, parentDeviceType, paramType, key, value): + def process_single_value(self, deviceAddress, deviceType, parentDeviceAddress, parentDeviceType, paramType, key, + value): logging.debug("Found {} param {} with value {}".format(paramType, key, value)) if value == '' or value is None: @@ -285,7 +243,9 @@ def process_single_value(self, deviceAddress, deviceType, parentDeviceAddress, p gaugename = key.lower() if not self.metrics.get(gaugename): self.metrics[gaugename] = Gauge(gaugename, 'Metrics for ' + key, labelnames=['ccu', 'device', 'device_type', - 'parent_device_type', 'mapped_name'], namespace=self.METRICS_NAMESPACE) + 'parent_device_type', + 'mapped_name'], + namespace=self.METRICS_NAMESPACE) gauge = self.metrics.get(gaugename) gauge.labels( ccu=self.ccu_host, @@ -304,7 +264,10 @@ def process_enum(self, deviceAddress, deviceType, parentDeviceAddress, parentDev if not self.metrics.get(gaugename): self.metrics[gaugename] = Enum(gaugename, 'Metrics for ' + key, states=istates, labelnames=['ccu', 'device', - 'device_type', 'parent_device_type', 'mapped_name'], namespace=self.METRICS_NAMESPACE) + 'device_type', + 'parent_device_type', + 'mapped_name'], + namespace=self.METRICS_NAMESPACE) gauge = self.metrics.get(gaugename) mapped_name_v = self.resolve_mapped_name(deviceAddress, parentDeviceAddress) state = istates[int(value)] @@ -335,8 +298,15 @@ def read_mapped_names(self): string chId; foreach(chId, device.Channels()) { var ch=dom.GetObject(chId); - WriteLine("C\t" # ch.Address() # "\t" # ch.Name() # "\t" # chId); - } + var chNumber = ch.Name().Substr(ch.Name().Length() - 1); + var chNamePrefix3 = ch.Name().Substr(0,3); + var chNamePrefix5 = ch.Name().Substr(0,5); + if ((chNamePrefix3 == "HM-") || (chNamePrefix3 == "Hm-") || (chNamePrefix5 == "HMIP-") || (chNamePrefix5 == "HmIP-")){ + WriteLine("C\t" # ch.Address() # "\t" # device.Name() # ":" # chNumber # "\t" # chId); + }else{ + WriteLine("C\t" # ch.Address() # "\t" # ch.Name() # "\t" # chId); + } + } } } } @@ -368,19 +338,12 @@ class _ThreadingSimpleServer(ThreadingMixIn, HTTPServer): """Thread per request HTTP server.""" -def start_http_server(port, addr='', registry=core.REGISTRY): - """Starts an HTTP server for prometheus metrics as a daemon thread""" - httpd = _ThreadingSimpleServer((addr, port), MetricsHandler.factory(registry)) - thread = threading.Thread(target=httpd.serve_forever) - thread.daemon = False - thread.start() - - if __name__ == '__main__': PARSER = argparse.ArgumentParser() PARSER.add_argument("--ccu_host", help="The hostname of the ccu instance", required=True) - PARSER.add_argument("--ccu_port", help="The port for the xmlrpc service (2001 for BidcosRF, 2010 for HmIP)", default=2010) + PARSER.add_argument("--ccu_port", help="The port for the xmlrpc service (2001 for BidcosRF, 2010 for HmIP)", + default=2010) PARSER.add_argument("--ccu_user", help="The username for the CCU (if authentication is enabled)") PARSER.add_argument("--ccu_pass", help="The password for the CCU (if authentication is enabled)") PARSER.add_argument("--interval", help="The interval between two gathering runs in seconds", default=60) @@ -390,7 +353,9 @@ def start_http_server(port, addr='', registry=core.REGISTRY): PARSER.add_argument("--debug", action="store_true") PARSER.add_argument("--dump_devices", help="Do not start exporter, just dump device list", action="store_true") PARSER.add_argument("--dump_parameters", help="Do not start exporter, just dump device parameters of given device") - PARSER.add_argument("--dump_device_names", help="Do not start exporter, just dump device names", action="store_true") + PARSER.add_argument("--dump_device_names", help="Do not start exporter, just dump device names", + action="store_true") + PARSER.add_argument("--dump_sysvars", help="Do not start exporter, just dump system variables", action="store_true") ARGS = PARSER.parse_args() if ARGS.debug: @@ -402,7 +367,8 @@ def start_http_server(port, addr='', registry=core.REGISTRY): if ARGS.ccu_user and ARGS.ccu_pass: auth = (ARGS.ccu_user, ARGS.ccu_pass) - PROCESSOR = HomematicMetricsProcessor(ARGS.ccu_host, ARGS.ccu_port, auth, ARGS.interval, ARGS.namereload, ARGS.config_file) + PROCESSOR = HomematicMetricsProcessor(ARGS.ccu_host, ARGS.ccu_port, auth, ARGS.interval, ARGS.namereload, + ARGS.config_file) if ARGS.dump_devices: print(pformat(PROCESSOR.fetch_devices_list())) @@ -418,3 +384,5 @@ def start_http_server(port, addr='', registry=core.REGISTRY): # Start up the server to expose the metrics. logging.info("Exposing metrics on port {}".format(ARGS.port)) start_http_server(int(ARGS.port)) + # Wait until the main loop terminates + PROCESSOR.join()