RESTful sensor json help - Australian tide predictions (BOM)

Hi all - good you have been able to resolve the issue.

I just used the user agent from my work pc (windows 11 using chrome)

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36

Anyone having issues accessing this now?

I’ve been accessing this data using command_line: with success until recently. Before I move over to rest, I just want to check that it’s still working for others.

update - got it working with rest:

Adding my thanks to those who’ve alredy worked this through in the thread. My minor variation on a theme is to switch to timestamps as device class to get the automatic rendering as follows:

rest:
  # BOM Tides
  - resource: "https://www.bom.gov.au/australia/tides/scripts/getNextTides.php?aac=TAS_TP003&offset=false&tz=Australia%2FHobart"
    scan_interval: 21600  # 6 hours
    method: GET
    headers:
      User-Agent: Mozilla/5.0
      Accept: application/json
    sensor:
      - name: Tide Sensor Last updated
        value_template: "{{ value_json.results.current_time | timestamp_local }}"
        device_class: timestamp
      - name: Hobart High Tide Time
        value_template: "{{ value_json.results.next_high.time | timestamp_local }}"
        device_class: timestamp
      - name: Hobart High Tide Height
        value_template: "{{ value_json.results.next_high.height }}"
        device_class: distance
        unit_of_measurement: m
      - name: Hobart Low Tide Time
        value_template: "{{ value_json.results.next_low.time | timestamp_local }}"
        device_class: timestamp
      - name: Hobart Low Tide Height
        value_template: "{{ value_json.results.next_low.height }}"
        device_class: distance
        unit_of_measurement: m

which I then threw into an entity card and added the ‘state’ as secondary objects to the times so I have both the actual time of the next low/high/last updated as well as the relative time to now


This working post BOM-Website refresh with a minimal user-agent

(What is it that most of the people working in this thread seem to be in Tassie :slight_smile: )

I just tried playing with your code - by adding it into configuration.yaml & changing the BOM code for my location. Unfortunately, I get “Unknown” as the rest.

Got it to work with further examples above, after spending an hour or so going down a vibe coding cesspit.

Question on the tidal data - seems like the BOM tidal information is pretty limited, only 3-4 locations in Sydney for example? The tidal data is out by an hour for high-tide and 30-minutes low-tide (I’m downstream of Botany Bay which is closest data point BOM provides). But I see WillyWeather has more accurate locational data, shame about the paid API.

I’ve come back to this to redo it. I decided to try WillyWeather as it has more locational data and the 5000 free pulls will probably last me a lifetime with him I implemented it.

Through a pyscript, I did a 365 day API pull, located it in a JSON file, then a second function to work out next high and low tide every 15 mins. It’s not super accurate (due to not wanting to pay for API pulls), you could be more accurate but for my purposes this was good enough.

On the dashboard front:
• apexcharts-card — draws the actual curve. Fed from a short list of upcoming (and one past, so the line connects smoothly through “now”) tide events, interpolated with a cosine smoothing function rather than straight lines between points, so it actually looks like a tide instead of a zigzag.
• stack-in-card — merges the chart and a details panel (next high/low, last synced, days of data remaining) into one visual block instead of two separate boxes.
• card-mod — for shrinking/dimming the details panel so it reads as secondary info under the main chart.

Tide Dashboard — Full Code (WillyWeather only)

Location details replaced with placeholders — swap in your own before use.

  • YOUR_LOCATION_ID — your WillyWeather location ID (find via https://api.willyweather.com.au/v2/YOUR_API_KEY/search.json?query=YOUR_SUBURB)
  • Entity/file names below use a generic tide_ prefix rather than a place name

1. secrets.yaml

willyweather_api_key: "YOUR_API_KEY_HERE"

2. pyscript — /config/pyscript/tide_sync.py

import requests
import json
import yaml
from datetime import datetime

WW_LOCATION_ID = "YOUR_LOCATION_ID"
DATA_FILE = "/config/pyscript/tide_data.json"


@pyscript_compile
def _read_secrets():
    with open("/config/secrets.yaml") as f:
        return yaml.safe_load(f)


@pyscript_compile
def _fetch_tide_data(api_key, location_id):
    url = f"https://api.willyweather.com.au/v2/{api_key}/locations/{location_id}/weather.json"
    params = {"forecasts": "tides", "days": 365}
    response = requests.get(url, params=params, timeout=30)
    response.raise_for_status()
    return response.json()


@pyscript_compile
def _write_data_file(data, path):
    with open(path, "w") as f:
        json.dump(data, f)


@pyscript_compile
def _read_data_file(path):
    with open(path) as f:
        return json.load(f)


@service
def tide_sync():
    """Pull a full year of tide predictions from WillyWeather and store locally."""
    secrets = task.executor(_read_secrets)
    api_key = secrets["willyweather_api_key"]

    data = task.executor(_fetch_tide_data, api_key, WW_LOCATION_ID)

    task.executor(_write_data_file, data, DATA_FILE)

    entry_count = 0
    for day in data["forecasts"]["tides"]["days"]:
        entry_count += len(day["entries"])

    now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    pyscript.tide_sync_status = now_str
    state.setattr("pyscript.tide_sync_status.entry_count", entry_count)
    state.setattr("pyscript.tide_sync_status.issue_date_time", data["forecasts"]["tides"]["issueDateTime"])

    log.info(f"Tide sync complete: {entry_count} entries saved.")

    tide_refresh()


@service
def tide_refresh():
    """Recalculate the next high/low, an upcoming-events list, and days of data remaining. No API call."""
    try:
        data = task.executor(_read_data_file, DATA_FILE)
    except FileNotFoundError:
        log.warning("No stored tide data yet — run tide_sync first.")
        return

    now = datetime.now()

    all_entries = []
    for day in data["forecasts"]["tides"]["days"]:
        for entry in day["entries"]:
            all_entries.append(entry)

    next_high = None
    next_low = None
    next_high_time = None
    next_low_time = None
    upcoming = []
    last_past_entry = None

    for entry in all_entries:
        entry_time = datetime.strptime(entry["dateTime"], "%Y-%m-%d %H:%M:%S")

        if entry_time <= now:
            last_past_entry = entry
            continue

        if next_high is None and entry["type"] == "high":
            next_high = entry
            next_high_time = entry_time
        if next_low is None and entry["type"] == "low":
            next_low = entry
            next_low_time = entry_time

        if len(upcoming) < 6:
            upcoming.append({
                "dateTime": entry["dateTime"],
                "height": entry["height"],
                "type": entry["type"],
            })

        if next_high and next_low and len(upcoming) >= 6:
            break

    # Prepend the last past event so the chart curve connects smoothly through "now"
    if last_past_entry:
        upcoming.insert(0, {
            "dateTime": last_past_entry["dateTime"],
            "height": last_past_entry["height"],
            "type": last_past_entry["type"],
        })

    # How many days of stored data are left before the next annual sync is needed
    last_entry_time = datetime.strptime(all_entries[-1]["dateTime"], "%Y-%m-%d %H:%M:%S")
    days_remaining = (last_entry_time - now).days
    pyscript.tide_days_remaining = f"{days_remaining} days"

    if next_high:
        pyscript.tide_next_high = next_high_time.strftime("%I:%M %p")
        state.setattr("pyscript.tide_next_high.height", next_high["height"])
        state.setattr("pyscript.tide_next_high.timestamp", next_high["dateTime"])

    if next_low:
        pyscript.tide_next_low = next_low_time.strftime("%I:%M %p")
        state.setattr("pyscript.tide_next_low.height", next_low["height"])
        state.setattr("pyscript.tide_next_low.timestamp", next_low["dateTime"])

    pyscript.tide_upcoming = len(upcoming)
    state.setattr("pyscript.tide_upcoming.events", upcoming)

3. Automations — automations.yaml

- id: tide_annual_sync
  alias: "Tide - Annual Sync"
  description: "Pulls a fresh year of tide predictions once a year, self-healing if a run is missed"
  trigger:
    - platform: time
      at: "03:00:00"
    - platform: homeassistant
      event: start
  condition:
    - condition: template
      value_template: >
        {% set last = states('pyscript.tide_sync_status') %}
        {{ last in ['unknown', 'unavailable', 'none', ''] or
           (now() - strptime(last, '%Y-%m-%d %H:%M:%S')).days > 350 }}
  action:
    - service: pyscript.tide_sync
  mode: single

- id: tide_refresh
  alias: "Tide - Refresh Next Events"
  description: "Recalculates next high/low from locally stored data every 15 minutes, no API call"
  trigger:
    - platform: time_pattern
      minutes: "/15"
  action:
    - service: pyscript.tide_refresh
  mode: single

4. Dashboard card — merged chart + details

Requires three HACS frontend cards: apexcharts-card, stack-in-card, card-mod.

type: custom:stack-in-card
cards:
  - type: markdown
    content: "## Tide"

  - type: custom:apexcharts-card
    header:
      show: false
    graph_span: 24h
    span:
      start: hour
      offset: "-6h"
    now:
      show: true
      label: Now
      color: "#f2a541"
    yaxis:
      - min: 0
        max: 2.2
        decimals: 1
    apex_config:
      chart:
        toolbar:
          show: false
      stroke:
        curve: smooth
        width: 3
      fill:
        type: gradient
        gradient:
          shadeIntensity: 1
          opacityFrom: 0.35
          opacityTo: 0.02
    series:
      - entity: pyscript.tide_upcoming
        name: Tide Height
        color: "#5b9bd5"
        unit: m
        type: area
        data_generator: |
          const events = entity.attributes.events;
          const points = [];
          const stepMinutes = 15;
          for (let i = 0; i < events.length - 1; i++) {
            const t0 = new Date(events[i].dateTime.replace(' ', 'T')).getTime();
            const t1 = new Date(events[i + 1].dateTime.replace(' ', 'T')).getTime();
            const h0 = events[i].height;
            const h1 = events[i + 1].height;
            const steps = Math.round((t1 - t0) / (stepMinutes * 60000));
            for (let s = 0; s <= steps; s++) {
              const frac = s / steps;
              const smoothFrac = (1 - Math.cos(frac * Math.PI)) / 2;
              const height = h0 + (h1 - h0) * smoothFrac;
              const t = t0 + (t1 - t0) * frac;
              points.push([t, Math.round(height * 100) / 100]);
            }
          }
          return points;

  - type: entities
    show_header_toggle: false
    entities:
      - entity: pyscript.tide_next_high
        name: Next High
        icon: mdi:wave-arrow-up
      - entity: pyscript.tide_next_low
        name: Next Low
        icon: mdi:wave-arrow-down
      - entity: pyscript.tide_sync_status
        name: Last Synced
        icon: mdi:cloud-sync
      - entity: pyscript.tide_days_remaining
        name: Data Valid For
        icon: mdi:calendar-clock
    card_mod:
      style: |
        ha-card {
          box-shadow: none;
          background: transparent;
        }
        .card-content {
          padding-top: 0px;
        }
        hui-generic-entity-row $: |
          state-badge {
            --mdc-icon-size: 16px;
            width: 24px;
            height: 24px;
          }
          .info {
            font-size: 0.78em;
            opacity: 0.75;
          }
          .info .secondary {
            font-size: 0.7em;
          }
          .state {
            font-size: 0.78em;
            opacity: 0.75;
          }