mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 13:47:35 +00:00
Remove BOM integration because it uses webscraping (#41941)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
ee1b6d3195
commit
39ba0fc7ee
@ -98,9 +98,6 @@ omit =
|
|||||||
homeassistant/components/bme680/sensor.py
|
homeassistant/components/bme680/sensor.py
|
||||||
homeassistant/components/bmp280/sensor.py
|
homeassistant/components/bmp280/sensor.py
|
||||||
homeassistant/components/bmw_connected_drive/*
|
homeassistant/components/bmw_connected_drive/*
|
||||||
homeassistant/components/bom/camera.py
|
|
||||||
homeassistant/components/bom/sensor.py
|
|
||||||
homeassistant/components/bom/weather.py
|
|
||||||
homeassistant/components/braviatv/__init__.py
|
homeassistant/components/braviatv/__init__.py
|
||||||
homeassistant/components/braviatv/const.py
|
homeassistant/components/braviatv/const.py
|
||||||
homeassistant/components/braviatv/media_player.py
|
homeassistant/components/braviatv/media_player.py
|
||||||
|
@ -60,7 +60,6 @@ homeassistant/components/blebox/* @gadgetmobile
|
|||||||
homeassistant/components/blink/* @fronzbot
|
homeassistant/components/blink/* @fronzbot
|
||||||
homeassistant/components/bmp280/* @belidzs
|
homeassistant/components/bmp280/* @belidzs
|
||||||
homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe
|
homeassistant/components/bmw_connected_drive/* @gerard33 @rikroe
|
||||||
homeassistant/components/bom/* @maddenp
|
|
||||||
homeassistant/components/bond/* @prystupa
|
homeassistant/components/bond/* @prystupa
|
||||||
homeassistant/components/braviatv/* @bieniu
|
homeassistant/components/braviatv/* @bieniu
|
||||||
homeassistant/components/broadlink/* @danielhiversen @felipediel
|
homeassistant/components/broadlink/* @danielhiversen @felipediel
|
||||||
|
@ -1 +0,0 @@
|
|||||||
"""The bom component."""
|
|
@ -1,132 +0,0 @@
|
|||||||
"""Provide animated GIF loops of BOM radar imagery."""
|
|
||||||
from bomradarloop import BOMRadarLoop
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
|
||||||
from homeassistant.const import CONF_ID, CONF_NAME
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
|
|
||||||
CONF_DELTA = "delta"
|
|
||||||
CONF_FRAMES = "frames"
|
|
||||||
CONF_LOCATION = "location"
|
|
||||||
CONF_OUTFILE = "filename"
|
|
||||||
|
|
||||||
LOCATIONS = [
|
|
||||||
"Adelaide",
|
|
||||||
"Albany",
|
|
||||||
"AliceSprings",
|
|
||||||
"Bairnsdale",
|
|
||||||
"Bowen",
|
|
||||||
"Brisbane",
|
|
||||||
"Broome",
|
|
||||||
"Cairns",
|
|
||||||
"Canberra",
|
|
||||||
"Carnarvon",
|
|
||||||
"Ceduna",
|
|
||||||
"Dampier",
|
|
||||||
"Darwin",
|
|
||||||
"Emerald",
|
|
||||||
"Esperance",
|
|
||||||
"Geraldton",
|
|
||||||
"Giles",
|
|
||||||
"Gladstone",
|
|
||||||
"Gove",
|
|
||||||
"Grafton",
|
|
||||||
"Gympie",
|
|
||||||
"HallsCreek",
|
|
||||||
"Hobart",
|
|
||||||
"Kalgoorlie",
|
|
||||||
"Katherine",
|
|
||||||
"Learmonth",
|
|
||||||
"Longreach",
|
|
||||||
"Mackay",
|
|
||||||
"Marburg",
|
|
||||||
"Melbourne",
|
|
||||||
"Mildura",
|
|
||||||
"Moree",
|
|
||||||
"MorningtonIs",
|
|
||||||
"MountIsa",
|
|
||||||
"MtGambier",
|
|
||||||
"Namoi",
|
|
||||||
"Newcastle",
|
|
||||||
"Newdegate",
|
|
||||||
"NorfolkIs",
|
|
||||||
"NWTasmania",
|
|
||||||
"Perth",
|
|
||||||
"PortHedland",
|
|
||||||
"Rainbow",
|
|
||||||
"SellicksHill",
|
|
||||||
"SouthDoodlakine",
|
|
||||||
"Sydney",
|
|
||||||
"Townsville",
|
|
||||||
"WaggaWagga",
|
|
||||||
"Warrego",
|
|
||||||
"Warruwi",
|
|
||||||
"Watheroo",
|
|
||||||
"Weipa",
|
|
||||||
"WillisIs",
|
|
||||||
"Wollongong",
|
|
||||||
"Woomera",
|
|
||||||
"Wyndham",
|
|
||||||
"Yarrawonga",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_schema(config):
|
|
||||||
if config.get(CONF_LOCATION) is None:
|
|
||||||
if not all(config.get(x) for x in (CONF_ID, CONF_DELTA, CONF_FRAMES)):
|
|
||||||
raise vol.Invalid(
|
|
||||||
f"Specify '{CONF_ID}', '{CONF_DELTA}' and '{CONF_FRAMES}' when '{CONF_LOCATION}' is unspecified"
|
|
||||||
)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
LOCATIONS_MSG = f"Set '{CONF_LOCATION}' to one of: {', '.join(sorted(LOCATIONS))}"
|
|
||||||
XOR_MSG = f"Specify exactly one of '{CONF_ID}' or '{CONF_LOCATION}'"
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = vol.All(
|
|
||||||
PLATFORM_SCHEMA.extend(
|
|
||||||
{
|
|
||||||
vol.Exclusive(CONF_ID, "xor", msg=XOR_MSG): cv.string,
|
|
||||||
vol.Exclusive(CONF_LOCATION, "xor", msg=XOR_MSG): vol.In(
|
|
||||||
LOCATIONS, msg=LOCATIONS_MSG
|
|
||||||
),
|
|
||||||
vol.Optional(CONF_DELTA): cv.positive_int,
|
|
||||||
vol.Optional(CONF_FRAMES): cv.positive_int,
|
|
||||||
vol.Optional(CONF_NAME): cv.string,
|
|
||||||
vol.Optional(CONF_OUTFILE): cv.string,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
_validate_schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
||||||
"""Set up BOM radar-loop camera component."""
|
|
||||||
location = config.get(CONF_LOCATION) or f"ID {config.get(CONF_ID)}"
|
|
||||||
name = config.get(CONF_NAME) or f"BOM Radar Loop - {location}"
|
|
||||||
args = [
|
|
||||||
config.get(x)
|
|
||||||
for x in (CONF_LOCATION, CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_OUTFILE)
|
|
||||||
]
|
|
||||||
add_entities([BOMRadarCam(name, *args)])
|
|
||||||
|
|
||||||
|
|
||||||
class BOMRadarCam(Camera):
|
|
||||||
"""A camera component producing animated BOM radar-imagery GIFs."""
|
|
||||||
|
|
||||||
def __init__(self, name, location, radar_id, delta, frames, outfile):
|
|
||||||
"""Initialize the component."""
|
|
||||||
|
|
||||||
super().__init__()
|
|
||||||
self._name = name
|
|
||||||
self._cam = BOMRadarLoop(location, radar_id, delta, frames, outfile)
|
|
||||||
|
|
||||||
def camera_image(self):
|
|
||||||
"""Return the current BOM radar-loop image."""
|
|
||||||
return self._cam.current
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Return the component name."""
|
|
||||||
return self._name
|
|
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "bom",
|
|
||||||
"name": "Australian Bureau of Meteorology (BOM)",
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bom",
|
|
||||||
"requirements": ["bomradarloop==0.1.5"],
|
|
||||||
"codeowners": ["@maddenp"]
|
|
||||||
}
|
|
@ -1,355 +0,0 @@
|
|||||||
"""Support for Australian BOM (Bureau of Meteorology) weather service."""
|
|
||||||
import datetime
|
|
||||||
import ftplib
|
|
||||||
import gzip
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
|
||||||
from homeassistant.const import (
|
|
||||||
ATTR_ATTRIBUTION,
|
|
||||||
CONF_LATITUDE,
|
|
||||||
CONF_LONGITUDE,
|
|
||||||
CONF_MONITORED_CONDITIONS,
|
|
||||||
CONF_NAME,
|
|
||||||
LENGTH_KILOMETERS,
|
|
||||||
LENGTH_METERS,
|
|
||||||
LENGTH_MILLIMETERS,
|
|
||||||
PERCENTAGE,
|
|
||||||
PRESSURE_MBAR,
|
|
||||||
SPEED_KILOMETERS_PER_HOUR,
|
|
||||||
TEMP_CELSIUS,
|
|
||||||
)
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity import Entity
|
|
||||||
from homeassistant.util import Throttle
|
|
||||||
import homeassistant.util.dt as dt_util
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ATTR_LAST_UPDATE = "last_update"
|
|
||||||
ATTR_SENSOR_ID = "sensor_id"
|
|
||||||
ATTR_STATION_ID = "station_id"
|
|
||||||
ATTR_STATION_NAME = "station_name"
|
|
||||||
ATTR_ZONE_ID = "zone_id"
|
|
||||||
|
|
||||||
ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology"
|
|
||||||
|
|
||||||
CONF_STATION = "station"
|
|
||||||
CONF_ZONE_ID = "zone_id"
|
|
||||||
CONF_WMO_ID = "wmo_id"
|
|
||||||
|
|
||||||
MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=60)
|
|
||||||
|
|
||||||
SENSOR_TYPES = {
|
|
||||||
"wmo": ["wmo", None],
|
|
||||||
"name": ["Station Name", None],
|
|
||||||
"history_product": ["Zone", None],
|
|
||||||
"local_date_time": ["Local Time", None],
|
|
||||||
"local_date_time_full": ["Local Time Full", None],
|
|
||||||
"aifstime_utc": ["UTC Time Full", None],
|
|
||||||
"lat": ["Lat", None],
|
|
||||||
"lon": ["Long", None],
|
|
||||||
"apparent_t": ["Feels Like C", TEMP_CELSIUS],
|
|
||||||
"cloud": ["Cloud", None],
|
|
||||||
"cloud_base_m": ["Cloud Base", None],
|
|
||||||
"cloud_oktas": ["Cloud Oktas", None],
|
|
||||||
"cloud_type_id": ["Cloud Type ID", None],
|
|
||||||
"cloud_type": ["Cloud Type", None],
|
|
||||||
"delta_t": ["Delta Temp C", TEMP_CELSIUS],
|
|
||||||
"gust_kmh": ["Wind Gust kmh", SPEED_KILOMETERS_PER_HOUR],
|
|
||||||
"gust_kt": ["Wind Gust kt", "kt"],
|
|
||||||
"air_temp": ["Air Temp C", TEMP_CELSIUS],
|
|
||||||
"dewpt": ["Dew Point C", TEMP_CELSIUS],
|
|
||||||
"press": ["Pressure mb", PRESSURE_MBAR],
|
|
||||||
"press_qnh": ["Pressure qnh", "qnh"],
|
|
||||||
"press_msl": ["Pressure msl", "msl"],
|
|
||||||
"press_tend": ["Pressure Tend", None],
|
|
||||||
"rain_trace": ["Rain Today", LENGTH_MILLIMETERS],
|
|
||||||
"rel_hum": ["Relative Humidity", PERCENTAGE],
|
|
||||||
"sea_state": ["Sea State", None],
|
|
||||||
"swell_dir_worded": ["Swell Direction", None],
|
|
||||||
"swell_height": ["Swell Height", LENGTH_METERS],
|
|
||||||
"swell_period": ["Swell Period", None],
|
|
||||||
"vis_km": [f"Visability {LENGTH_KILOMETERS}", LENGTH_KILOMETERS],
|
|
||||||
"weather": ["Weather", None],
|
|
||||||
"wind_dir": ["Wind Direction", None],
|
|
||||||
"wind_spd_kmh": ["Wind Speed kmh", SPEED_KILOMETERS_PER_HOUR],
|
|
||||||
"wind_spd_kt": ["Wind Speed kt", "kt"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_station(station):
|
|
||||||
"""Check that the station ID is well-formed."""
|
|
||||||
if station is None:
|
|
||||||
return
|
|
||||||
station = station.replace(".shtml", "")
|
|
||||||
if not re.fullmatch(r"ID[A-Z]\d\d\d\d\d\.\d\d\d\d\d", station):
|
|
||||||
raise vol.error.Invalid("Malformed station ID")
|
|
||||||
return station
|
|
||||||
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
||||||
{
|
|
||||||
vol.Inclusive(CONF_ZONE_ID, "Deprecated partial station ID"): cv.string,
|
|
||||||
vol.Inclusive(CONF_WMO_ID, "Deprecated partial station ID"): cv.string,
|
|
||||||
vol.Optional(CONF_NAME): cv.string,
|
|
||||||
vol.Optional(CONF_STATION): validate_station,
|
|
||||||
vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All(
|
|
||||||
cv.ensure_list, [vol.In(SENSOR_TYPES)]
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
||||||
"""Set up the BOM sensor."""
|
|
||||||
station = config.get(CONF_STATION)
|
|
||||||
zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID)
|
|
||||||
|
|
||||||
if station is not None:
|
|
||||||
if zone_id and wmo_id:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Using configuration %s, not %s and %s for BOM sensor",
|
|
||||||
CONF_STATION,
|
|
||||||
CONF_ZONE_ID,
|
|
||||||
CONF_WMO_ID,
|
|
||||||
)
|
|
||||||
elif zone_id and wmo_id:
|
|
||||||
station = f"{zone_id}.{wmo_id}"
|
|
||||||
else:
|
|
||||||
station = closest_station(
|
|
||||||
config.get(CONF_LATITUDE),
|
|
||||||
config.get(CONF_LONGITUDE),
|
|
||||||
hass.config.config_dir,
|
|
||||||
)
|
|
||||||
if station is None:
|
|
||||||
_LOGGER.error("Could not get BOM weather station from lat/lon")
|
|
||||||
return
|
|
||||||
|
|
||||||
bom_data = BOMCurrentData(station)
|
|
||||||
|
|
||||||
try:
|
|
||||||
bom_data.update()
|
|
||||||
except ValueError as err:
|
|
||||||
_LOGGER.error("Received error from BOM Current: %s", err)
|
|
||||||
return
|
|
||||||
|
|
||||||
add_entities(
|
|
||||||
[
|
|
||||||
BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME))
|
|
||||||
for variable in config[CONF_MONITORED_CONDITIONS]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BOMCurrentSensor(Entity):
|
|
||||||
"""Implementation of a BOM current sensor."""
|
|
||||||
|
|
||||||
def __init__(self, bom_data, condition, stationname):
|
|
||||||
"""Initialize the sensor."""
|
|
||||||
self.bom_data = bom_data
|
|
||||||
self._condition = condition
|
|
||||||
self.stationname = stationname
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Return the name of the sensor."""
|
|
||||||
if self.stationname is None:
|
|
||||||
return f"BOM {SENSOR_TYPES[self._condition][0]}"
|
|
||||||
|
|
||||||
return f"BOM {self.stationname} {SENSOR_TYPES[self._condition][0]}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def state(self):
|
|
||||||
"""Return the state of the sensor."""
|
|
||||||
return self.bom_data.get_reading(self._condition)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_state_attributes(self):
|
|
||||||
"""Return the state attributes of the device."""
|
|
||||||
return {
|
|
||||||
ATTR_ATTRIBUTION: ATTRIBUTION,
|
|
||||||
ATTR_LAST_UPDATE: self.bom_data.last_updated,
|
|
||||||
ATTR_SENSOR_ID: self._condition,
|
|
||||||
ATTR_STATION_ID: self.bom_data.latest_data["wmo"],
|
|
||||||
ATTR_STATION_NAME: self.bom_data.latest_data["name"],
|
|
||||||
ATTR_ZONE_ID: self.bom_data.latest_data["history_product"],
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def unit_of_measurement(self):
|
|
||||||
"""Return the units of measurement."""
|
|
||||||
return SENSOR_TYPES[self._condition][1]
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
"""Update current conditions."""
|
|
||||||
self.bom_data.update()
|
|
||||||
|
|
||||||
|
|
||||||
class BOMCurrentData:
|
|
||||||
"""Get data from BOM."""
|
|
||||||
|
|
||||||
def __init__(self, station_id):
|
|
||||||
"""Initialize the data object."""
|
|
||||||
self._zone_id, self._wmo_id = station_id.split(".")
|
|
||||||
self._data = None
|
|
||||||
self.last_updated = None
|
|
||||||
|
|
||||||
def _build_url(self):
|
|
||||||
"""Build the URL for the requests."""
|
|
||||||
url = (
|
|
||||||
f"http://www.bom.gov.au/fwo/{self._zone_id}"
|
|
||||||
f"/{self._zone_id}.{self._wmo_id}.json"
|
|
||||||
)
|
|
||||||
_LOGGER.debug("BOM URL: %s", url)
|
|
||||||
return url
|
|
||||||
|
|
||||||
@property
|
|
||||||
def latest_data(self):
|
|
||||||
"""Return the latest data object."""
|
|
||||||
if self._data:
|
|
||||||
return self._data[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_reading(self, condition):
|
|
||||||
"""Return the value for the given condition.
|
|
||||||
|
|
||||||
BOM weather publishes condition readings for weather (and a few other
|
|
||||||
conditions) at intervals throughout the day. To avoid a `-` value in
|
|
||||||
the frontend for these conditions, we traverse the historical data
|
|
||||||
for the latest value that is not `-`.
|
|
||||||
|
|
||||||
Iterators are used in this method to avoid iterating needlessly
|
|
||||||
through the entire BOM provided dataset.
|
|
||||||
"""
|
|
||||||
condition_readings = (entry[condition] for entry in self._data)
|
|
||||||
reading = next((x for x in condition_readings if x != "-"), None)
|
|
||||||
|
|
||||||
if isinstance(reading, (int, float)):
|
|
||||||
return round(reading, 2)
|
|
||||||
return reading
|
|
||||||
|
|
||||||
def should_update(self):
|
|
||||||
"""Determine whether an update should occur.
|
|
||||||
|
|
||||||
BOM provides updated data every 30 minutes. We manually define
|
|
||||||
refreshing logic here rather than a throttle to keep updates
|
|
||||||
in lock-step with BOM.
|
|
||||||
|
|
||||||
If 35 minutes has passed since the last BOM data update, then
|
|
||||||
an update should be done.
|
|
||||||
"""
|
|
||||||
if self.last_updated is None:
|
|
||||||
# Never updated before, therefore an update should occur.
|
|
||||||
return True
|
|
||||||
|
|
||||||
now = dt_util.utcnow()
|
|
||||||
update_due_at = self.last_updated + datetime.timedelta(minutes=35)
|
|
||||||
return now > update_due_at
|
|
||||||
|
|
||||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
|
||||||
def update(self):
|
|
||||||
"""Get the latest data from BOM."""
|
|
||||||
if not self.should_update():
|
|
||||||
_LOGGER.debug(
|
|
||||||
"BOM was updated %s minutes ago, skipping update as"
|
|
||||||
" < 35 minutes, Now: %s, LastUpdate: %s",
|
|
||||||
(dt_util.utcnow() - self.last_updated),
|
|
||||||
dt_util.utcnow(),
|
|
||||||
self.last_updated,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = requests.get(self._build_url(), timeout=10).json()
|
|
||||||
self._data = result["observations"]["data"]
|
|
||||||
|
|
||||||
# set lastupdate using self._data[0] as the first element in the
|
|
||||||
# array is the latest date in the json
|
|
||||||
self.last_updated = dt_util.as_utc(
|
|
||||||
datetime.datetime.strptime(
|
|
||||||
str(self._data[0]["local_date_time_full"]), "%Y%m%d%H%M%S"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
except ValueError as err:
|
|
||||||
_LOGGER.error("Check BOM %s", err.args)
|
|
||||||
self._data = None
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _get_bom_stations():
|
|
||||||
"""Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.
|
|
||||||
|
|
||||||
This function does several MB of internet requests, so please use the
|
|
||||||
caching version to minimize latency and hit-count.
|
|
||||||
"""
|
|
||||||
latlon = {}
|
|
||||||
with io.BytesIO() as file_obj:
|
|
||||||
with ftplib.FTP("ftp.bom.gov.au") as ftp:
|
|
||||||
ftp.login()
|
|
||||||
ftp.cwd("anon2/home/ncc/metadata/sitelists")
|
|
||||||
ftp.retrbinary("RETR stations.zip", file_obj.write)
|
|
||||||
file_obj.seek(0)
|
|
||||||
with zipfile.ZipFile(file_obj) as zipped:
|
|
||||||
with zipped.open("stations.txt") as station_txt:
|
|
||||||
for _ in range(4):
|
|
||||||
station_txt.readline() # skip header
|
|
||||||
while True:
|
|
||||||
line = station_txt.readline().decode().strip()
|
|
||||||
if len(line) < 120:
|
|
||||||
break # end while loop, ignoring any footer text
|
|
||||||
wmo, lat, lon = (
|
|
||||||
line[a:b].strip() for a, b in [(128, 134), (70, 78), (79, 88)]
|
|
||||||
)
|
|
||||||
if wmo != "..":
|
|
||||||
latlon[wmo] = (float(lat), float(lon))
|
|
||||||
zones = {}
|
|
||||||
pattern = (
|
|
||||||
r'<a href="/products/(?P<zone>ID[A-Z]\d\d\d\d\d)/'
|
|
||||||
r'(?P=zone)\.(?P<wmo>\d\d\d\d\d).shtml">'
|
|
||||||
)
|
|
||||||
for state in ("nsw", "vic", "qld", "wa", "tas", "nt"):
|
|
||||||
url = f"http://www.bom.gov.au/{state}/observations/{state}all.shtml"
|
|
||||||
for zone_id, wmo_id in re.findall(pattern, requests.get(url).text):
|
|
||||||
zones[wmo_id] = zone_id
|
|
||||||
return {f"{zones[k]}.{k}": latlon[k] for k in set(latlon) & set(zones)}
|
|
||||||
|
|
||||||
|
|
||||||
def bom_stations(cache_dir):
|
|
||||||
"""Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.
|
|
||||||
|
|
||||||
Results from internet requests are cached as compressed JSON, making
|
|
||||||
subsequent calls very much faster.
|
|
||||||
"""
|
|
||||||
cache_file = os.path.join(cache_dir, ".bom-stations.json.gz")
|
|
||||||
if not os.path.isfile(cache_file):
|
|
||||||
stations = _get_bom_stations()
|
|
||||||
with gzip.open(cache_file, "wt") as cache:
|
|
||||||
json.dump(stations, cache, sort_keys=True)
|
|
||||||
return stations
|
|
||||||
with gzip.open(cache_file, "rt") as cache:
|
|
||||||
return {k: tuple(v) for k, v in json.load(cache).items()}
|
|
||||||
|
|
||||||
|
|
||||||
def closest_station(lat, lon, cache_dir):
|
|
||||||
"""Return the ZONE_ID.WMO_ID of the closest station to our lat/lon."""
|
|
||||||
if lat is None or lon is None or not os.path.isdir(cache_dir):
|
|
||||||
return
|
|
||||||
stations = bom_stations(cache_dir)
|
|
||||||
|
|
||||||
def comparable_dist(wmo_id):
|
|
||||||
"""Create a psudeo-distance from latitude/longitude."""
|
|
||||||
station_lat, station_lon = stations[wmo_id]
|
|
||||||
return (lat - station_lat) ** 2 + (lon - station_lon) ** 2
|
|
||||||
|
|
||||||
return min(stations, key=comparable_dist)
|
|
@ -1,115 +0,0 @@
|
|||||||
"""Support for Australian BOM (Bureau of Meteorology) weather service."""
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity
|
|
||||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
|
|
||||||
# Reuse data and API logic from the sensor implementation
|
|
||||||
from .sensor import CONF_STATION, BOMCurrentData, closest_station, validate_station
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
||||||
{vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_STATION): validate_station}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_platform(hass, config, add_entities, discovery_info=None):
|
|
||||||
"""Set up the BOM weather platform."""
|
|
||||||
station = config.get(CONF_STATION) or closest_station(
|
|
||||||
config.get(CONF_LATITUDE), config.get(CONF_LONGITUDE), hass.config.config_dir
|
|
||||||
)
|
|
||||||
if station is None:
|
|
||||||
_LOGGER.error("Could not get BOM weather station from lat/lon")
|
|
||||||
return False
|
|
||||||
bom_data = BOMCurrentData(station)
|
|
||||||
try:
|
|
||||||
bom_data.update()
|
|
||||||
except ValueError as err:
|
|
||||||
_LOGGER.error("Received error from BOM_Current: %s", err)
|
|
||||||
return False
|
|
||||||
add_entities([BOMWeather(bom_data, config.get(CONF_NAME))], True)
|
|
||||||
|
|
||||||
|
|
||||||
class BOMWeather(WeatherEntity):
|
|
||||||
"""Representation of a weather condition."""
|
|
||||||
|
|
||||||
def __init__(self, bom_data, stationname=None):
|
|
||||||
"""Initialise the platform with a data instance and station name."""
|
|
||||||
self.bom_data = bom_data
|
|
||||||
self.stationname = stationname or self.bom_data.latest_data.get("name")
|
|
||||||
|
|
||||||
def update(self):
|
|
||||||
"""Update current conditions."""
|
|
||||||
self.bom_data.update()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""Return the name of the sensor."""
|
|
||||||
return f"BOM {self.stationname or '(unknown station)'}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def condition(self):
|
|
||||||
"""Return the current condition."""
|
|
||||||
return self.bom_data.get_reading("weather") or self.bom_data.get_reading(
|
|
||||||
"cloud"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Now implement the WeatherEntity interface
|
|
||||||
|
|
||||||
@property
|
|
||||||
def temperature(self):
|
|
||||||
"""Return the platform temperature."""
|
|
||||||
return self.bom_data.get_reading("air_temp")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def temperature_unit(self):
|
|
||||||
"""Return the unit of measurement."""
|
|
||||||
return TEMP_CELSIUS
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pressure(self):
|
|
||||||
"""Return the mean sea-level pressure."""
|
|
||||||
return self.bom_data.get_reading("press_msl")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def humidity(self):
|
|
||||||
"""Return the relative humidity."""
|
|
||||||
return self.bom_data.get_reading("rel_hum")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wind_speed(self):
|
|
||||||
"""Return the wind speed."""
|
|
||||||
return self.bom_data.get_reading("wind_spd_kmh")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wind_bearing(self):
|
|
||||||
"""Return the wind bearing."""
|
|
||||||
directions = [
|
|
||||||
"N",
|
|
||||||
"NNE",
|
|
||||||
"NE",
|
|
||||||
"ENE",
|
|
||||||
"E",
|
|
||||||
"ESE",
|
|
||||||
"SE",
|
|
||||||
"SSE",
|
|
||||||
"S",
|
|
||||||
"SSW",
|
|
||||||
"SW",
|
|
||||||
"WSW",
|
|
||||||
"W",
|
|
||||||
"WNW",
|
|
||||||
"NW",
|
|
||||||
"NNW",
|
|
||||||
]
|
|
||||||
wind = {name: idx * 360 / 16 for idx, name in enumerate(directions)}
|
|
||||||
return wind.get(self.bom_data.get_reading("wind_dir"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def attribution(self):
|
|
||||||
"""Return the attribution."""
|
|
||||||
return "Data provided by the Australian Bureau of Meteorology"
|
|
@ -369,9 +369,6 @@ blockchain==1.4.4
|
|||||||
# homeassistant.components.bme680
|
# homeassistant.components.bme680
|
||||||
# bme680==1.0.5
|
# bme680==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.bom
|
|
||||||
bomradarloop==0.1.5
|
|
||||||
|
|
||||||
# homeassistant.components.bond
|
# homeassistant.components.bond
|
||||||
bond-api==0.1.8
|
bond-api==0.1.8
|
||||||
|
|
||||||
|
@ -197,9 +197,6 @@ blebox_uniapi==1.3.2
|
|||||||
# homeassistant.components.blink
|
# homeassistant.components.blink
|
||||||
blinkpy==0.16.3
|
blinkpy==0.16.3
|
||||||
|
|
||||||
# homeassistant.components.bom
|
|
||||||
bomradarloop==0.1.5
|
|
||||||
|
|
||||||
# homeassistant.components.bond
|
# homeassistant.components.bond
|
||||||
bond-api==0.1.8
|
bond-api==0.1.8
|
||||||
|
|
||||||
|
@ -1 +0,0 @@
|
|||||||
"""Tests for the bom component."""
|
|
@ -1,104 +0,0 @@
|
|||||||
"""The tests for the BOM Weather sensor platform."""
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import unittest
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from homeassistant.components import sensor
|
|
||||||
from homeassistant.components.bom.sensor import BOMCurrentData
|
|
||||||
from homeassistant.setup import setup_component
|
|
||||||
|
|
||||||
from tests.async_mock import patch
|
|
||||||
from tests.common import assert_setup_component, get_test_home_assistant, load_fixture
|
|
||||||
|
|
||||||
VALID_CONFIG = {
|
|
||||||
"platform": "bom",
|
|
||||||
"station": "IDN60901.94767",
|
|
||||||
"name": "Fake",
|
|
||||||
"monitored_conditions": ["apparent_t", "press", "weather"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def mocked_requests(*args, **kwargs):
|
|
||||||
"""Mock requests.get invocations."""
|
|
||||||
|
|
||||||
class MockResponse:
|
|
||||||
"""Class to represent a mocked response."""
|
|
||||||
|
|
||||||
def __init__(self, json_data, status_code):
|
|
||||||
"""Initialize the mock response class."""
|
|
||||||
self.json_data = json_data
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
"""Return the json of the response."""
|
|
||||||
return self.json_data
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content(self):
|
|
||||||
"""Return the content of the response."""
|
|
||||||
return self.json()
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
"""Raise an HTTPError if status is not 200."""
|
|
||||||
if self.status_code != 200:
|
|
||||||
raise requests.HTTPError(self.status_code)
|
|
||||||
|
|
||||||
url = urlparse(args[0])
|
|
||||||
if re.match(r"^/fwo/[\w]+/[\w.]+\.json", url.path):
|
|
||||||
return MockResponse(json.loads(load_fixture("bom_weather.json")), 200)
|
|
||||||
|
|
||||||
raise NotImplementedError(f"Unknown route {url.path}")
|
|
||||||
|
|
||||||
|
|
||||||
class TestBOMWeatherSensor(unittest.TestCase):
|
|
||||||
"""Test the BOM Weather sensor."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
"""Set up things to be run when tests are started."""
|
|
||||||
self.hass = get_test_home_assistant()
|
|
||||||
self.config = VALID_CONFIG
|
|
||||||
self.addCleanup(self.hass.stop)
|
|
||||||
|
|
||||||
@patch("requests.get", side_effect=mocked_requests)
|
|
||||||
def test_setup(self, mock_get):
|
|
||||||
"""Test the setup with custom settings."""
|
|
||||||
with assert_setup_component(1, sensor.DOMAIN):
|
|
||||||
assert setup_component(self.hass, sensor.DOMAIN, {"sensor": VALID_CONFIG})
|
|
||||||
self.hass.block_till_done()
|
|
||||||
|
|
||||||
fake_entities = [
|
|
||||||
"bom_fake_feels_like_c",
|
|
||||||
"bom_fake_pressure_mb",
|
|
||||||
"bom_fake_weather",
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in fake_entities:
|
|
||||||
state = self.hass.states.get(f"sensor.{entity_id}")
|
|
||||||
assert state is not None
|
|
||||||
|
|
||||||
@patch("requests.get", side_effect=mocked_requests)
|
|
||||||
def test_sensor_values(self, mock_get):
|
|
||||||
"""Test retrieval of sensor values."""
|
|
||||||
assert setup_component(self.hass, sensor.DOMAIN, {"sensor": VALID_CONFIG})
|
|
||||||
self.hass.block_till_done()
|
|
||||||
|
|
||||||
weather = self.hass.states.get("sensor.bom_fake_weather").state
|
|
||||||
assert "Fine" == weather
|
|
||||||
|
|
||||||
pressure = self.hass.states.get("sensor.bom_fake_pressure_mb").state
|
|
||||||
assert "1021.7" == pressure
|
|
||||||
|
|
||||||
feels_like = self.hass.states.get("sensor.bom_fake_feels_like_c").state
|
|
||||||
assert "25.0" == feels_like
|
|
||||||
|
|
||||||
|
|
||||||
class TestBOMCurrentData(unittest.TestCase):
|
|
||||||
"""Test the BOM data container."""
|
|
||||||
|
|
||||||
def test_should_update_initial(self):
|
|
||||||
"""Test that the first update always occurs."""
|
|
||||||
bom_data = BOMCurrentData("IDN60901.94767")
|
|
||||||
assert bom_data.should_update() is True
|
|
Loading…
x
Reference in New Issue
Block a user