Rename [blueprint|Blueprint] -> [integration_blueprint|Integration blueprint] (#47)
This commit is contained in:
@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "blueprint",
|
||||
"name": "Blueprint",
|
||||
"documentation": "https://github.com/custom-components/blueprint",
|
||||
"issue_tracker": "https://github.com/custom-components/blueprint/issues",
|
||||
"dependencies": [],
|
||||
"config_flow": true,
|
||||
"codeowners": [
|
||||
"@ludeeus"
|
||||
],
|
||||
"requirements": []
|
||||
}
|
@ -1,107 +1,109 @@
|
||||
"""
|
||||
Custom integration to integrate blueprint with Home Assistant.
|
||||
|
||||
For more details about this integration, please refer to
|
||||
https://github.com/custom-components/blueprint
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Config, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import BlueprintApiClient
|
||||
|
||||
from .const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
STARTUP_MESSAGE,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: Config):
|
||||
"""Set up this integration using YAML is not supported."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up this integration using UI."""
|
||||
if hass.data.get(DOMAIN) is None:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
_LOGGER.info(STARTUP_MESSAGE)
|
||||
|
||||
username = entry.data.get(CONF_USERNAME)
|
||||
password = entry.data.get(CONF_PASSWORD)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
client = BlueprintApiClient(username, password, session)
|
||||
|
||||
coordinator = BlueprintDataUpdateCoordinator(hass, client=client)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
for platform in PLATFORMS:
|
||||
if entry.options.get(platform, True):
|
||||
coordinator.platforms.append(platform)
|
||||
hass.async_add_job(
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
)
|
||||
|
||||
entry.add_update_listener(async_reload_entry)
|
||||
return True
|
||||
|
||||
|
||||
class BlueprintDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client: BlueprintApiClient) -> None:
|
||||
"""Initialize."""
|
||||
self.api: BlueprintApiClient = client
|
||||
self.platforms = []
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data via library."""
|
||||
try:
|
||||
return await self.api.async_get_data()
|
||||
except Exception as exception:
|
||||
raise UpdateFailed() from exception
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
unloaded = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
if platform in coordinator.platforms
|
||||
]
|
||||
)
|
||||
)
|
||||
if unloaded:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unloaded
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
||||
"""
|
||||
Custom integration to integrate integration_blueprint with Home Assistant.
|
||||
|
||||
For more details about this integration, please refer to
|
||||
https://github.com/custom-components/integration_blueprint
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Config, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import IntegrationBlueprintApiClient
|
||||
|
||||
from .const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
STARTUP_MESSAGE,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: Config):
|
||||
"""Set up this integration using YAML is not supported."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up this integration using UI."""
|
||||
if hass.data.get(DOMAIN) is None:
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
_LOGGER.info(STARTUP_MESSAGE)
|
||||
|
||||
username = entry.data.get(CONF_USERNAME)
|
||||
password = entry.data.get(CONF_PASSWORD)
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
client = IntegrationBlueprintApiClient(username, password, session)
|
||||
|
||||
coordinator = BlueprintDataUpdateCoordinator(hass, client=client)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
for platform in PLATFORMS:
|
||||
if entry.options.get(platform, True):
|
||||
coordinator.platforms.append(platform)
|
||||
hass.async_add_job(
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
)
|
||||
|
||||
entry.add_update_listener(async_reload_entry)
|
||||
return True
|
||||
|
||||
|
||||
class BlueprintDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, client: IntegrationBlueprintApiClient
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.api = client
|
||||
self.platforms = []
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Update data via library."""
|
||||
try:
|
||||
return await self.api.async_get_data()
|
||||
except Exception as exception:
|
||||
raise UpdateFailed() from exception
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
unloaded = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
if platform in coordinator.platforms
|
||||
]
|
||||
)
|
||||
)
|
||||
if unloaded:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unloaded
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry."""
|
||||
await async_unload_entry(hass, entry)
|
||||
await async_setup_entry(hass, entry)
|
@ -14,7 +14,7 @@ _LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
HEADERS = {"Content-type": "application/json; charset=UTF-8"}
|
||||
|
||||
|
||||
class BlueprintApiClient:
|
||||
class IntegrationBlueprintApiClient:
|
||||
def __init__(
|
||||
self, username: str, password: str, session: aiohttp.ClientSession
|
||||
) -> None:
|
@ -1,35 +1,35 @@
|
||||
"""Binary sensor platform for blueprint."""
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
|
||||
from .const import (
|
||||
BINARY_SENSOR,
|
||||
BINARY_SENSOR_DEVICE_CLASS,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from .entity import BlueprintEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Setup binary_sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_devices([BlueprintBinarySensor(coordinator, entry)])
|
||||
|
||||
|
||||
class BlueprintBinarySensor(BlueprintEntity, BinarySensorDevice):
|
||||
"""blueprint binary_sensor class."""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the binary_sensor."""
|
||||
return f"{DEFAULT_NAME}_{BINARY_SENSOR}"
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this binary_sensor."""
|
||||
return BINARY_SENSOR_DEVICE_CLASS
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary_sensor is on."""
|
||||
return self.coordinator.data.get("title", "") == "foo"
|
||||
"""Binary sensor platform for integration_blueprint."""
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
|
||||
from .const import (
|
||||
BINARY_SENSOR,
|
||||
BINARY_SENSOR_DEVICE_CLASS,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from .entity import IntegrationBlueprintEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Setup binary_sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_devices([IntegrationBlueprintBinarySensor(coordinator, entry)])
|
||||
|
||||
|
||||
class IntegrationBlueprintBinarySensor(IntegrationBlueprintEntity, BinarySensorEntity):
|
||||
"""integration_blueprint binary_sensor class."""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the binary_sensor."""
|
||||
return f"{DEFAULT_NAME}_{BINARY_SENSOR}"
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this binary_sensor."""
|
||||
return BINARY_SENSOR_DEVICE_CLASS
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary_sensor is on."""
|
||||
return self.coordinator.data.get("title", "") == "foo"
|
@ -4,7 +4,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
import voluptuous as vol
|
||||
|
||||
from .api import BlueprintApiClient
|
||||
from .api import IntegrationBlueprintApiClient
|
||||
from .const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
@ -65,7 +65,7 @@ class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Return true if credentials is valid."""
|
||||
try:
|
||||
session = async_create_clientsession(self.hass)
|
||||
client = BlueprintApiClient(username, password, session)
|
||||
client = IntegrationBlueprintApiClient(username, password, session)
|
||||
await client.async_get_data()
|
||||
return True
|
||||
except Exception: # pylint: disable=broad-except
|
@ -1,40 +1,40 @@
|
||||
"""Constants for blueprint."""
|
||||
# Base component constants
|
||||
NAME = "Blueprint"
|
||||
DOMAIN = "blueprint"
|
||||
DOMAIN_DATA = f"{DOMAIN}_data"
|
||||
VERSION = "0.0.1"
|
||||
ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/"
|
||||
ISSUE_URL = "https://github.com/custom-components/blueprint/issues"
|
||||
|
||||
# Icons
|
||||
ICON = "mdi:format-quote-close"
|
||||
|
||||
# Device classes
|
||||
BINARY_SENSOR_DEVICE_CLASS = "connectivity"
|
||||
|
||||
# Platforms
|
||||
BINARY_SENSOR = "binary_sensor"
|
||||
SENSOR = "sensor"
|
||||
SWITCH = "switch"
|
||||
PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH]
|
||||
|
||||
|
||||
# Configuration and options
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_USERNAME = "username"
|
||||
CONF_PASSWORD = "password"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_NAME = DOMAIN
|
||||
|
||||
|
||||
STARTUP_MESSAGE = f"""
|
||||
-------------------------------------------------------------------
|
||||
{NAME}
|
||||
Version: {VERSION}
|
||||
This is a custom integration!
|
||||
If you have any issues with this you need to open an issue here:
|
||||
{ISSUE_URL}
|
||||
-------------------------------------------------------------------
|
||||
"""
|
||||
"""Constants for integration_blueprint."""
|
||||
# Base component constants
|
||||
NAME = "Integration blueprint"
|
||||
DOMAIN = "integration_blueprint"
|
||||
DOMAIN_DATA = f"{DOMAIN}_data"
|
||||
VERSION = "0.0.1"
|
||||
ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/"
|
||||
ISSUE_URL = "https://github.com/custom-components/integration_blueprint/issues"
|
||||
|
||||
# Icons
|
||||
ICON = "mdi:format-quote-close"
|
||||
|
||||
# Device classes
|
||||
BINARY_SENSOR_DEVICE_CLASS = "connectivity"
|
||||
|
||||
# Platforms
|
||||
BINARY_SENSOR = "binary_sensor"
|
||||
SENSOR = "sensor"
|
||||
SWITCH = "switch"
|
||||
PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH]
|
||||
|
||||
|
||||
# Configuration and options
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_USERNAME = "username"
|
||||
CONF_PASSWORD = "password"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_NAME = DOMAIN
|
||||
|
||||
|
||||
STARTUP_MESSAGE = f"""
|
||||
-------------------------------------------------------------------
|
||||
{NAME}
|
||||
Version: {VERSION}
|
||||
This is a custom integration!
|
||||
If you have any issues with this you need to open an issue here:
|
||||
{ISSUE_URL}
|
||||
-------------------------------------------------------------------
|
||||
"""
|
@ -4,7 +4,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from .const import DOMAIN, NAME, VERSION, ATTRIBUTION
|
||||
|
||||
|
||||
class BlueprintEntity(CoordinatorEntity):
|
||||
class IntegrationBlueprintEntity(CoordinatorEntity):
|
||||
def __init__(self, coordinator, config_entry):
|
||||
super().__init__(coordinator)
|
||||
self.config_entry = config_entry
|
12
custom_components/integration_blueprint/manifest.json
Normal file
12
custom_components/integration_blueprint/manifest.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "integration_blueprint",
|
||||
"name": "Integration blueprint",
|
||||
"documentation": "https://github.com/custom-components/integration_blueprint",
|
||||
"issue_tracker": "https://github.com/custom-components/integration_blueprint/issues",
|
||||
"dependencies": [],
|
||||
"config_flow": true,
|
||||
"codeowners": [
|
||||
"@ludeeus"
|
||||
],
|
||||
"requirements": []
|
||||
}
|
@ -1,28 +1,28 @@
|
||||
"""Sensor platform for blueprint."""
|
||||
from .const import DEFAULT_NAME, DOMAIN, ICON, SENSOR
|
||||
from .entity import BlueprintEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Setup sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_devices([BlueprintSensor(coordinator, entry)])
|
||||
|
||||
|
||||
class BlueprintSensor(BlueprintEntity):
|
||||
"""blueprint Sensor class."""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return f"{DEFAULT_NAME}_{SENSOR}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self.coordinator.data.get("body")
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the sensor."""
|
||||
return ICON
|
||||
"""Sensor platform for integration_blueprint."""
|
||||
from .const import DEFAULT_NAME, DOMAIN, ICON, SENSOR
|
||||
from .entity import IntegrationBlueprintEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Setup sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_devices([IntegrationBlueprintSensor(coordinator, entry)])
|
||||
|
||||
|
||||
class IntegrationBlueprintSensor(IntegrationBlueprintEntity):
|
||||
"""integration_blueprint Sensor class."""
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return f"{DEFAULT_NAME}_{SENSOR}"
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self.coordinator.data.get("body")
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of the sensor."""
|
||||
return ICON
|
@ -1,40 +1,40 @@
|
||||
"""Switch platform for blueprint."""
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN, ICON, SWITCH
|
||||
from .entity import BlueprintEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Setup sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_devices([BlueprintBinarySwitch(coordinator, entry)])
|
||||
|
||||
|
||||
class BlueprintBinarySwitch(BlueprintEntity, SwitchEntity):
|
||||
"""blueprint switch class."""
|
||||
|
||||
async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument
|
||||
"""Turn on the switch."""
|
||||
await self.coordinator.api.async_set_title("bar")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument
|
||||
"""Turn off the switch."""
|
||||
await self.coordinator.api.async_set_title("foo")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return f"{DEFAULT_NAME}_{SWITCH}"
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of this switch."""
|
||||
return ICON
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the switch is on."""
|
||||
return self.coordinator.data.get("title", "") == "foo"
|
||||
"""Switch platform for integration_blueprint."""
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN, ICON, SWITCH
|
||||
from .entity import IntegrationBlueprintEntity
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_devices):
|
||||
"""Setup sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_devices([IntegrationBlueprintBinarySwitch(coordinator, entry)])
|
||||
|
||||
|
||||
class IntegrationBlueprintBinarySwitch(IntegrationBlueprintEntity, SwitchEntity):
|
||||
"""integration_blueprint switch class."""
|
||||
|
||||
async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument
|
||||
"""Turn on the switch."""
|
||||
await self.coordinator.api.async_set_title("bar")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument
|
||||
"""Turn off the switch."""
|
||||
await self.coordinator.api.async_set_title("foo")
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return f"{DEFAULT_NAME}_{SWITCH}"
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of this switch."""
|
||||
return ICON
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the switch is on."""
|
||||
return self.coordinator.data.get("title", "") == "foo"
|
@ -4,7 +4,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Blueprint",
|
||||
"description": "If you need help with the configuration have a look here: https://github.com/custom-components/blueprint",
|
||||
"description": "If you need help with the configuration have a look here: https://github.com/custom-components/integration_blueprint",
|
||||
"data": {
|
||||
"username": "Username",
|
||||
"password": "Password"
|
@ -4,7 +4,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Blueprint",
|
||||
"description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/custom-components/blueprint",
|
||||
"description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/custom-components/integration_blueprint",
|
||||
"data": {
|
||||
"username": "Brukernavn",
|
||||
"password": "Passord"
|
Reference in New Issue
Block a user