Skip to content

Commit

Permalink
Init exporter
Browse files Browse the repository at this point in the history
  • Loading branch information
drewburr committed May 24, 2024
1 parent 35dcc52 commit 5cf21d2
Show file tree
Hide file tree
Showing 7 changed files with 459 additions and 0 deletions.
63 changes: 63 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Docker Build/Publish

on:
# schedule:
# - cron: "20 4 * * *"
push:
branches: [main]
# Publish semver tags as releases.
tags: ["*.*.*"]
paths:
- "src/**"
- "poetry.lock"
- "pyProject.toml"
- ".dockerignore"
- "Dockerfile"
pull_request:
branches: [main]

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout repository
uses: actions/checkout@v4

# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
.venv
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.11

WORKDIR /usr/src/app

RUN pip install poetry

COPY poetry.lock pyproject.toml ./
RUN poetry install --no-root --no-cache

COPY ./src ./

CMD ["poetry", "run", "python", "main.py"]
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# geist-pdu-exporter

Prometheus exporter for Geist PDUs
204 changes: 204 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[tool.poetry]
name = "geist-pdu-exporter"
version = "0.1.0"
description = "Prometheus exporter for Geist PDUs"
authors = ["drewburr <[email protected]>"]
license = "Apache-2.0"
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.32.2"
prometheus-client = "^0.20.0"
python-dotenv = "^1.0.1"
xmltodict = "^0.13.0"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
157 changes: 157 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Application exporter"""

import os
import time
from prometheus_client import start_http_server, Gauge, Counter, Enum
import requests
import xml.etree.ElementTree as ET
from xml.etree.ElementTree import Element

# pdu_


class Exporter:
"""
Representation of Prometheus metrics and loop to fetch and transform
application metrics into Prometheus metrics.
"""

def __init__(self, address, port, polling_interval_seconds):
self.address = address
self.port = port
self.polling_interval_seconds = polling_interval_seconds

self.device_labels = ["id", "type"]
self.outlet_labels = ["name", "num", "url"]

self.device_metrics = {
"KWatt-hrs-Total": Gauge(
"pdu_kwh_total", "Total device KWh", self.device_labels
),
"KWatt-hrs-A": Gauge("pdu_kwh", "Device KWh", self.device_labels),
"RealPower-Total": Gauge(
"pdu_realpower_total", "Device RealPower", self.device_labels
),
"RealPower-A": Gauge(
"pdu_realpower", "Device realpower", self.device_labels
),
"Volts-A": Gauge("pdu_volts", "Device voltage", self.device_labels),
"Volt-Pk-A": Gauge(
"pdu_volts_peak", "Device peak voltage", self.device_labels
),
"Amps-A": Gauge("pdu_amps", "Device amperage", self.device_labels),
"Amps-Pk-A": Gauge(
"pdu_amps_peak", "Device peak amperage", self.device_labels
),
"ApPower-A": Gauge(
"pdu_apparent_power", "Device apparent power", self.device_labels
),
"Pwr-Factor%-A": Gauge(
"pdu_power_factor_percent",
"Power Factor Percentage",
self.device_labels,
),
}

self.outlet_metrics = {
"amps": Gauge(
"pdu_outlet_amps",
"Outlet Amperage",
self.outlet_labels + self.device_labels,
),
"kwatthrs": Gauge(
"pdu_outlet_kwh_total",
"Outlet Total KWh",
self.outlet_labels + self.device_labels,
),
"watts": Gauge(
"pdu_outlet_watts",
"Outlet Watts",
self.outlet_labels + self.device_labels,
),
}

self.outlet_status = Enum(
"pdu_outlet_status",
"Outlet status",
self.outlet_labels + self.device_labels,
states=["On", "Off"],
)

def start_export_loop(self):
"""Metrics fetching loop"""

while True:
self.process()
time.sleep(self.polling_interval_seconds)

def process(self):
"""
Generate metrics and publish
"""
root = self.fetch()
devices = root.find("devices")

for device in devices:
self.process_device(device)

def process_device(self, device: Element):
outlets = device.find("outlets")
if not outlets:
return

device_labels = {label: device.attrib[label] for label in self.device_labels}

for child in device:
if not child.tag == "field":
continue

metric = self.device_metrics.get(child.attrib["key"])
if metric:
metric.labels(**device_labels).set(float(child.attrib["value"]))

for outlet in outlets:
self.process_outlet(outlet, device_labels)

def process_outlet(self, outlet: Element, device_labels: dict[str, str]):
outlet_labels = {label: outlet.attrib[label] for label in self.outlet_labels}
outlet_labels = {**outlet_labels, **device_labels}

for attr, metric in self.outlet_metrics.items():
metric.labels(**outlet_labels).set(float(outlet.attrib[attr]))

self.outlet_status.labels(**outlet_labels).state(outlet.attrib["status"])

def fetch(self):
"""
Get metrics from PDU and return
"""
resp = requests.get(url=f"http://{self.address}:{self.port}/data.xml")
root = ET.fromstring(resp.text)
return root


def main():
"""Main entry point"""
from dotenv import load_dotenv

load_dotenv(
override=False
) # load .env into environment variables, without override

PDU_ADDRESS = os.getenv("PDU_ADDRESS")
PDU_PORT = int(os.getenv("PDU_PORT", "80"))
POLLING_INTERVAL_SECONDS = int(os.getenv("POLLING_INTERVAL_SECONDS", "5"))
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "9100"))

exporter = Exporter(
address=PDU_ADDRESS,
port=PDU_PORT,
polling_interval_seconds=POLLING_INTERVAL_SECONDS,
)
start_http_server(LISTEN_PORT)
exporter.start_export_loop()


if __name__ == "__main__":
main()

0 comments on commit 5cf21d2

Please sign in to comment.