From b24b8a1e4dbbbbfaa310acd9fdff7c3e65267c15 Mon Sep 17 00:00:00 2001 From: "Ethan L. Miller" Date: Thu, 17 Jul 2025 11:02:26 -0700 Subject: [PATCH] Initial (working) version of waterconditions.py --- waterconditions.py | 201 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100755 waterconditions.py diff --git a/waterconditions.py b/waterconditions.py new file mode 100755 index 0000000..bd9c80c --- /dev/null +++ b/waterconditions.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# +# Description: +# +# Simple web server that retrieves data from waterdata.usgs.gov +# and outputs it in JSON format for use in Home Assistant. +# Designed to be used as a rest template. +# +# Requirements: +# - Python 3.9+ +# - requests +# +# Usage/installation: +# +# Takes a single argument: the 8-digit code identifying the station. +# This is part of the URL you'd use to view the information on the web, +# and is listed on the web page. For example, +# https://waterdata.usgs.gov/monitoring-location/14339000/ +# is for measurement station 14339000. +# The web page title is: +# Rogue River at Dodge Bridge, Near Eagle Point, OR - 14339000 +# This means that the command line would be: +# /path/to/binary/riverconditions.py 14339000 +# +# +# Update the first line of this script to be the same python3 executable as +# your Home Assistant instance uses. +# +# To use the integration, add one or more river or lake sections to the sensor: section +# of your configuration.yaml file. +# ------------------------ +# - name: rogue_river_curr +# platform: rest +# resource: 'http://192.168.1.4:8999/river-14339000' +# scan_interval: 1800 +# json_attributes: +# - data +# value_template: 'Rogue River status at Dodge Bridge' +# - platform: template +# sensors: +# river_temp: +# friendly_name: "River temperature" +# device_class: temperature +# value_template: '{{ state_attr("sensor.rogue_river_curr", "data")["watertemp"] | round(0) }}' +# river_flow: +# friendly_name: "River flow rate" +# device_class: volume_flow_rate +# value_template: '{{ state_attr("sensor.rogue_river_curr", "data")["flow"] | round(0) }}' +# river_height: +# friendly_name: "River height" +# device_class: distance +# value_template: '{{ state_attr("sensor.rogue_river_curr", "data")["height"] | round(1) }}' +# - name: lostcreek_lake_curr +# platform: rest +# resource: 'http://192.168.1.4:8999/lake-14335040' +# scan_interval: 1800 +# json_attributes: +# - data +# value_template: 'Lost Creek Lake status' +# - platform: template +# sensors: +# lake_level: +# friendly_name: "Lake level" +# device_class: distance +# ------------------------ +# Values returned by the script are in native units, which means: +# * level: feet above sea level (lake) +# * flow: cubic feet per second (river) +# * watertemp: degrees Celsius (river) +# * height: feet (river, lake) +# Note that lakes may sometimes have both height and level. Height is a relative measurement, +# while level is an absolute (feet above sea level). It should always be the case +# that level-height for a lake is a constant (the zero point for the gauge). +# +# The URL should refer to the server and port on which you're running this script. +# The path for the URL must be either "lake-" or "river-" followed by the 8 digit number corresponding +# to the water sensor you want to query. +# You can find water sensors at https://waterdata.usgs.gov. +# +# You can use a regular Web browser to connect to this script; the page returned will contain the +# current values for your sensor in JSON format. This may be helpful in debugging your URL. +# +# You can use any value you want for name, but it must match the sensor specified in the value template. +# Similarly, you can name your river sensors anything you want. +# +# Scan interval should be relatively long, since the values aren't updated +# frequently. Minimum interval should be 600 seconds (every 10 minutes). +# However, since scan_interval doesn't always work, this web server will cache the retrieved values for you. +# It'll only query the USGS server if the retrieved value is at least request_interval seconds old. +# The default for this is 599, so the USGS server is only queried every 10 minutes. This keeps the +# load on the USGS server low, and prevents the USGS from banning you. +# +#========================================================================== +# Copyright 2025 Ethan L. Miller (code@ethanmiller.us) +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import sys,re,time +import json +import requests +from http.server import * + +request_interval = 599 +global listen_port +listen_port = 8999 +mappings = { + 'river': { + 'temperature': 'watertemp', + 'streamflow': 'flow', + 'height': 'height', + }, + 'lake': { + 'surface elevation': 'level', + 'height': 'height', + }, +} + +cached_requests = dict() + +def get_conditions (station, station_type = 'river', n_tries = 4): + cur_time = time.time() + req = None + new_req = False + if station in cached_requests: + (req, req_time) = cached_requests[station] + if cur_time - req_time > request_interval: + req = None + else: + time_delta = cur_time - req_time + print (f'Reusing request for {station} {time_delta} seconds old') + if not req: + for i in range(n_tries): + try: + url = f'https://waterservices.usgs.gov/nwis/iv/?format=json&sites={station}&siteStatus=all' + req = requests.get (url, timeout=3) + if req.ok: + new_req = True + break + except: + req = None + result = dict() + mp = mappings[station_type] + if req and req.ok: + j = req.json() + for v in j['value']['timeSeries']: + variable_name = v['variable']['variableName'].lower() + for k in mp.keys(): + if k in variable_name: + result[mp[k]] = float(v['values'][0]['value'][0]['value']) + if new_req: + cached_requests[station] = (req, cur_time) + return result + +class WaterConditionsHandler(BaseHTTPRequestHandler): + def do_GET (self): + response_code = 404 + data = 'Not found' + try: + m = re.search ('/(river|lake)-(\d+)', self.path) + station_type = m.group (1) + station = m.group (2) + result = get_conditions (station, station_type) + if result: + response_code = 200 + data = json.dumps({'data': result}) + else: + raise + except: + response_code = 404 + self.send_response (response_code) + self.send_header('content-type', 'text/plain') + self.end_headers () + self.wfile.write (data.encode()) + +if __name__ == '__main__': + if len(sys.argv) > 1: + listen_port = int(sys.argv[1]) + # print (get_conditions ('14339000', 'river')) + port = HTTPServer (('', listen_port), WaterConditionsHandler) + port.serve_forever () +