-
Notifications
You must be signed in to change notification settings - Fork 52
feat: add method for returning country and country code #492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add method for returning country and country code #492
Conversation
|
Hi @deconstructionalism! Thanks for the PR! But I unfortunately have a decent bit of changes in #490 that will break this logic. |
|
Feel free to remake it based off of that with a cli command! |
|
Ah ok. I was basically set this up to try and find a solution for the integration in HA to try v4 auth, and also therefore find the country code and country via the package. If you have the bandwidth, I think you'd do a much better job with these fixes, but here are the core HA code changes I was sketching, in case they are useful to you. Uploading as fenced code instead of a diff or PR as this crappy sketch doesnt pass commit-hook lints yet lol
"""Config flow for Roborock."""
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
import logging
from typing import Any
from roborock.containers import UserData
from roborock.exceptions import (
RoborockAccountDoesNotExist,
RoborockException,
RoborockInvalidCode,
RoborockInvalidEmail,
RoborockTooFrequentCodeRequests,
RoborockUrlException,
)
from roborock.web_api import RoborockApiClient
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import RoborockConfigEntry
from .const import (
CONF_BASE_URL,
CONF_ENTRY_CODE,
CONF_SHOW_BACKGROUND,
CONF_USER_DATA,
DEFAULT_BASE_URL,
DEFAULT_DRAWABLES,
DOMAIN,
DRAWABLES,
)
_LOGGER = logging.getLogger(__name__)
class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Roborock."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self._username: str | None = None
self._client: RoborockApiClient | None = None
self._using_v4: bool = False
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
username = user_input[CONF_USERNAME]
base_url = user_input[CONF_BASE_URL]
self._username = username
_LOGGER.debug("Requesting code for Roborock account")
self._client = RoborockApiClient(
username, base_url=base_url, session=async_get_clientsession(self.hass)
)
errors = await self._request_code()
if not errors:
return await self.async_step_code()
self._using_v4 = True
errors = await self._request_code()
if not errors:
return await self.async_step_code()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_BASE_URL, default=DEFAULT_BASE_URL): str,
}
),
errors=errors,
)
async def _request_code(self) -> dict:
assert self._client
errors: dict[str, str] = {}
request_code = (
self._client.request_code_v4
if self._using_v4
else self._client.request_code
)
try:
await request_code()
except RoborockAccountDoesNotExist:
errors["base"] = "invalid_email"
except RoborockUrlException:
errors["base"] = "unknown_url"
except RoborockInvalidEmail:
errors["base"] = "invalid_email_format"
except RoborockTooFrequentCodeRequests:
errors["base"] = "too_frequent_code_requests"
except RoborockException:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown_roborock"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
async def _code_login(self, code: int | str) -> tuple[dict, UserData | None]:
assert self._client
assert self._username
errors: dict[str, str] = {}
user_data: UserData | None = None
try:
if self._using_v4:
res = await self._client.get_country_code_and_country()
countrycode = res.get("countrycode")
country = res.get("country")
user_data = await self._client.code_login_v4(code, country, countrycode)
else:
user_data = await self._client.code_login(code)
except RoborockInvalidCode:
errors["base"] = "invalid_code"
except RoborockException:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown_roborock"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return (errors, user_data)
async def async_step_code(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
if user_input is not None:
code = user_input[CONF_ENTRY_CODE]
_LOGGER.debug("Logging into Roborock account using email provided code")
(errors, user_data) = await self._client.code_login(code)
if not errors:
await self.async_set_unique_id(user_data.rruid)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()}
)
self._abort_if_unique_id_configured(error="already_configured_account")
return self._create_entry(self._client, self._username, user_data)
return self.async_show_form(
step_id="code",
data_schema=vol.Schema({vol.Required(CONF_ENTRY_CODE): str}),
errors=errors,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow started by a dhcp discovery."""
await self._async_handle_discovery_without_unique_id()
device_registry = dr.async_get(self.hass)
device = device_registry.async_get_device(
connections={
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress))
}
)
if device is not None and any(
identifier[0] == DOMAIN for identifier in device.identifiers
):
return self.async_abort(reason="already_configured")
return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
self._username = entry_data[CONF_USERNAME]
assert self._username
self._client = RoborockApiClient(
self._username, session=async_get_clientsession(self.hass)
)
self._using_v4 = False
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await self._request_code()
if not errors:
return await self.async_step_code()
self._using_v4 = True
errors = await self._request_code()
if not errors:
return await self.async_step_code()
return self.async_show_form(step_id="reauth_confirm", errors=errors)
def _create_entry(
self, client: RoborockApiClient, username: str, user_data: UserData
) -> ConfigFlowResult:
"""Finished config flow and create entry."""
return self.async_create_entry(
title=username,
data={
CONF_USERNAME: username,
CONF_USER_DATA: user_data.as_dict(),
CONF_BASE_URL: client.base_url,
},
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: RoborockConfigEntry,
) -> RoborockOptionsFlowHandler:
"""Create the options flow."""
return RoborockOptionsFlowHandler(config_entry)
class RoborockOptionsFlowHandler(OptionsFlowWithReload):
"""Handle an option flow for Roborock."""
def __init__(self, config_entry: RoborockConfigEntry) -> None:
"""Initialize options flow."""
self.options = deepcopy(dict(config_entry.options))
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
return await self.async_step_drawables()
async def async_step_drawables(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the map object drawable options."""
if user_input is not None:
self.options[CONF_SHOW_BACKGROUND] = user_input.pop(CONF_SHOW_BACKGROUND)
self.options.setdefault(DRAWABLES, {}).update(user_input)
return self.async_create_entry(title="", data=self.options)
data_schema = {}
for drawable, default_value in DEFAULT_DRAWABLES.items():
data_schema[
vol.Required(
drawable.value,
default=self.config_entry.options.get(DRAWABLES, {}).get(
drawable, default_value
),
)
] = bool
data_schema[
vol.Required(
CONF_SHOW_BACKGROUND,
default=self.config_entry.options.get(CONF_SHOW_BACKGROUND, False),
)
] = bool
return self.async_show_form(
step_id=DRAWABLES,
data_schema=vol.Schema(data_schema),
)
{
"config": {
"step": {
"user": {
"description": "Enter your Roborock email address.",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"api_url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"username": "The email address used to sign in to the Roborock app.",
"api_url": "Roborock API URL to use to authenticate."
}
},
"code": {
"description": "Type the verification code sent to your email",
"data": {
"code": "Verification code"
},
"data_description": {
"code": "The verification code sent to your email."
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Roborock integration needs to re-authenticate your account"
}
},
"error": {
"invalid_code": "The code you entered was incorrect, please check it and try again.",
"invalid_email": "There is no account associated with the email you entered, please try again.",
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
"too_frequent_code_requests": "You have attempted to request too many codes. Try again later.",
"unknown_roborock": "There was an unknown Roborock exception - please check your logs.",
"unknown_url": "There was an issue determining the correct URL for your Roborock account - please check your logs.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "Wrong account: Please authenticate with the right account."
}
},
"options": {
"step": {
"drawables": {
"description": "Specify which features to draw on the map.",
"data": {
"charger": "Charger",
"cleaned_area": "Cleaned area",
"goto_path": "Go-to path",
"ignored_obstacles": "Ignored obstacles",
"ignored_obstacles_with_photo": "Ignored obstacles with photo",
"mop_path": "Mop path",
"no_carpet_zones": "No carpet zones",
"no_go_zones": "No-go zones",
"no_mopping_zones": "No mopping zones",
"obstacles": "Obstacles",
"obstacles_with_photo": "Obstacles with photo",
"path": "Path",
"predicted_path": "Predicted path",
"room_names": "Room names",
"vacuum_position": "Vacuum position",
"virtual_walls": "Virtual walls",
"zones": "Zones",
"show_background": "Show background"
},
"data_description": {
"charger": "Show the charger on the map.",
"cleaned_area": "Show the area cleaned on the map.",
"goto_path": "Show the go-to path on the map.",
"ignored_obstacles": "Show ignored obstacles on the map.",
"ignored_obstacles_with_photo": "Show ignored obstacles with photos on the map.",
"mop_path": "Show the mop path on the map.",
"no_carpet_zones": "Show the no carpet zones on the map.",
"no_go_zones": "Show the no-go zones on the map.",
"no_mopping_zones": "Show the no-mop zones on the map.",
"obstacles": "Show obstacles on the map.",
"obstacles_with_photo": "Show obstacles with photos on the map.",
"path": "Show the path on the map.",
"predicted_path": "Show the predicted path on the map.",
"room_names": "Show room names on the map.",
"vacuum_position": "Show the vacuum position on the map.",
"virtual_walls": "Show virtual walls on the map.",
"zones": "Show zones on the map.",
"show_background": "Add a background to the map."
}
}
}
},
"entity": {
"binary_sensor": {
"in_cleaning": {
"name": "Cleaning"
},
"mop_attached": {
"name": "Mop attached"
},
"mop_drying_status": {
"name": "Mop drying"
},
"water_box_attached": {
"name": "Water box attached"
},
"water_shortage": {
"name": "Water shortage"
}
},
"button": {
"reset_sensor_consumable": {
"name": "Reset sensor consumable"
},
"reset_air_filter_consumable": {
"name": "Reset air filter consumable"
},
"reset_side_brush_consumable": {
"name": "Reset side brush consumable"
},
"reset_main_brush_consumable": {
"name": "Reset main brush consumable"
}
},
"number": {
"volume": {
"name": "Volume"
}
},
"sensor": {
"a01_error": {
"name": "Error",
"state": {
"none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]",
"dirty_tank_full": "Dirty tank full",
"water_level_sensor_stuck": "Water level sensor stuck.",
"clean_tank_empty": "Clean tank empty",
"clean_head_entangled": "Cleaning head entangled",
"clean_head_too_hot": "Cleaning head too hot.",
"fan_protection_e5": "Fan protection",
"cleaning_head_blocked": "Cleaning head blocked",
"temperature_protection": "Temperature protection",
"fan_protection_e4": "[%key:component::roborock::entity::sensor::a01_error::state::fan_protection_e5%]",
"fan_protection_e9": "[%key:component::roborock::entity::sensor::a01_error::state::fan_protection_e5%]",
"battery_temperature_protection_e0": "[%key:component::roborock::entity::sensor::a01_error::state::temperature_protection%]",
"battery_temperature_protection": "Battery temperature protection",
"battery_temperature_protection_2": "[%key:component::roborock::entity::sensor::a01_error::state::battery_temperature_protection%]",
"power_adapter_error": "Power adapter error",
"dirty_charging_contacts": "Clean charging contacts",
"low_battery": "[%key:component::roborock::entity::sensor::vacuum_error::state::low_battery%]",
"battery_under_10": "Battery under 10%"
}
},
"a01_status": {
"name": "Status",
"state": {
"unknown": "[%key:component::roborock::entity::sensor::status::state::unknown%]",
"fetching": "Fetching",
"fetch_failed": "Fetch failed",
"updating": "[%key:component::roborock::entity::sensor::status::state::updating%]",
"washing": "Washing",
"ready": "Ready",
"charging": "[%key:common::state::charging%]",
"mop_washing": "Washing mop",
"self_clean_cleaning": "Self-clean cleaning",
"self_clean_deep_cleaning": "Self-clean deep cleaning",
"self_clean_rinsing": "Self-clean rinsing",
"self_clean_dehydrating": "Self-clean drying",
"drying": "Drying",
"ventilating": "Ventilating",
"reserving": "Reserving",
"mop_washing_paused": "Mop washing paused",
"dusting_mode": "Dusting mode"
}
},
"brush_remaining": {
"name": "Roller left"
},
"cleaning_area": {
"name": "Cleaning area"
},
"cleaning_time": {
"name": "Cleaning time"
},
"clean_percent": {
"name": "Cleaning progress"
},
"countdown": {
"name": "Countdown"
},
"current_room": {
"name": "Current room"
},
"dock_error": {
"name": "Dock error",
"state": {
"ok": "Ok",
"duct_blockage": "Duct blockage",
"water_empty": "Water empty",
"waste_water_tank_full": "Waste water tank full",
"dirty_tank_latch_open": "Dirty tank latch open",
"no_dustbin": "No dustbin",
"cleaning_tank_full_or_blocked": "Cleaning tank full or blocked"
}
},
"main_brush_time_left": {
"name": "Main brush time left"
},
"mop_drying_remaining_time": {
"name": "Mop drying remaining time"
},
"last_clean_start": {
"name": "Last clean begin"
},
"last_clean_end": {
"name": "Last clean end"
},
"side_brush_time_left": {
"name": "Side brush time left"
},
"filter_time_left": {
"name": "Filter time left"
},
"sensor_time_left": {
"name": "Sensor time left"
},
"status": {
"name": "Status",
"state": {
"starting": "Starting",
"charger_disconnected": "Charger disconnected",
"idle": "[%key:common::state::idle%]",
"remote_control_active": "Remote control active",
"cleaning": "Cleaning",
"returning_home": "Returning home",
"manual_mode": "Manual mode",
"charging": "[%key:common::state::charging%]",
"charging_problem": "Charging problem",
"paused": "[%key:common::state::paused%]",
"spot_cleaning": "Spot cleaning",
"error": "[%key:common::state::error%]",
"shutting_down": "Shutting down",
"updating": "Updating",
"docking": "Docking",
"going_to_target": "Going to target",
"zoned_cleaning": "Zoned cleaning",
"segment_cleaning": "Segment cleaning",
"emptying_the_bin": "Emptying the bin",
"washing_the_mop": "Washing the mop",
"going_to_wash_the_mop": "Going to wash the mop",
"charging_complete": "Charging complete",
"device_offline": "Device offline",
"unknown": "Unknown",
"locked": "Locked",
"air_drying_stopping": "Air drying stopping",
"egg_attack": "Cupid mode",
"mapping": "Mapping"
}
},
"total_cleaning_time": {
"name": "Total cleaning time"
},
"total_cleaning_area": {
"name": "Total cleaning area"
},
"total_cleaning_count": {
"name": "Total cleaning count"
},
"vacuum_error": {
"name": "Vacuum error",
"state": {
"none": "None",
"lidar_blocked": "Lidar blocked",
"bumper_stuck": "Bumper stuck",
"wheels_suspended": "Wheels suspended",
"cliff_sensor_error": "Cliff sensor error",
"main_brush_jammed": "Main brush jammed",
"side_brush_jammed": "Side brush jammed",
"wheels_jammed": "Wheels jammed",
"robot_trapped": "Robot trapped",
"no_dustbin": "No dustbin",
"low_battery": "Low battery",
"charging_error": "Charging error",
"battery_error": "Battery error",
"wall_sensor_dirty": "Wall sensor dirty",
"robot_tilted": "Robot tilted",
"side_brush_error": "Side brush error",
"fan_error": "Fan error",
"vertical_bumper_pressed": "Vertical bumper pressed",
"dock_locator_error": "Dock locator error",
"return_to_dock_fail": "Return to dock fail",
"nogo_zone_detected": "No-go zone detected",
"vibrarise_jammed": "VibraRise jammed",
"robot_on_carpet": "Robot on carpet",
"filter_blocked": "Filter blocked",
"invisible_wall_detected": "Invisible wall detected",
"cannot_cross_carpet": "Cannot cross carpet",
"internal_error": "Internal error",
"strainer_error": "Filter is wet or blocked",
"compass_error": "Strong magnetic field detected",
"dock": "Dock not connected to power",
"visual_sensor": "Camera error",
"light_touch": "Wall sensor error",
"collect_dust_error_3": "Clean auto-empty dock",
"collect_dust_error_4": "Auto empty dock voltage error",
"mopping_roller_1": "Wash roller may be jammed",
"mopping_roller_error_2": "Wash roller not lowered properly",
"clear_water_box_hoare": "Check the clean water tank",
"dirty_water_box_hoare": "Check the dirty water tank",
"sink_strainer_hoare": "Reinstall the water filter",
"clear_water_box_exception": "Clean water tank empty",
"clear_brush_exception": "Check that the water filter has been correctly installed",
"clear_brush_exception_2": "Positioning button error",
"filter_screen_exception": "Clean the dock water filter",
"mopping_roller_2": "[%key:component::roborock::entity::sensor::vacuum_error::state::mopping_roller_1%]",
"temperature_protection": "Unit temperature protection"
}
},
"washing_left": {
"name": "Washing left"
},
"zeo_error": {
"name": "Error",
"state": {
"none": "[%key:component::roborock::entity::sensor::vacuum_error::state::none%]",
"refill_error": "Refill error",
"drain_error": "Drain error",
"door_lock_error": "Door lock error",
"water_level_error": "Water level error",
"inverter_error": "Inverter error",
"heating_error": "Heating error",
"temperature_error": "Temperature error",
"communication_error": "Communication error",
"drying_error": "Drying error",
"drying_error_e_12": "Drying error E12",
"drying_error_e_13": "Drying error E13",
"drying_error_e_14": "Drying error E14",
"drying_error_e_15": "Drying error E15",
"drying_error_e_16": "Drying error E16",
"drying_error_water_flow": "Check water flow",
"drying_error_restart": "Restart the washer",
"spin_error": "Re-arrange clothes"
}
},
"zeo_state": {
"name": "State",
"state": {
"standby": "[%key:common::state::standby%]",
"weighing": "Weighing",
"soaking": "Soaking",
"washing": "Washing",
"rinsing": "Rinsing",
"spinning": "Spinning",
"drying": "Drying",
"cooling": "Cooling",
"under_delay_start": "Delayed start",
"done": "Done"
}
}
},
"select": {
"mop_mode": {
"name": "Mop mode",
"state": {
"standard": "Standard",
"deep": "Deep",
"deep_plus": "Deep+",
"custom": "Custom",
"fast": "Fast",
"smart_mode": "SmartPlan"
}
},
"mop_intensity": {
"name": "Mop intensity",
"state": {
"off": "[%key:common::state::off%]",
"low": "[%key:common::state::low%]",
"mild": "Mild",
"medium": "[%key:common::state::medium%]",
"moderate": "Moderate",
"max": "Max",
"high": "[%key:common::state::high%]",
"intense": "Intense",
"custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]",
"custom_water_flow": "Custom water flow",
"smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]"
}
},
"selected_map": {
"name": "Selected map"
},
"dust_collection_mode": {
"name": "Empty mode",
"state": {
"smart": "Smart",
"light": "Light",
"balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]",
"max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]"
}
}
},
"switch": {
"child_lock": {
"name": "Child lock"
},
"dnd_switch": {
"name": "Do not disturb"
},
"off_peak_switch": {
"name": "Off-peak charging"
},
"status_indicator": {
"name": "Status indicator light"
}
},
"time": {
"dnd_start_time": {
"name": "Do not disturb begin"
},
"dnd_end_time": {
"name": "Do not disturb end"
},
"off_peak_start": {
"name": "Off-peak start"
},
"off_peak_end": {
"name": "Off-peak end"
}
},
"vacuum": {
"roborock": {
"state_attributes": {
"fan_speed": {
"state": {
"off": "[%key:common::state::off%]",
"auto": "[%key:common::state::auto%]",
"balanced": "Balanced",
"custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]",
"gentle": "Gentle",
"max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]",
"max_plus": "Max plus",
"medium": "[%key:common::state::medium%]",
"quiet": "Quiet",
"silent": "Silent",
"standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]",
"turbo": "Turbo",
"smart_mode": "[%key:component::roborock::entity::select::mop_mode::state::smart_mode%]"
}
}
}
}
}
},
"exceptions": {
"command_failed": {
"message": "Error while calling {command}"
},
"home_data_fail": {
"message": "Failed to get Roborock home data"
},
"invalid_credentials": {
"message": "Invalid credentials."
},
"map_failure": {
"message": "Something went wrong creating the map"
},
"position_not_found": {
"message": "Robot position not found"
},
"update_data_fail": {
"message": "Failed to update data"
},
"no_coordinators": {
"message": "No devices were able to successfully setup"
},
"update_options_failed": {
"message": "Failed to update Roborock options"
},
"invalid_user_agreement": {
"message": "User agreement must be accepted again. Open your Roborock app and accept the agreement."
},
"no_user_agreement": {
"message": "You have not valid user agreement. Open your Roborock app and accept the agreement."
}
},
"services": {
"get_maps": {
"name": "Get maps",
"description": "Retrieves the map and room information of your device."
},
"set_vacuum_goto_position": {
"name": "Go to position",
"description": "Sends the vacuum to a specific position.",
"fields": {
"x": {
"name": "X-coordinate",
"description": "Coordinates are relative to the dock. x=25500,y=25500 is the dock position."
},
"y": {
"name": "Y-coordinate",
"description": "[%key:component::roborock::services::set_vacuum_goto_position::fields::x::description%]"
}
}
},
"get_vacuum_current_position": {
"name": "Get current position",
"description": "Retrieves the current position of the vacuum."
}
}
} |
See here for explanation of utlity.