Skip to content

Commit

Permalink
Add zont_prom_exporter and a couple of examples (#3)
Browse files Browse the repository at this point in the history
* Add zont_prom_exporter, minor refactoring

* fix: make python 3.8/3.9 linters happier
  • Loading branch information
defanator authored Oct 14, 2024
1 parent 30fee58 commit 8098936
Show file tree
Hide file tree
Showing 11 changed files with 697 additions and 200 deletions.
27 changes: 17 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,36 @@ show-var-%:

show-env: $(addprefix show-var-, $(SHOW_ENV_VARS)) ## Show environment details

.PHONY: src/zont_api/version.py
src/zont_api/version.py:
@printf "%s\n" "$${__VERSION_CONTENT__}" >$@

.PHONY: version
version: src/zont_api/version.py
@printf "%s\n" "$(VERSION)" >VERSION
VERSION: src/zont_api/version.py
@printf "%s\n" "$(VERSION)" >$@

build: version ## Build a module with python -m build
$(TOPDIR)/dist/zont_api-$(VERSION)-py3-none-any.whl: VERSION
tox run -e build

test: version ## Run tests
build: $(TOPDIR)/dist/zont_api-$(VERSION)-py3-none-any.whl ## Build a module with python -m build

test: VERSION ## Run tests
tox run

lint: version ## Run linters
lint: VERSION ## Run linters
tox run -e lint

fmt: version ## Run formatters
fmt: VERSION ## Run formatters
tox run -e fmt

venv: version ## Create virtualenv
venv: VERSION ## Create virtualenv
tox devenv --list-dependencies .venv

image: build ## Build container image with zont_prom_exporter
docker build \
-f $(TOPDIR)/examples/zont_prom_exporter/Dockerfile \
--build-arg ZONT_API_VERSION=$(VERSION) \
-t zont_prom_exporter:$(VERSION) \
$(TOPDIR)

clean: ## Clean up
find $(TOPDIR)/ -type f -name "*.pyc" -delete
find $(TOPDIR)/ -type f -name "*.pyo" -delete
Expand All @@ -77,4 +84,4 @@ clean: ## Clean up
rm -rf $(TOPDIR)/htmlcov-py*
rm -f $(TOPDIR)/src/zont_api/version.py $(TOPDIR)/VERSION

.PHONY: build test lint fmt venv clean
.PHONY: test lint fmt venv clean
21 changes: 21 additions & 0 deletions examples/dump_data/dump_devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env python3

import sys
import json
from zont_api import ZontAPI


def main():
zapi = ZontAPI()

device_data = []
devices = zapi.get_devices()

for device in devices:
device_data.append(device.data)

print(json.dumps(device_data, ensure_ascii=False))


if __name__ == "__main__":
sys.exit(main())
23 changes: 23 additions & 0 deletions examples/dump_data/dump_sensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env python3

import sys
import json
from zont_api import ZontAPI


def main():
zapi = ZontAPI()

sensors_data = []
devices = zapi.get_devices()

for device in devices:
sensors_data.extend(device.get_analog_inputs())
sensors_data.extend(device.get_analog_temperature_sensors())
sensors_data.extend(device.get_boiler_adapters())

print(json.dumps(sensors_data, ensure_ascii=False))


if __name__ == "__main__":
sys.exit(main())
15 changes: 15 additions & 0 deletions examples/zont_prom_exporter/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.12-slim

ARG ZONT_API_VERSION

WORKDIR /app

COPY examples/zont_prom_exporter/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY dist/zont_api-${ZONT_API_VERSION}-py3-none-any.whl /tmp
RUN pip install /tmp/zont_api-${ZONT_API_VERSION}-py3-none-any.whl

COPY examples/zont_prom_exporter/zont_prom_exporter.py .

CMD [ "python", "./zont_prom_exporter.py" ]
19 changes: 19 additions & 0 deletions examples/zont_prom_exporter/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
services:
zont_prom_exporter:
container_name: zont_prom_exporter
image: zont_prom_exporter:1.0.0
secrets:
- zont_api_client
- zont_api_token
environment:
- PYTHONUNBUFFERED=1
- ZONT_API_CLIENT_FILE=/run/secrets/zont_api_client
- ZONT_API_TOKEN_FILE=/run/secrets/zont_api_token
ports:
- 6000:6000

secrets:
zont_api_client:
file: ~/.env.zont-api-client
zont_api_token:
file: ~/.env.zont-api-token
4 changes: 4 additions & 0 deletions examples/zont_prom_exporter/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
requests>=2.32.3
Flask>=3.0.3
prometheus_client>=0.21.0
APScheduler>=3.10.4
252 changes: 252 additions & 0 deletions examples/zont_prom_exporter/zont_prom_exporter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
#!/usr/bin/env python3

import sys
import logging
from flask import Flask
from apscheduler.schedulers.background import BackgroundScheduler
from prometheus_client import make_wsgi_app, Gauge
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from zont_api import ZontAPI, ZontAPIException

APP_NAME = "zont_prom_exporter"

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s %(threadName)s: %(message)s",
)

logger = logging.getLogger(APP_NAME)

"""
urllib3_log = logging.getLogger('urllib3')
urllib3_log.setLevel(logging.DEBUG)
from http.client import HTTPConnection
HTTPConnection.debuglevel = 1
"""

app = Flask(APP_NAME)

app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {"/metrics": make_wsgi_app()})

# напряжение питания
# input voltage
VOLTAGE = Gauge("zont_input_voltage", "Input voltage", ["device_id", "sensor_id"])

# температура воздуха в помещении
# indoor air temperature
INDOOR_AIR_TEMP = Gauge(
"zont_indoor_air_temp", "Indoor air temperature", ["device_id", "sensor_id"]
)

# уличная температура
# outdoor air temperature
BOILER_OTA_OT = Gauge(
"zont_boiler_outdoor_air_temp",
"Outdoor air temperature",
["device_id", "sensor_id"],
)

# расчетная температура теплоносителя
# heat-transfer fluid design temperature
BOILER_OTA_CS = Gauge(
"zont_boiler_htf_design_temp",
"Heat-transfer fluid design temperature",
["device_id", "sensor_id"],
)

# фактическая температура теплоносителя
# heat-transfer fluid actual temperature
BOILER_OTA_BT = Gauge(
"zont_boiler_htf_actual_temp",
"Heat-transfer fluid actual temperature",
["device_id", "sensor_id"],
)

# расчетная температура ГВС
# DHW design temperature
BOILER_OTA_DS = Gauge(
"zont_boiler_dhw_design_temp",
"Domestic hot water design temperature",
["device_id", "sensor_id"],
)

# фактическая температура ГВС
# DHW actual temperature
BOILER_OTA_DT = Gauge(
"zont_boiler_dhw_actual_temp",
"Domestic hot water actual temperature",
["device_id", "sensor_id"],
)

# уровень модуляции горелки
# burner modulation level
BOILER_OTA_RML = Gauge(
"zont_boiler_rml", "Burner modulation level", ["device_id", "sensor_id"]
)


zapi = None
zdevice = None


def update_metrics() -> bool:
"""
Update Prometheus metrics based on recent data obtained
from Zont API
:return: bool - status of operation
"""
global zdevice

voltage_inputs = zdevice.get_analog_inputs()
temperature_sensors = zdevice.get_analog_temperature_sensors()
boiler_adapters = zdevice.get_boiler_adapters()

z3k = zdevice.data.get("io").get("z3k-state")

if not z3k:
logger.error(
'z3k-state subtree not found for "%s" (%d})', zdevice.name, zdevice.id
)
return False

rc = True

for voltage_input in voltage_inputs:
sensor_id = str(voltage_input.get("id"))
sensor_name = voltage_input.get("name")
try:
VOLTAGE.labels(zdevice.id, sensor_id).set(z3k.get(sensor_id).get("voltage"))
logger.info("%s/%s (%s) updated", zdevice.id, sensor_id, sensor_name)
except Exception as e:
logger.error(
"%s/%s (%s) update failed: %s",
zdevice.id,
sensor_id,
sensor_name,
str(e),
)
rc = False

for temperature_sensor in temperature_sensors:
sensor_id = str(temperature_sensor.get("id"))
sensor_name = temperature_sensor.get("name")
try:
INDOOR_AIR_TEMP.labels(zdevice.id, sensor_id).set(
z3k.get(sensor_id).get("curr_temp")
)
logger.info("%s/%s (%s) updated", zdevice.id, sensor_id, sensor_name)
except Exception as e:
logger(
"%s/%s (%s) update failed: %s",
zdevice.id,
sensor_id,
sensor_name,
str(e),
)
rc = False

for boiler_adapter in boiler_adapters:
adapter_id = str(boiler_adapter.get("id"))
adapter_name = boiler_adapter.get("name")
try:
z3k_ot = z3k.get(adapter_id).get("ot")
BOILER_OTA_OT.labels(zdevice.id, adapter_id).set(z3k_ot.get("ot"))
BOILER_OTA_CS.labels(zdevice.id, adapter_id).set(z3k_ot.get("cs"))
BOILER_OTA_BT.labels(zdevice.id, adapter_id).set(z3k_ot.get("bt"))
BOILER_OTA_DS.labels(zdevice.id, adapter_id).set(z3k_ot.get("ds"))
BOILER_OTA_DT.labels(zdevice.id, adapter_id).set(z3k_ot.get("dt"))
BOILER_OTA_RML.labels(zdevice.id, adapter_id).set(z3k_ot.get("rml"))
logger.info("%s/%s (%s) updated", zdevice.id, adapter_id, adapter_name)
except Exception as e:
logger.error(
"%s/%s (%s) update failed: %s",
zdevice.id,
adapter_id,
adapter_name,
str(e),
)
rc = False

return rc


def initialize_zont_device() -> bool:
"""
Gather initial information on monitored device
:return: bool - status of operation
"""
global zapi, zdevice

try:
zapi = ZontAPI()
zdevice = zapi.get_devices()[0]

except ZontAPIException as e:
logger.error("error while initializing ZontAPI: %s", str(e))
return False

except IndexError:
logger.error("no devices found")
return False

logger.info('found device "%s" (%d)', zdevice.name, zdevice.id)
return True


def update_zont_data():
"""
Auxiliary function that is intended to be called periodically
from a scheduler with a goal of polling fresh device data from
Zont API and update Prometheus metrics
"""
global zdevice

if not zdevice.update_info():
logger.error('failed to update data for "%s" (%d)', zdevice.name, zdevice.id)
return

logger.info(
'refreshed data for "%s" (%d) as of %s (%d seconds ago)',
zdevice.name,
zdevice.id,
zdevice.last_seen.strftime("%Y-%m-%d %H:%M:%S"),
zdevice.last_seen_relative,
)

if not update_metrics():
logger.error('failed to update metrics for "%s" (%d)', zdevice.name, zdevice.id)
return


def main():
"""
Entrypoint function
"""

if not initialize_zont_device():
logger.error("failed to initialize device")
sys.exit(1)

if not update_metrics():
logger.error("failed to do initial metrics update")
sys.exit(1)

scheduler = BackgroundScheduler()
scheduler.add_job(update_zont_data, "interval", seconds=60)
scheduler.start()

app.run(host="0.0.0.0", port=6000)


@app.route("/")
def default():
"""
Placeholder for the default route
"""
return "Try /metrics!\n"


if __name__ == "__main__":
main()
Loading

0 comments on commit 8098936

Please sign in to comment.