#!/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 ()