diff --git a/custom_components/unifi_voucher/__init__.py b/custom_components/unifi_voucher/__init__.py index b752a2d..9cd7907 100644 --- a/custom_components/unifi_voucher/__init__.py +++ b/custom_components/unifi_voucher/__init__.py @@ -3,6 +3,10 @@ from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, +) from .const import ( DOMAIN, @@ -12,36 +16,46 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up DUniFi Hotspot Manager component.""" + """Set up UniFi Hotspot Manager component.""" hass.data.setdefault(DOMAIN, {}) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator = UnifiVoucherCoordinator( - hass=hass, - config_entry=entry, - ) - await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + try: + coordinator = UnifiVoucherCoordinator( + hass=hass, + config_entry=config_entry, + ) + await coordinator.initialize() + await coordinator.async_config_entry_first_refresh() + + except AuthenticationRequired as err: + raise ConfigEntryAuthFailed from err + + except Exception as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][config_entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + if unload_ok := await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS): # Remove config entry from domain. - hass.data[DOMAIN].pop(entry.entry_id) + hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + await async_unload_entry(hass, config_entry) + await async_setup_entry(hass, config_entry) diff --git a/custom_components/unifi_voucher/api.py b/custom_components/unifi_voucher/api.py index e0482a4..9ad84dd 100644 --- a/custom_components/unifi_voucher/api.py +++ b/custom_components/unifi_voucher/api.py @@ -3,11 +3,19 @@ import asyncio from dataclasses import dataclass +from typing import TypedDict + +from datetime import ( + timedelta, + datetime, +) from aiohttp import CookieJar import aiounifi +from aiounifi.interfaces.api_handlers import APIHandler from aiounifi.models.configuration import Configuration from aiounifi.models.api import ( + ApiItem, ApiRequest, TypedApiResponse, ) @@ -17,6 +25,7 @@ HomeAssistant, ) from homeassistant.helpers import aiohttp_client +import homeassistant.util.dt as dt_util from .const import ( LOGGER, @@ -40,6 +49,28 @@ class UnifiVoucherApiAuthenticationError(UnifiVoucherApiError): """Exception to indicate an authentication error.""" +class UnifiTypedVoucher(TypedDict): + """Voucher description.""" + + _id: str + site_id: str + note: str + code: str + quota: int + duration: int + qos_overwrite: bool + qos_usage_quota: str + qos_rate_max_up: int + qos_rate_max_down: int + used: int + create_time: datetime + start_time: int + end_time: int + for_hotspot: bool + admin_name: str + status: str + status_expires: int + @dataclass class UnifiVoucherListRequest(ApiRequest): """Request object for device list.""" @@ -54,6 +85,7 @@ def create( path="/stat/voucher", ) + @dataclass class UnifiVoucherCreateRequest(ApiRequest): """Request object for voucher create.""" @@ -80,31 +112,153 @@ def create( :param byte_quota: quantity of bytes allowed in MB :param note: description """ - params = { + data = { + "cmd": "create-voucher", "n": number, "quota": quota, "expire": "custom", "expire_number": expire, "expire_unit": 1, + "down": None, + "up": None, } if up_bandwidth: - params["up"] = up_bandwidth + data["up"] = up_bandwidth if down_bandwidth: - params["down"] = down_bandwidth + data["down"] = down_bandwidth if byte_quota: - params["bytes"] = byte_quota + data["bytes"] = byte_quota if note: - params["note"] = note + data["note"] = note return cls( method="post", path="/cmd/hotspot", - data={ - "cmd": "create-voucher", - "params": params, - }, + data=data, + ) + + +class UnifiVoucher(ApiItem): + """Represents a voucher.""" + + raw: UnifiTypedVoucher + + @property + def id(self) -> str: + """ID of voucher.""" + return self.raw["_id"] + + @property + def site_id(self) -> str: + """Site ID.""" + return self.raw["_id"] + + @property + def note(self) -> str: + """Note.""" + return self.raw.get("note") or "" + + @property + def code(self) -> str: + """Code.""" + if len(c := self.raw.get("code", "")) > 5: + return '%s-%s' % (c[:5], c[5:]) + return c + + @property + def quota(self) -> int: + """Nmber of vouchers.""" + return self.raw.get("quota", 0) + + @property + def duration(self) -> timedelta: + """Expiration of voucher.""" + return timedelta( + minutes=self.raw.get("duration", 0) + ) + + @property + def qos_overwrite(self) -> bool: + """Used count.""" + return self.raw.get("qos_overwrite", False) + + @property + def qos_usage_quota(self) -> int: + """Quantity of bytes allowed in MB.""" + return int(self.raw.get("qos_usage_quota", 0)) + + @property + def qos_rate_max_up(self) -> int: + """Up speed allowed in kbps.""" + return self.raw.get("qos_rate_max_up", 0) + + @property + def qos_rate_max_down(self) -> int: + """Down speed allowed in kbps.""" + return self.raw.get("qos_rate_max_down", 0) + + @property + def used(self) -> int: + """Number of using; 0 = unlimited.""" + return self.raw.get("used", 0) + + @property + def create_time(self) -> datetime: + """Create datetime.""" + return dt_util.as_local( + datetime.fromtimestamp(self.raw["create_time"]) ) + @property + def start_time(self) -> datetime | None: + """Start datetime.""" + if "start_time" in self.raw: + return dt_util.as_local( + datetime.fromtimestamp(self.raw["start_time"]) + ) + return None + + @property + def end_time(self) -> datetime | None: + """End datetime.""" + if "end_time" in self.raw: + return dt_util.as_local( + datetime.fromtimestamp(self.raw["end_time"]) + ) + return None + + @property + def for_hotspot(self) -> bool: + """For hotspot.""" + return self.raw.get("for_hotspot", False) + + @property + def admin_name(self) -> str: + """Admin name.""" + return self.raw.get("admin_name", "") + + @property + def status(self) -> str: + """Status.""" + return self.raw.get("status", "") + + @property + def status_expires(self) -> int | None: + """Status expires.""" + if self.raw.get("status_expires", 0) > 0: + return timedelta( + seconds=self.raw.get("status_expires") + ) + return None + + +class UnifiVouchers(APIHandler[UnifiVoucher]): + """Represent UniFi vouchers.""" + + obj_id_key = "_id" + item_cls = UnifiVoucher + api_request = UnifiVoucherListRequest.create() + class UnifiVoucherApiClient: """API Client.""" @@ -132,7 +286,7 @@ def __init__( verify_ssl=False, cookie_jar=CookieJar(unsafe=True), ) - self.api = aiounifi.Controller( + self.controller = aiounifi.Controller( Configuration( session, host=host, @@ -155,7 +309,7 @@ async def async_reconnect(self) -> None: """Try to reconnect UniFi Network session.""" try: async with asyncio.timeout(5): - await self.api.login() + await self.controller.login() except ( asyncio.TimeoutError, aiounifi.BadGateway, @@ -171,9 +325,9 @@ async def check_api_user( _sites = {} try: async with asyncio.timeout(10): - await self.api.login() - await self.api.sites.update() - for _unique_id, _site in self.api.sites.items(): + await self.controller.login() + await self.controller.sites.update() + for _unique_id, _site in self.controller.sites.items(): # User must have admin or hotspot permissions if _site.role in ("admin", "hotspot"): _sites[_unique_id] = _site @@ -226,4 +380,4 @@ async def request( api_request: ApiRequest, ) -> TypedApiResponse: """Make a request to the API, retry login on failure.""" - return await self.api.request(api_request) + return await self.controller.request(api_request) diff --git a/custom_components/unifi_voucher/button.py b/custom_components/unifi_voucher/button.py index 11153fb..1bdbcc3 100644 --- a/custom_components/unifi_voucher/button.py +++ b/custom_components/unifi_voucher/button.py @@ -46,13 +46,20 @@ async def async_setup_entry( """Do setup buttons from a config entry created in the integrations UI.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] entity_descriptions = [ - # TODO - #UnifiVoucherButtonDescription( - # key=ATTR_REBOOT, - # translation_key=ATTR_REBOOT, - # device_class=ButtonDeviceClass.RESTART, - # press_action=lambda coordinator: coordinator.async_reboot(), - #), + UnifiVoucherButtonDescription( + key="update", + icon="mdi:update", + translation_key="update", + device_class=ButtonDeviceClass.UPDATE, + press_action=lambda coordinator: coordinator.async_update_vouchers(), + ), + UnifiVoucherButtonDescription( + key="create", + icon="mdi:numeric-positive-1", + translation_key="create", + device_class=ButtonDeviceClass.RESTART, + press_action=lambda coordinator: coordinator.async_create_voucher(), + ), ] async_add_entities( diff --git a/custom_components/unifi_voucher/config_flow.py b/custom_components/unifi_voucher/config_flow.py index 9117f88..af1fb9e 100644 --- a/custom_components/unifi_voucher/config_flow.py +++ b/custom_components/unifi_voucher/config_flow.py @@ -184,7 +184,7 @@ async def async_step_site( # Abort if site is already configured self._async_abort_entries_match( { - CONF_HOST: user_input[CONF_HOST], + CONF_HOST: self.data[CONF_HOST], CONF_SITE_ID: self.sites[unique_id].name, } ) diff --git a/custom_components/unifi_voucher/const.py b/custom_components/unifi_voucher/const.py index f2d3963..e045f96 100644 --- a/custom_components/unifi_voucher/const.py +++ b/custom_components/unifi_voucher/const.py @@ -24,8 +24,9 @@ UPDATE_INTERVAL = 120 -CONF_SITE_ID = "site" +CONF_SITE_ID = "site_id" ATTR_EXTRA_STATE_ATTRIBUTES = "extra_state_attributes" +ATTR_LAST_PULL = "last_pull" ATTR_AVAILABLE = "available" -ATTR_LAST_PULL = "last_pull" \ No newline at end of file +ATTR_VOUCHER = "voucher" diff --git a/custom_components/unifi_voucher/coordinator.py b/custom_components/unifi_voucher/coordinator.py index bae7be7..c2175c1 100644 --- a/custom_components/unifi_voucher/coordinator.py +++ b/custom_components/unifi_voucher/coordinator.py @@ -21,19 +21,16 @@ DOMAIN, LOGGER, UPDATE_INTERVAL, - DEFAULT_HOST, - DEFAULT_PORT, - DEFAULT_VERIFY_SSL, - DEFAULT_SITE_ID, CONF_SITE_ID, ATTR_AVAILABLE, ATTR_LAST_PULL, ) from .api import ( UnifiVoucherApiClient, - UnifiVoucherListRequest, + UnifiVouchers, UnifiVoucherCreateRequest, UnifiVoucherApiAuthenticationError, + UnifiVoucherApiAccessError, UnifiVoucherApiConnectionError, UnifiVoucherApiError, ) @@ -57,46 +54,166 @@ def __init__( update_interval=update_interval, ) self.config_entry = config_entry - self.api = UnifiVoucherApiClient( + self.client = UnifiVoucherApiClient( hass, - host=config_entry.data.get(CONF_HOST, DEFAULT_HOST), - username=config_entry.data.get(CONF_USERNAME, ""), - password=config_entry.data.get(CONF_PASSWORD, ""), - port=int(config_entry.data.get(CONF_PORT, DEFAULT_PORT)), - site_id=config_entry.data.get(CONF_SITE_ID, DEFAULT_SITE_ID), - verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), + host=config_entry.data.get(CONF_HOST), + username=config_entry.data.get(CONF_USERNAME), + password=config_entry.data.get(CONF_PASSWORD), + port=int(config_entry.data.get(CONF_PORT)), + site_id=config_entry.data.get(CONF_SITE_ID), + verify_ssl=config_entry.data.get(CONF_VERIFY_SSL), ) + self.vouchers = {} + self.last_voucher_id = None self._last_pull = None + self._available = False + self._scheduled_update_listeners: asyncio.TimerHandle | None = None async def __aenter__(self): """Return Self.""" return self - async def __aexit__(self, *excinfo): - """Close Session before class is destroyed.""" - await self.client._session.close() - async def _async_update_data(self): """Update data via library.""" - _available = False - _data = {} + self._available = False try: - self._last_pull = dt_util.now() - _available = True - - foo = await self.api.request(UnifiVoucherListRequest.create()) - LOGGER.debug(foo) - - # TODO - #except (UnifiVoucherClientTimeoutError, UnifiVoucherClientCommunicationError, UnifiVoucherClientAuthenticationError) as exception: - # LOGGER.error(str(exception)) + # Update vouchers. + await self.async_fetch_vouchers() + + LOGGER.debug("_async_update_data") + LOGGER.debug(self.vouchers) + + return self.vouchers + except ( + UnifiVoucherApiAuthenticationError, + UnifiVoucherApiAccessError, + ) as exception: + raise ConfigEntryAuthFailed(exception) from exception + except UnifiVoucherApiError as exception: + raise UpdateFailed(exception) from exception + + async def _async_update_listeners(self) -> None: + """Schedule update all registered listeners after 1 second.""" + if self._scheduled_update_listeners: + self._scheduled_update_listeners.cancel() + self._scheduled_update_listeners = self.hass.loop.call_later( + 1, + lambda: self.async_update_listeners(), + ) + + def get_entry_id( + self, + ) -> str: + """Get unique id for config entry.""" + if self.config_entry.unique_id is not None: + return self.config_entry.unique_id + + _host = self.config_entry.data.get(CONF_HOST) + _site_id = self.config_entry.data.get(CONF_SITE_ID) + return f"{_host}_{_site_id}" + + def get_entry_title( + self, + ) -> str: + """Get title for config entry.""" + return self.config_entry.title + + def get_configuration_url( + self, + ) -> str: + """Get configuration url for config entry.""" + _host = self.config_entry.data.get(CONF_HOST) + _port = self.config_entry.data.get(CONF_PORT) + _site_id = self.config_entry.data.get(CONF_SITE_ID) + return f"https://{_host}:{_port}/network/{_site_id}/hotspot" + + async def initialize(self) -> None: + """Set up a UniFi Network instance.""" + await self.client.controller.login() + + async def async_fetch_vouchers( + self, + ) -> None: + _vouchers = {} + _last_voucher_id = None + + vouchers = UnifiVouchers(self.client.controller) + await vouchers.update() + self._last_pull = dt_util.now() + self._available = True + for voucher in vouchers.values(): + # No HA generated voucher + if not voucher.note.startswith("HA-generated"): + continue + # Voucher is full used + if voucher.quota > 0 and voucher.quota <= voucher.used: + continue + + _voucher = { + "code": voucher.code, + "quota": voucher.quota, + "duration": voucher.duration, + "qos_overwrite": voucher.qos_overwrite, + "qos_usage_quota": voucher.qos_usage_quota, + "qos_rate_max_up": voucher.qos_rate_max_up, + "qos_rate_max_down": voucher.qos_rate_max_down, + "used": voucher.used, + "create_time": voucher.create_time, + "start_time": voucher.start_time, + "end_time": voucher.end_time, + "status": voucher.status, + "status_expires": voucher.status_expires, + } + _vouchers[voucher.id] = _voucher + + for _i, _v in _vouchers.items(): + if ( + _last_voucher_id is None or + _vouchers.get(_last_voucher_id, {}).get("create_time") < _v.get("create_time") + ): + _last_voucher_id = _i + + self.vouchers = _vouchers + self.last_voucher_id = _last_voucher_id + + async def async_update_vouchers( + self, + ) -> None: + """Create new voucher.""" + try: + await self.async_fetch_vouchers() + + # Always update HA states after a command was executed. + # API calls that change the lawn mower's state update the local object when + # executing the command, so only the HA state needs further updates. + self.hass.async_create_task( + self._async_update_listeners() + ) except Exception as exception: LOGGER.exception(exception) - _data.update( - { - ATTR_LAST_PULL: self._last_pull, - ATTR_AVAILABLE: _available, - } - ) - return _data + async def async_create_voucher( + self, + number: int = 1, + quota: int = 1, + expire: int = 480, + up_bandwidth: int | None = None, + down_bandwidth: int | None = None, + byte_quota: int | None = None, + ) -> None: + """Create new voucher.""" + try: + await self.client.controller.request( + UnifiVoucherCreateRequest.create( + number=number, + quota=quota, + expire=expire, + up_bandwidth=up_bandwidth, + down_bandwidth=down_bandwidth, + byte_quota=byte_quota, + note="HA-generated", + ) + ) + await self.async_update_vouchers() + except Exception as exception: + LOGGER.exception(exception) diff --git a/custom_components/unifi_voucher/entity.py b/custom_components/unifi_voucher/entity.py index 80f4428..61a660c 100644 --- a/custom_components/unifi_voucher/entity.py +++ b/custom_components/unifi_voucher/entity.py @@ -36,30 +36,25 @@ def __init__( """Initialize.""" super().__init__(coordinator) - self._host = "unifi_voucher" # TODO + self._entry_id = coordinator.get_entry_id() self._entity_type = entity_type self._entity_key = entity_key if entity_key: - self._unique_id = slugify(f"{self._host}_{entity_key}") + self._unique_id = slugify(f"{self._entry_id}_{entity_key}") else: - self._unique_id = slugify(f"{self._host}") + self._unique_id = slugify(f"{self._entry_id}") + self._additional_extra_state_attributes = {} self.entity_id = f"{entity_type}.{self._unique_id}" - def _get_state( - self, - ) -> any: - """Get state of the current entity.""" - return self.coordinator.data.get(self._entity_key, {}).get(ATTR_STATE, None) + def _update_extra_state_attributes(self) -> None: + """Update extra attributes.""" + self._additional_extra_state_attributes = {} - def _get_attribute( - self, - attr: str, - default_value: any | None = None, - ) -> any: - """Get attribute of the current entity.""" - return self.coordinator.data.get(self._entity_key, {}).get(attr, default_value) + def _update_handler(self) -> None: + """Handle updated data.""" + self._update_extra_state_attributes() @property def unique_id(self) -> str: @@ -69,26 +64,27 @@ def unique_id(self) -> str: @property def available(self) -> bool: """Return True if entity is available.""" - return self.coordinator.data.get(ATTR_AVAILABLE) + return self.coordinator._available @property def device_info(self): """Return the device info.""" return { ATTR_IDENTIFIERS: { - (DOMAIN, self._host) + (DOMAIN, self._entry_id) }, - ATTR_NAME: self._host, + ATTR_NAME: self.coordinator.get_entry_title(), ATTR_MANUFACTURER: MANUFACTURER, + ATTR_CONFIGURATION_URL: self.coordinator.get_configuration_url(), } @property def extra_state_attributes(self) -> dict[str, any]: """Return axtra attributes.""" - _extra_state_attributes = self._get_attribute(ATTR_EXTRA_STATE_ATTRIBUTES, {}) + _extra_state_attributes = self._additional_extra_state_attributes _extra_state_attributes.update( { - ATTR_LAST_PULL: self.coordinator.data.get(ATTR_LAST_PULL), + ATTR_LAST_PULL: self.coordinator._last_pull, } ) return _extra_state_attributes @@ -102,6 +98,3 @@ def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._update_handler() self.async_write_ha_state() - - def _update_handler(self) -> None: - """Handle updated data.""" diff --git a/custom_components/unifi_voucher/sensor.py b/custom_components/unifi_voucher/sensor.py index 5031f47..4f6e39e 100644 --- a/custom_components/unifi_voucher/sensor.py +++ b/custom_components/unifi_voucher/sensor.py @@ -19,6 +19,7 @@ from .const import ( DOMAIN, + ATTR_VOUCHER, ) from .coordinator import UnifiVoucherCoordinator from .entity import UnifiVoucherEntity @@ -32,19 +33,11 @@ async def async_setup_entry( """Do setup sensors from a config entry created in the integrations UI.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] entity_descriptions = [ - # TODO - #SensorEntityDescription( - # key=ATTR_FANSPEED, - # translation_key=ATTR_FANSPEED, - # icon="mdi:fan", - # native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, - # unit_of_measurement=REVOLUTIONS_PER_MINUTE, - # state_class=SensorStateClass.MEASUREMENT, - #), SensorEntityDescription( - key="voucher", - translation_key="voucher", + key=ATTR_VOUCHER, + translation_key=ATTR_VOUCHER, icon="mdi:numeric", + device_class=None, ), ] @@ -76,7 +69,45 @@ def __init__( ) self.entity_description = entity_description + def _get_last_voucher(self) -> dict[str, any] | None: + """Get last voucher.""" + if (voucher_id := self.coordinator.last_voucher_id) in self.coordinator.vouchers: + return self.coordinator.vouchers[voucher_id] + + return None + + def _update_extra_state_attributes(self) -> None: + """Update extra attributes.""" + if (voucher := self._get_last_voucher()) is None: + return None + + _x = { + "quota": voucher.get("quota"), + "used": voucher.get("used"), + "duration": str(voucher.get("duration")), + "status": voucher.get("status"), + "create_time": voucher.get("create_time"), + } + if voucher.get("start_time") is not None: + _x["start_time"] = voucher.get("start_time") + + if voucher.get("end_time") is not None: + _x["end_time"] = voucher.get("end_time") + + if voucher.get("status_expires") is not None: + _x["status_expires"] = str(voucher.get("status_expires")) + + self._additional_extra_state_attributes = _x + @property - def native_value(self) -> str: + def available(self) -> bool: + """Return True if entity is available.""" + return (self.coordinator.last_voucher_id in self.coordinator.vouchers) + + @property + def native_value(self) -> str | None: """Return the native value of the sensor.""" - return self._get_state() + if (voucher := self._get_last_voucher()) is None: + return None + + return voucher.get("code") diff --git a/custom_components/unifi_voucher/strings.json b/custom_components/unifi_voucher/strings.json index 33d6540..f3d97bc 100644 --- a/custom_components/unifi_voucher/strings.json +++ b/custom_components/unifi_voucher/strings.json @@ -36,8 +36,16 @@ }, "entity": { "button": { - "button_name": { - "name": "Button name", + "update": { + "name": "Update", + "state_attributes": { + "last_pull": { + "name": "Last pull" + } + } + }, + "create": { + "name": "Create voucher", "state_attributes": { "last_pull": { "name": "Last pull" @@ -46,9 +54,40 @@ } }, "sensor": { - "sensor_name": { - "name": "Sensor name", + "voucher": { + "name": "Voucher", "state_attributes": { + "quota": { + "name": "Quota" + }, + "used": { + "name": "Used" + }, + "duration": { + "name": "Duration" + }, + "create_time": { + "name": "Created" + }, + "start_time": { + "name": "Started" + }, + "end_time": { + "name": "Ends" + }, + "status_expires": { + "name": "Expires" + }, + "status": { + "name": "Status", + "state": { + "USED": "Used once", + "USED_MULTIPLE": "Used multiple times", + "EXPIRED": "Expired", + "VALID_ONE": "Valid once", + "VALID_MULTI": "Valid multiple times" + } + }, "last_pull": { "name": "Last pull" }