Skip to content

Commit a194865

Browse files
authored
Rework sensor configuration into Pydantic models (#66)
* Rework sensor configuration into Pydantic models * Fix linting errors * Update README
1 parent 6659e33 commit a194865

19 files changed

Lines changed: 371 additions & 164 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ An external-facing, FastAPI-based front-end for accessing some Leigh Hackspace d
2222
| `HACKSPACE_PUBLIC_CALENDAR` | `calendar.public_events` | The entity ID of the Home Assistant public calendar |
2323
| `HACKSPACE_MEMBER_CALENDAR` | `calendar.member_events` | The entity ID of the Home Assistant member calendar |
2424
| `SENSORS_PRESSURE_ENABLED` | `False` | Enable pressure sensors |
25+
| `SENSOR_CONFIG_FILE` | `sensors.yaml` | Path to the sensors configuration | |
26+
27+
### Sensor Config File
28+
29+
An example can be found in [`sensors.yaml`](sensors.yaml), and the details of the schema they're using can be found in `hackspaceapi.models.sensors`.
2530

2631
## Endpoints
2732

hackspaceapi/events.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from hackspaceapi import VERSION
1010

11-
from .config import settings
11+
from .models.config import settings
1212
from .services.homeassistant import call_homeassistant
1313

1414
events = APIRouter()

hackspaceapi/models/__init__.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
from pydantic import BaseModel, Field
2-
3-
from hackspaceapi import VERSION
4-
5-
6-
class HealthResponseModel(BaseModel):
7-
health: str = Field(description="State of the API", examples=["ok", "error"])
8-
version: str = Field(description="Version of the API", examples=[VERSION])
1+
# ruff: noqa: F401
2+
from .config import SettingsModel
3+
from .health import HealthResponseModel
4+
from .sensors import SensorSettingsModel
Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1-
from pydantic import Field
1+
from typing import Optional
2+
3+
from pydantic import Field, AnyHttpUrl, HttpUrl, FilePath
24
from pydantic_settings import BaseSettings, SettingsConfigDict
35

6+
from .sensors import SensorSettingsModel
47

5-
class Settings(BaseSettings):
8+
class SettingsModel(BaseSettings):
69
model_config = SettingsConfigDict(env_file=".env")
710

8-
base_url: str = Field(
11+
base_url: AnyHttpUrl = Field(
912
default="http://localhost:8000",
1013
description="URL base where the application will be accessible at",
1114
)
1215

13-
prometheus_instance: str = Field(
16+
prometheus_instance: AnyHttpUrl = Field(
1417
default="http://prometheus:9090",
1518
description="Endpoint URL for the Prometheus instance",
1619
)
1720

18-
homeassistant_instance: str = Field(
21+
homeassistant_instance: AnyHttpUrl = Field(
1922
default="http://homeassistant:8123",
2023
description="Endpoint URL for the Home Assistant instance",
2124
)
@@ -28,12 +31,12 @@ class Settings(BaseSettings):
2831
default="Leigh Hackspace", description="Name of the hackspace"
2932
)
3033

31-
hackspace_logo_url: str = Field(
34+
hackspace_logo_url: HttpUrl = Field(
3235
default="https://raw.githubusercontent.com/leigh-hackspace/logos-graphics-assets/master/logo/rose_logo.svg",
3336
description="URL to the logo for the hackspace",
3437
)
3538

36-
hackspace_website_url: str = Field(
39+
hackspace_website_url: HttpUrl = Field(
3740
default="https://leighhack.org", description="URL to the hackspace's website"
3841
)
3942

@@ -43,7 +46,8 @@ class Settings(BaseSettings):
4346
)
4447

4548
hackspace_osm_node: int = Field(
46-
default=4300807520, description="OpenStreetMap Node ID for the Hackspace's location"
49+
default=4300807520,
50+
description="OpenStreetMap Node ID for the Hackspace's location",
4751
)
4852

4953
hackspace_address_lat: float = Field(
@@ -77,5 +81,11 @@ class Settings(BaseSettings):
7781
default=False, description="Enable pressure sensors"
7882
)
7983

84+
sensor_config_file: Optional[FilePath] = Field(
85+
default="sensors.yaml", description="Path to the sensors configuration"
86+
)
87+
88+
sensor_config: Optional[SensorSettingsModel] = None
89+
8090

81-
settings = Settings()
91+
settings = SettingsModel()

hackspaceapi/models/health.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pydantic import BaseModel, Field
2+
3+
from hackspaceapi import VERSION
4+
5+
6+
class HealthResponseModel(BaseModel):
7+
health: str = Field(description="State of the API", examples=["ok", "error"])
8+
version: str = Field(description="Version of the API", examples=[VERSION])

hackspaceapi/models/sensors.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from typing import List, Optional
2+
3+
import yaml
4+
from pydantic import BaseModel, Field
5+
6+
7+
class HomeassistantSensorModel(BaseModel):
8+
entity: str
9+
name: Optional[str] = None
10+
location: str
11+
unit: Optional[str] = None
12+
13+
14+
class PrometheusSensorModel(BaseModel):
15+
query: str
16+
name: str
17+
location: Optional[str] = None
18+
sensor_type: str
19+
unit: Optional[str] = None
20+
21+
22+
class SensorSettingsModel(BaseModel):
23+
homeassistant: List[HomeassistantSensorModel] = Field(
24+
description="A list of sensors from Home Assistant"
25+
)
26+
prometheus: List[PrometheusSensorModel] = Field(
27+
description="A list of sensors from Prometheus"
28+
)
29+
30+
@staticmethod
31+
def load_from_yaml(filename: str) -> BaseModel:
32+
with open(filename, "r") as fobj:
33+
data = yaml.safe_load(fobj)
34+
return SensorSettingsModel(**data)

hackspaceapi/services/homeassistant.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from cachetools.func import ttl_cache
77
from prometheus_client import Summary
88

9-
from hackspaceapi.config import settings
9+
from hackspaceapi.models.config import settings
1010
from hackspaceapi.services.session import get_requests_session
1111

1212
session = get_requests_session()
@@ -28,7 +28,7 @@ def call_homeassistant(endpoint: str, **params) -> Optional[Iterable]:
2828
"""
2929
Call a Home Assistant API endpoint and return the JSON if successful
3030
"""
31-
url = urljoin(settings.homeassistant_instance, endpoint)
31+
url = urljoin(str(settings.homeassistant_instance), endpoint)
3232
try:
3333
resp = session.get(url, params=params)
3434
if resp.ok:

hackspaceapi/services/prometheus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from cachetools.func import ttl_cache
77
from prometheus_client import Summary
88

9-
from hackspaceapi.config import settings
9+
from hackspaceapi.models.config import settings
1010
from hackspaceapi.services.session import get_requests_session
1111

1212
session = get_requests_session()
@@ -23,7 +23,7 @@ def get_prometheus_metric(query: str) -> Optional[Dict]:
2323
Call the configured Prometheus endpoint with a query, and return the
2424
resulting data if successful.
2525
"""
26-
url = urljoin(settings.prometheus_instance, "/api/v1/query")
26+
url = urljoin(str(settings.prometheus_instance), "/api/v1/query")
2727
try:
2828
resp = session.get(url, params={"query": query})
2929
if resp.ok:

hackspaceapi/spaceapi.py

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,14 @@
55
from cachetools.func import ttl_cache
66
from fastapi import APIRouter
77

8-
from .config import settings
8+
from .models.config import settings
9+
from .models.sensors import SensorSettingsModel
910
from .services.homeassistant import get_entity_state
1011
from .services.prometheus import get_prometheus_metric
1112
from .services.website import get_membership_data
1213

1314
spaceapi = APIRouter()
1415

15-
# Home Assistant Sensors to export to the Space API
16-
# entity_id, override_name
17-
HOMEASSISTANT_SENSORS = (
18-
("sensor.gw_dhcp_leases_online", "WiFi Clients"),
19-
("sensor.bluetooth_proxy_temperature", "Rack 1"),
20-
("sensor.bluetooth_proxy_humidity", "Rack 1"),
21-
("sensor.pi_room_sensor_temperature", "Pi Room"),
22-
("sensor.pi_room_sensor_humidity", "Pi Room"),
23-
("sensor.pi_room_sensor_pressure", "Pi Room"),
24-
("sensor.fabrication_sensor_temperature", "Fabrication"),
25-
("sensor.fabrication_sensor_humidity", "Fabrication"),
26-
("weather.forecast_leigh_hackspace", "Outside"),
27-
("sensor.3d_1_current_state", "3D-1"),
28-
("sensor.3d_2_current_state", "3D-2"),
29-
("sensor.3d_3_current_state", "3D-3"),
30-
)
31-
32-
# Prometheus queries to export to the Space API
33-
# query, name, sensor type
34-
PROMETHEUS_SENSORS = (
35-
("gocardless_members_count{}", "Active Members", "total_member_count"),
36-
)
37-
3816

3917
def get_state() -> dict:
4018
"""
@@ -60,12 +38,17 @@ def get_sensors() -> dict:
6038
SpaceAPI response.
6139
"""
6240
results = {}
63-
for sensor, override_name in HOMEASSISTANT_SENSORS:
64-
data = get_entity_state(sensor)
41+
42+
# Load the sensor config if its not initialized
43+
if not settings.sensor_config:
44+
settings.sensor_config = SensorSettingsModel.load_from_yaml(settings.sensor_config_file)
45+
46+
for sensor in settings.sensor_config.homeassistant:
47+
data = get_entity_state(sensor.entity)
6548
if not data:
6649
logging.warning(
6750
"Call for {0} sensor returned an empty result, skipping".format(
68-
override_name
51+
sensor.entity
6952
)
7053
)
7154
continue
@@ -89,8 +72,8 @@ def get_sensors() -> dict:
8972
results["temperature"].append(
9073
{
9174
"value": value,
92-
"unit": unit_val,
93-
"location": override_name or data["attributes"]["friendly_name"],
75+
"unit": sensor.unit or unit_val,
76+
"location": sensor.location or data["attributes"]["friendly_name"],
9477
"lastchange": int(arrow.get(data["last_changed"]).timestamp()),
9578
}
9679
)
@@ -114,8 +97,8 @@ def get_sensors() -> dict:
11497
results["humidity"].append(
11598
{
11699
"value": value,
117-
"unit": unit_val,
118-
"location": override_name or data["attributes"]["friendly_name"],
100+
"unit": sensor.unit or unit_val,
101+
"location": sensor.location or data["attributes"]["friendly_name"],
119102
"lastchange": int(arrow.get(data["last_changed"]).timestamp()),
120103
}
121104
)
@@ -140,9 +123,8 @@ def get_sensors() -> dict:
140123
results["barometer"].append(
141124
{
142125
"value": value,
143-
"unit": unit_val,
144-
"location": override_name
145-
or data["attributes"]["friendly_name"],
126+
"unit": sensor.unit or unit_val,
127+
"location": sensor.location or data["attributes"]["friendly_name"],
146128
"lastchange": int(arrow.get(data["last_changed"]).timestamp()),
147129
}
148130
)
@@ -163,7 +145,7 @@ def get_sensors() -> dict:
163145
results["network_connections"].append(
164146
{
165147
"value": state,
166-
"location": override_name or data["attributes"]["friendly_name"],
148+
"location": sensor.location or data["attributes"]["friendly_name"],
167149
"lastchange": int(arrow.get(data["last_changed"]).timestamp()),
168150
}
169151
)
@@ -183,29 +165,29 @@ def get_sensors() -> dict:
183165

184166
results["ext_3d_printers"].append(
185167
{
186-
"name": override_name
168+
"name": sensor.name or sensor.location
187169
or data["attributes"]["friendly_name"].split()[0],
188170
"state": state,
189171
"lastchange": int(arrow.get(data["last_changed"]).timestamp()),
190172
}
191173
)
192174

193-
for query, name, sensor_type in PROMETHEUS_SENSORS:
194-
data = get_prometheus_metric(query)
175+
for sensor in settings.sensor_config.prometheus:
176+
data = get_prometheus_metric(sensor.query)
195177
if not data or "result" not in data or len(data["result"]) == 0:
196178
logging.warning(
197-
"Call for {0} sensor returned an empty result, skipping".format(name)
179+
"Call for {0} sensor returned an empty result, skipping".format(sensor.name)
198180
)
199181
continue
200182

201-
if sensor_type not in results:
202-
results[sensor_type] = []
183+
if sensor.sensor_type not in results:
184+
results[sensor.sensor_type] = []
203185

204-
if sensor_type == "total_member_count":
186+
if sensor.sensor_type == "total_member_count":
205187
results["total_member_count"].append(
206188
{
207189
"value": int(data["result"][0]["value"][1]),
208-
"name": name,
190+
"name": sensor.name,
209191
"lastchange": int(data["result"][0]["value"][0]),
210192
}
211193
)
@@ -287,7 +269,7 @@ async def space_json():
287269
},
288270
"calendar": {
289271
"type": "ical",
290-
"url": urljoin(settings.base_url, "/events.ics"),
272+
"url": urljoin(str(settings.base_url), "/events.ics"),
291273
},
292274
},
293275
"links": get_links(),

sensors.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
homeassistant:
2+
- entity: sensor.gw_dhcp_leases_online
3+
location: WiFi Clients
4+
5+
- entity: sensor.bluetooth_proxy_temperature
6+
location: Rack 1
7+
- entity: sensor.bluetooth_proxy_humidity
8+
location: Rack 1
9+
10+
- entity: sensor.pi_room_sensor_temperature
11+
location: Pi Room
12+
- entity: sensor.pi_room_sensor_humidity
13+
location: Pi Room
14+
- entity: sensor.pi_room_sensor_pressure
15+
location: Pi Room
16+
17+
- entity: sensor.fabrication_sensor_temperature
18+
location: Fabrication
19+
- entity: sensor.fabrication_sensor_humidity
20+
location: Fabrication
21+
22+
- entity: weather.forecast_leigh_hackspace
23+
location: Outside
24+
25+
- entity: sensor.3d_1_current_state
26+
location: 3D-1
27+
- entity: sensor.3d_2_current_state
28+
location: 3D-2
29+
- entity: sensor.3d_3_current_state
30+
location: 3D-3
31+
32+
prometheus:
33+
- query: gocardless_members_count{}
34+
name: Active Members
35+
sensor_type: total_member_count

0 commit comments

Comments
 (0)