forked from eosborne-newrelic/pwmon
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpwmon.py
executable file
·370 lines (303 loc) · 12.3 KB
/
pwmon.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
#!/usr/bin/env python3
"""
Push solar and related data to New Relic.
Can be run from the CLI or as a service. If running as a service, set the
environment variable AS_SERVICE to something.
"""
import os
import sys
import time
import enum
import logging
from datetime import datetime as dt
from pprint import pprint as pp
import requests
import tenacity
from dotenv import load_dotenv
from tesla_powerwall.error import APIError
from tesla_powerwall.powerwall import Powerwall
from tesla_powerwall.const import MeterType
from tesla_powerwall.responses import Meter, Battery
# environment variables
# load environment variables from a file if they're there
load_dotenv('env.list', override=False)
# this script expects these environment variables to be set
# New Relic key
INSIGHTS_API_KEY = os.environ.get('INSIGHTS_API_KEY', '')
# Weather lat/long, units, and key
WEATHER_LAT = os.environ.get('WEATHER_LAT', 0)
WEATHER_LON = os.environ.get('WEATHER_LON', 0)
WEATHER_UNITS = os.environ.get('WEATHER_UNITS', 'imperial')
WEATHER_KEY = os.environ.get('WEATHER_KEY', '')
# powerwall username
PW_USER = os.environ.get('PW_USER', '')
# powerwall password
PW_PASS = os.environ.get('PW_PASS', '')
# Am I running as a service? Part of a hack to let me run via CLI.
AS_SERVICE = os.environ.get('AS_SERVICE', '')
# How often does the script poll when run as a service?
POLL_INTERVAL = int(os.environ.get('POLL_INTERVAL', 60))
# powerwall hostname or IP.
# The powerwall's self-signed certificate only responds to
# hostnamnes "powerwall", "teg", or "powerpack", and of course you have to have DNS set up properly.
# IP addresses work, too.
PW_ADDR = os.environ.get("PW_ADDR", 'powerwall')
# Optional Metrics
# Reserve Percent (enabled by default)
# Reserve Percent Available (enabled by default)
# Battery Charge in Wh
# Battery Capacity in Wh
# Grid Status as Gauge
OPT_RESERVE_PCT = os.environ.get('OPT_RESERVE_PCT', True)
OPT_RESERVE_PCT_AVAIL = os.environ.get('OPT_RESERVE_PCT_AVAIL', True)
OPT_BATTERY_CHARGE_WH = os.environ.get('OPT_BATTERY_CHARGE_WH', False)
OPT_BATTERY_CAPACITY_WH = os.environ.get('OPT_BATTERY_CAPACITY_WH', False)
OPT_GRID_STATUS_GAUGE = os.environ.get('OPT_GRID_STATUS_GAUGE', False)
# end environment variables
# constants
# URL to post to
URL = 'https://metric-api.newrelic.com/metric/v1'
# header to go with it
HEADER = {
'Content-Type': 'application/json',
'Api-Key': INSIGHTS_API_KEY,
}
# end constants
# Grid Status Enum for OPT_GRID_STATUS_GAUGE
class GridStatus(enum.IntEnum):
UNKNOWN = 0
CONNECTED = 1
ISLANDED_READY = 2
ISLANDED = 3
TRANSITION_TO_GRID = 4
TRANSITION_TO_ISLAND = 5
def _missing(self, value):
return self.UNKNOWN
# end Grid Status Enum
def get_now():
"""Return the current Unix timestamp in msec."""
return int(time.time() * 1000)
@tenacity.retry(reraise=True,
stop=tenacity.stop_after_attempt(1),
wait=tenacity.wait_random(min=3, max=7))
def post_metrics(data):
"""POST a block of data and headers to a URL."""
response = requests.post(URL, json=[data], headers=HEADER)
status = response.status_code
if status == 202:
return 0
else:
raise Exception(f'return code is {status}')
# tenacity is only really useful for pw
# because the gateway is very slow to respond
# and it has some absurdly low rate limit
@tenacity.retry(reraise=True,
stop=tenacity.stop_after_attempt(7),
wait=tenacity.wait_random(min=3, max=7))
def get_pw():
"""Return a Powerwall connection object."""
pw = Powerwall(PW_ADDR)
loginResult = pw.login(PW_PASS, PW_USER)
return pw
def connect():
"""Return a Powerwall object and its meters."""
pw = get_pw()
return pw, pw.get_meters()
@tenacity.retry(reraise=True,
stop=tenacity.stop_after_attempt(7),
wait=tenacity.wait_random(min=3, max=7))
def get_weather():
"""Return weather for a given lat/lon."""
params = {
'lat': WEATHER_LAT,
'lon': WEATHER_LON,
'appid': WEATHER_KEY,
'units': WEATHER_UNITS,
}
response = requests.get(
url="http://api.openweathermap.org/data/2.5/weather", params=params)
r = response.json()
return r
def get_data():
"""Return powerwall and weather data formatted for submission as New Relic metrics."""
now = get_now()
# ought to do these two in an event loop but weather is so fast it's not
# worth it.
pw, m = connect()
# Get a copy of each meter
batteryMeter = m.get_meter(MeterType.BATTERY)
loadMeter = m.get_meter(MeterType.LOAD)
siteMeter = m.get_meter(MeterType.SITE)
solarMeter = m.get_meter(MeterType.SOLAR)
weather = get_weather()
data = {
"common": {
"timestamp": now,
"interval.ms": POLL_INTERVAL * 1000,
"attributes": {
"app.name": "solar",
"mode": pw.get_operation_mode().name.title().replace('_', ' '),
"status": pw.get_grid_status().name.title().replace('_', ' '),
"poll_timestamp": now,
}
},
"metrics": [],
}
# figure out if the sun is up. This is helpful
# when trying to know how much power to expect from the panels.
weather['sys']['sunrise'] *= 1000
weather['sys']['sunset'] *= 1000
if now > weather['sys']['sunrise'] and now < weather['sys']['sunset']:
is_daytime = 1
else:
is_daytime = 0
metric_data = {
'solar': [
('battery_charge_pct', round(pw.get_charge(), 1)),
('battery.imported', batteryMeter.energy_imported),
('battery.exported', batteryMeter.energy_exported),
('house.imported', loadMeter.energy_imported),
('house.exported', loadMeter.energy_exported),
('grid.imported', siteMeter.energy_imported),
('grid.exported', siteMeter.energy_exported),
('solar.imported', solarMeter.energy_imported),
('solar.exported', solarMeter.energy_exported),
],
'weather': [
('cloud_coverage_pct', weather['clouds']['all']),
('visibility', weather['visibility']),
('temperature', weather['main']['temp']),
('is_daytime', is_daytime),
]
}
# turn stuff into weather.stuff and solar.stuff.
# not very useful for solar because so much of that is bespoke
for k, v_list in metric_data.items():
for pair in v_list:
m_name = pair[0]
try:
m_value = pair[1]
except KeyError:
m_value = 0
m_name = f'{k}.{m_name}'
data['metrics'].append(make_gauge(m_name, m_value))
data['metrics'].extend(make_meter_gauges('solar', solarMeter))
data['metrics'].extend(make_meter_gauges('grid', siteMeter))
# The Load/House meter is inverted (e.g. positive is "to" and negative is "from")
data['metrics'].extend(make_meter_gauges('house', loadMeter, True))
data['metrics'].extend(make_meter_gauges('battery', batteryMeter))
# Add optional metrics
# Reserve Percent (enabled by default)
# Reserve Percent Available (enabled by default)
# Battery Charge in Wh
# Battery Capacity in Wh
# Grid Status
if OPT_RESERVE_PCT:
reserve = make_gauge('solar.reserve_pct',
pw.get_backup_reserve_percentage())
data['metrics'].append(reserve)
if OPT_RESERVE_PCT_AVAIL:
tmp = round(pw.get_charge(), 1)
remaining = make_gauge(
'solar.pct_left_above_reserve', int(
tmp - pw.get_backup_reserve_percentage()))
data['metrics'].append(remaining)
batteries: list[Battery] = []
if OPT_BATTERY_CHARGE_WH or OPT_BATTERY_CAPACITY_WH:
batteries = pw.get_batteries()
if OPT_BATTERY_CHARGE_WH:
tmp = 0
for battery in batteries:
tmp = tmp + battery.energy_remaining
charge_Wh = make_gauge('solar.battery_charge_wh', tmp)
data['metrics'].append(charge_Wh)
if OPT_BATTERY_CAPACITY_WH:
tmp = 0
for battery in batteries:
tmp = tmp + battery.capacity
capacity = make_gauge('solar.battery_capacity_wh', tmp)
data['metrics'].append(capacity)
if OPT_GRID_STATUS_GAUGE:
grid_status = make_gauge(
'solar.grid_status', GridStatus[pw.get_grid_status().name].value)
data['metrics'].append(grid_status)
return data
def make_meter_gauges(name: str, meter: Meter, invertDirection: bool = False, type: str = 'gauge') -> list[dict]:
"""Return a list of gauges for a supplied Meter"""
gauges = [
make_gauge('solar.to_' + name, 0, type),
make_gauge('solar.from_' + name, 0, type)
]
activeGauge = 1 if meter.instant_power > 0 and not invertDirection else 0
gauges[activeGauge]['value'] = abs(meter.instant_power)
return gauges
def make_gauge(name: str, value: int | float, m_type: str = 'gauge') -> dict:
"""Return a dict for use as a gauge."""
return {
'name': name,
'value': value,
'type': m_type
}
def run_from_cli(data):
"""Print data and exit. Useful when running the script from the CLI."""
pp(data, compact=True)
timestamp = data['common']['timestamp']
logger.info('timestamp:\t%s', timestamp)
sys.exit(0)
logging.basicConfig(format='%(asctime)s %(name)s.%(funcName)s %(levelname)s: %(message)s',
datefmt='[%Y-%m-%d %H:%M:%S]', level=logging.INFO)
logger: logging.Logger = logging.getLogger('pwmon')
if __name__ == "__main__":
logger.info('Startup')
try:
# If POLL_INTERVAL is a multiple of a minute, try to start at the beginning of the next minute
if POLL_INTERVAL % 60 == 0 and AS_SERVICE:
wait_time = 60 - time.localtime().tm_sec
logger.info('Found minute intervals, delaying first iteration %s seconds until the start of the next minute', wait_time)
time.sleep(wait_time)
while True:
start = time.time()
try:
data = get_data()
ret = post_metrics(data)
logger.info('Submitted at %s', dt.now())
except APIError as apiEx:
logger.warning(apiEx)
# If this is an HTTP 429, back off immediately for at least 5 minutes
if str(apiEx).find('429: Too Many Requests') > 0:
FIVE_MINUTES = 5 * 60
elapsed = time.time() - start
# Back off for at least 3x POLL_INTERVAL, for a minimum of 5 minutes to allow things to cool down
backoffInterval = POLL_INTERVAL * 3
if backoffInterval < FIVE_MINUTES:
backoffInterval = FIVE_MINUTES
logger.info('Backing off for %s seconds because of HTTP 429.', round(backoffInterval - elapsed, 0))
time.sleep(backoffInterval - elapsed)
# Determine if we need to wait until the start of the minute again
if POLL_INTERVAL % 60 == 0 and AS_SERVICE:
wait_time = 60 - time.localtime().tm_sec
time.sleep(wait_time)
# Reset the start time to coincide with the top of the minute
start = time.time()
except (SystemExit, KeyboardInterrupt) as ex:
logger.info('%s received; shutting down...',
ex.__class__.__name__)
break
except Exception as ex:
logger.warning('Failed to gather data: %s', ex)
logger.exception(ex)
if not AS_SERVICE:
run_from_cli(data)
# Try to position each loop exactly POLL_INTERVAL seconds apart.
# This is most useful when POLL_INTERVAL is an even division of a minute
elapsed = time.time() - start
if elapsed < 0 or elapsed > POLL_INTERVAL:
elapsed = 0
time.sleep(POLL_INTERVAL - elapsed)
except (SystemExit, KeyboardInterrupt) as ex:
logger.info('%s received; shutting down...',
ex.__class__.__name__)
except Exception as ex:
logger.warning('Exception during main loop: %s', ex)
logger.exception(ex)
logger.info('Shutdown')