├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── __init__.py ├── config_flow.py ├── const.py ├── hacs.json ├── light.py ├── manifest.json ├── prismatik.py ├── strings.json ├── test_server.py └── translations └── en.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Latest HA 2 | 3 | on: 4 | # push: 5 | # branches: [ 'feature/ci' ] 6 | # pull_request: 7 | # branches: [ master ] 8 | schedule: 9 | - cron: '0 0 * * *' 10 | 11 | jobs: 12 | test_vs_hass: 13 | runs-on: ubuntu-20.04 14 | name: Test vs last Home Assistant 15 | env: 16 | HASS_IMAGE: homeassistant/home-assistant 17 | HASS_TAG: stable 18 | TEST_RESPONSE: ${{ github.workspace }}/response.log 19 | steps: 20 | - name: Check for new HA versions 21 | env: 22 | HOURS_INTERVAL: 25 23 | DHUB_URL: 'https://hub.docker.com/v2/repositories/${{ env.HASS_IMAGE }}/tags/?page_size=5&page=1&name=${{ env.HASS_TAG }}&ordering=last_updated' 24 | DHUB_RES: ${{ github.workspace }}/dhub_res.json 25 | run: | 26 | set -xe 27 | curl "$DHUB_URL" > "$DHUB_RES" 28 | 29 | # get last stable tag and flag it as new/old 30 | cat << EOF | python -- >> $GITHUB_ENV 31 | import datetime as dt 32 | import json 33 | import re 34 | 35 | with open('$DHUB_RES') as f: 36 | data = json.load(f) 37 | 38 | prev_date = dt.datetime.today() - dt.timedelta(hours = $HOURS_INTERVAL) 39 | 40 | for tag in data['results']: 41 | if tag['name'] == "$HASS_TAG": 42 | is_new = dt.datetime.strptime(tag['last_updated'], '%Y-%m-%dT%H:%M:%S.%fZ') > prev_date 43 | print(f"FORCE_RUN={int(is_new)}") 44 | break 45 | 46 | EOF 47 | 48 | - name: Check previous run if no new HA 49 | if: ${{ env.FORCE_RUN == '0' }} 50 | env: 51 | RUNS_OUT: previous_runs_list.json 52 | run: | 53 | RUNS_URL=$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/main.yml/runs?event=schedule 54 | 55 | set -xe 56 | curl -H "User-Agent: $GITHUB_REPOSITORY" "$RUNS_URL" > "$RUNS_OUT" 57 | 58 | cat << EOF | python -- >> $GITHUB_ENV 59 | import json 60 | 61 | with open('$RUNS_OUT') as f: 62 | data = json.load(f) 63 | 64 | force = data.get('total_count', 0) < 2 or data['workflow_runs'][1]['conclusion'] != 'success' 65 | print(f"FORCE_RUN={int(force)}") 66 | EOF 67 | 68 | - name: Checkout Source 69 | if: ${{ always() }} 70 | uses: actions/checkout@v2 71 | with: 72 | path: 'config/custom_components/prismatik' 73 | 74 | - name: Pull HA 75 | if: ${{ env.FORCE_RUN == '1' }} 76 | run: | 77 | set -xe 78 | docker pull "$HASS_IMAGE:$HASS_TAG" 79 | 80 | - name: Generate HA config yaml 81 | if: ${{ env.FORCE_RUN == '1' }} 82 | working-directory: config 83 | run: | 84 | set -xe 85 | cat << EOF > configuration.yaml 86 | default_config: 87 | 88 | light: 89 | - platform: prismatik 90 | host: 127.0.0.1 91 | 92 | logger: 93 | default: warning 94 | logs: 95 | custom_components.prismatik: debug 96 | EOF 97 | 98 | - name: Launch Prismatik dummy server 99 | if: ${{ env.FORCE_RUN == '1' }} 100 | working-directory: config/custom_components/prismatik 101 | run: | 102 | set -xe 103 | ./test_server.py > "$TEST_RESPONSE" & 104 | 105 | - name: Run HA for 15 seconds 106 | if: ${{ env.FORCE_RUN == '1' }} 107 | env: 108 | HASS_CONTAINER: tmphass 109 | run: | 110 | set -xe 111 | 112 | docker run \ 113 | -v "$(pwd)/config:/config" \ 114 | --rm \ 115 | --network=host \ 116 | --name "$HASS_CONTAINER" "$HASS_IMAGE:$HASS_TAG" & 117 | 118 | sleep 15 119 | 120 | docker stop "$HASS_CONTAINER" 121 | 122 | - name: Check HA Log / Server responses 123 | if: ${{ env.FORCE_RUN == '1' }} 124 | working-directory: config 125 | run: | 126 | set -xe 127 | 128 | [ -s "$TEST_RESPONSE" ] 129 | 130 | for r in `cat "$TEST_RESPONSE"` 131 | do 132 | grep -Fo "$r" home-assistant.log || exit 1 133 | done 134 | 135 | - name: Check Logos 136 | if: ${{ always() }} 137 | working-directory: config/custom_components/prismatik 138 | run: | 139 | set -xe 140 | 141 | for logo in `grep -Eo "https://raw[^\"]+" README.md` 142 | do 143 | curl -f "$logo" > /dev/null 144 | done 145 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *~ 3 | #* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |   4 |   5 |   6 | 7 |

8 | 9 |

10 | 11 | 12 |

13 | 14 | **Prismatik** 15 | 16 | uncheck `Listen only on local interface` 17 | 18 | **HA server** 19 | ```sh 20 | cd /hass/config/custom_components 21 | 22 | # for the latest version 23 | git clone --branch master --depth 1 https://github.com/zomfg/home-assistant-prismatik.git prismatik 24 | 25 | # or for a specific HA version, see https://github.com/zomfg/home-assistant-prismatik/tags for available versions 26 | git clone --branch ha-0.110 --depth 1 https://github.com/zomfg/home-assistant-prismatik.git prismatik 27 | ``` 28 | or manually download to `/hass/config/custom_components` and rename `home-assistant-prismatik` to `prismatik` 29 | 30 | or add the repo to HACS and install from there 31 | 32 | **HA config** 33 | you can configure the integration through UI 34 | or with YAML 35 | ```yaml 36 | light: 37 | - platform: prismatik 38 | host: 192.168.42.42 39 | 40 | # optional 41 | port: 3636 42 | 43 | # optional 44 | name: "Prismatik" 45 | 46 | # optional 47 | api_key: '{API_KEY}' 48 | 49 | # optional: profile name to use so other profiles don't get altered 50 | profile_name: hass 51 | ``` 52 | 53 | Initially tested on HA 0.105.4 and Prismatik [5.2.11.21](https://github.com/psieg/Lightpack/releases/tag/5.11.2.21) 54 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Prismatik integration. 3 | https://github.com/psieg/Lightpack 4 | """ 5 | import asyncio 6 | from homeassistant.config_entries import SOURCE_IMPORT 7 | from homeassistant.const import Platform 8 | from .const import DOMAIN 9 | 10 | PLATFORMS = [Platform.LIGHT] 11 | 12 | async def async_setup(hass, config): 13 | """Set up the Prismatik integration.""" 14 | conf = config.get(DOMAIN) 15 | if conf is not None: 16 | hass.async_create_task( 17 | hass.config_entries.flow.async_init( 18 | DOMAIN, context={"source": SOURCE_IMPORT}, data=conf 19 | ) 20 | ) 21 | 22 | return True 23 | 24 | 25 | 26 | async def async_setup_entry(hass, entry): 27 | """Set up Prismatik platform.""" 28 | config = {} 29 | for key, value in entry.data.items(): 30 | config[key] = value 31 | for key, value in entry.options.items(): 32 | config[key] = value 33 | if entry.options: 34 | hass.config_entries.async_update_entry(entry, data=config, options={}) 35 | 36 | hass.data.setdefault(DOMAIN, {}) 37 | hass.data[DOMAIN][entry.entry_id] = config 38 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 39 | 40 | return True 41 | -------------------------------------------------------------------------------- /config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Prismatik integration.""" 2 | import voluptuous as vol 3 | 4 | from homeassistant import config_entries, core, exceptions 5 | from homeassistant.const import ( 6 | CONF_API_KEY, 7 | CONF_HOST, 8 | CONF_NAME, 9 | CONF_PORT, 10 | CONF_PROFILE_NAME 11 | ) 12 | from homeassistant.core import callback 13 | 14 | from .const import ( 15 | DEFAULT_NAME, 16 | DEFAULT_PORT, 17 | DEFAULT_PROFILE_NAME, 18 | DOMAIN 19 | ) 20 | 21 | from .light import PrismatikClient 22 | 23 | 24 | async def validate_input(data): 25 | """Validate the user input allows us to connect. 26 | 27 | Data has the keys from DATA_SCHEMA with values provided by the user. 28 | """ 29 | client = PrismatikClient( 30 | data[CONF_HOST], 31 | data[CONF_PORT], 32 | data[CONF_API_KEY] 33 | ) 34 | await client.is_on() 35 | if not client.is_reachable: 36 | raise CannotConnect 37 | if not client.is_connected: 38 | raise InvalidApiKey 39 | 40 | 41 | class PrismatikFlow: # pylint: disable=too-few-public-methods 42 | """Prismatik Flow.""" 43 | 44 | def __init__(self): 45 | """Init.""" 46 | self._host = None 47 | self._port = DEFAULT_PORT 48 | self._name = DEFAULT_NAME 49 | self._profile_name = DEFAULT_PROFILE_NAME 50 | self._apikey = "" 51 | self._is_import = False 52 | 53 | async def async_step_user(self, user_input=None): 54 | """Handle the initial step.""" 55 | errors = {} 56 | if user_input is not None: 57 | self._host = str(user_input[CONF_HOST]) 58 | self._port = user_input[CONF_PORT] 59 | self._name = str(user_input[CONF_NAME]) 60 | self._profile_name = str(user_input[CONF_PROFILE_NAME]) 61 | self._apikey = str(user_input[CONF_API_KEY]) 62 | try: 63 | await validate_input(user_input) 64 | 65 | # host = self._host.replace(".", "_") 66 | # await self.async_set_unique_id(f"{host}_{self._port}") 67 | # self._abort_if_unique_id_configured() 68 | 69 | return self._async_create_entry(title=self._name, data=user_input) 70 | except CannotConnect: 71 | errors["base"] = "cannot_connect" 72 | except InvalidApiKey: 73 | errors["base"] = "invalid_api_key" 74 | except Exception: # pylint: disable=broad-except 75 | errors["base"] = "unknown" 76 | 77 | data_schema = vol.Schema( 78 | { 79 | vol.Required(CONF_HOST, default=self._host): str, 80 | vol.Optional(CONF_PORT, default=self._port): int, 81 | vol.Optional(CONF_API_KEY, default=self._apikey): str, 82 | vol.Optional(CONF_NAME, default=self._name): str, 83 | vol.Optional(CONF_PROFILE_NAME, default=self._profile_name): str 84 | } 85 | ) 86 | return self._async_show_form( 87 | step_id="user", data_schema=data_schema, errors=errors 88 | ) 89 | 90 | def _async_create_entry(self, title, data): 91 | pass 92 | 93 | def _async_show_form(self, step_id, data_schema, errors): 94 | pass 95 | 96 | 97 | @config_entries.HANDLERS.register(DOMAIN) 98 | class PrismatikConfigFlow(PrismatikFlow, config_entries.ConfigFlow, domain=DOMAIN): 99 | """Handle a config flow for Prismatik.""" 100 | 101 | VERSION = 1 102 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL 103 | 104 | async def async_step_import(self, user_input=None): 105 | """Handle configuration by yaml file.""" 106 | self._is_import = True 107 | return await self.async_step_user(user_input) 108 | 109 | def _async_create_entry(self, title, data): 110 | return self.async_create_entry(title=title, data=data) 111 | 112 | def _async_show_form(self, step_id, data_schema, errors): 113 | return self.async_show_form(step_id=step_id, data_schema=data_schema, errors=errors) 114 | 115 | @staticmethod 116 | @callback 117 | def async_get_options_flow(config_entry): 118 | """Options flow.""" 119 | return PrismatikOptionsFlowHandler(config_entry) 120 | 121 | class PrismatikOptionsFlowHandler(PrismatikFlow, config_entries.OptionsFlow): 122 | """Prismatik config flow options handler.""" 123 | 124 | def __init__(self, config_entry): 125 | """Initialize options flow.""" 126 | super().__init__() 127 | self._host = config_entry.data[CONF_HOST] if CONF_HOST in config_entry.data else None 128 | self._port = config_entry.data[CONF_PORT] if CONF_PORT in config_entry.data else DEFAULT_PORT 129 | self._name = config_entry.data[CONF_NAME] if CONF_NAME in config_entry.data else DEFAULT_NAME 130 | self._profile_name = config_entry.data[CONF_PROFILE_NAME] if CONF_PROFILE_NAME in config_entry.data else DEFAULT_PROFILE_NAME 131 | self._apikey = config_entry.data[CONF_API_KEY] if CONF_API_KEY in config_entry.data else "" 132 | 133 | async def async_step_init(self, user_input=None): # pylint: disable=unused-argument 134 | """Manage the options.""" 135 | return await self.async_step_user() 136 | 137 | def _async_create_entry(self, title, data): 138 | return self.async_create_entry(title=title, data=data) 139 | 140 | def _async_show_form(self, step_id, data_schema, errors): 141 | return self.async_show_form(step_id=step_id, data_schema=data_schema, errors=errors) 142 | 143 | 144 | class CannotConnect(exceptions.HomeAssistantError): # pylint: disable=too-few-public-methods 145 | """Error to indicate we cannot connect.""" 146 | 147 | 148 | class InvalidApiKey(exceptions.HomeAssistantError): # pylint: disable=too-few-public-methods 149 | """Error to indicate there is invalid API Key.""" 150 | -------------------------------------------------------------------------------- /const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Prismatik integration.""" 2 | 3 | CONNECTION_RETRY_ERRORS = 5 4 | DEFAULT_ICON_OFF = "mdi:string-lights-off" 5 | DEFAULT_ICON_ON = "mdi:string-lights" 6 | DEFAULT_NAME = "Prismatik" 7 | DEFAULT_PORT = "3636" 8 | DEFAULT_PROFILE_NAME = "hass" 9 | DOMAIN = "prismatik" 10 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Prismatik", 3 | "domains": ["light"], 4 | "render_readme": true, 5 | "iot_class": "Local Polling", 6 | "content_in_root": true 7 | } 8 | -------------------------------------------------------------------------------- /light.py: -------------------------------------------------------------------------------- 1 | """Prismatik light.""" 2 | from typing import Any, Callable, Dict, List, Optional, Set 3 | 4 | import homeassistant.helpers.config_validation as cv 5 | import homeassistant.util.color as color_util 6 | import voluptuous as vol 7 | from homeassistant.components.light import ( 8 | ATTR_BRIGHTNESS, 9 | ATTR_EFFECT, 10 | ATTR_EFFECT_LIST, 11 | ATTR_HS_COLOR, 12 | COLOR_MODE_HS, 13 | ColorMode, 14 | LightEntity, 15 | LightEntityFeature, 16 | PLATFORM_SCHEMA, 17 | ) 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.const import ( 20 | ATTR_STATE, 21 | CONF_API_KEY, 22 | CONF_HOST, 23 | CONF_NAME, 24 | CONF_PORT, 25 | CONF_PROFILE_NAME, 26 | ) 27 | from homeassistant.core import HomeAssistant 28 | 29 | from .const import ( 30 | DEFAULT_ICON_OFF, 31 | DEFAULT_ICON_ON, 32 | DEFAULT_NAME, 33 | DEFAULT_PORT, 34 | DEFAULT_PROFILE_NAME, 35 | DOMAIN 36 | ) 37 | 38 | from .prismatik import PrismatikClient 39 | 40 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 41 | { 42 | vol.Required(CONF_HOST): cv.string, 43 | vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, 44 | vol.Optional(CONF_API_KEY): cv.string, 45 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 46 | vol.Optional(CONF_PROFILE_NAME, default=DEFAULT_PROFILE_NAME): cv.string, 47 | } 48 | ) 49 | 50 | async def async_setup_platform( 51 | hass: HomeAssistant, 52 | config: Dict, 53 | async_add_entities: Callable[[List[LightEntity], bool], None], 54 | discovery_info: Optional[Any] = None, 55 | ) -> None: 56 | """Set up the Prismatik Light platform.""" 57 | # pylint: disable=unused-argument 58 | 59 | client = PrismatikClient( 60 | config[CONF_HOST], 61 | config[CONF_PORT], 62 | config.get(CONF_API_KEY) 63 | ) 64 | light = PrismatikLight(hass, config[CONF_NAME], client, config.get(CONF_PROFILE_NAME)) 65 | await light.async_update() 66 | 67 | async_add_entities([light]) 68 | 69 | async def async_setup_entry( 70 | hass: HomeAssistant, 71 | config_entry: ConfigEntry, 72 | async_add_entities: Callable[[List[LightEntity], bool], None], 73 | ) -> None: 74 | """Set up the Prismatik Light from integration.""" 75 | config = hass.data[DOMAIN][config_entry.entry_id] 76 | if config_entry.options: 77 | config.update(config_entry.options) 78 | client = PrismatikClient( 79 | config[CONF_HOST], 80 | config[CONF_PORT], 81 | config.get(CONF_API_KEY) 82 | ) 83 | light = PrismatikLight(hass, config[CONF_NAME], client, config.get(CONF_PROFILE_NAME)) 84 | await light.async_update() 85 | 86 | async_add_entities([light]) 87 | 88 | 89 | class PrismatikLight(LightEntity): 90 | """Representation of Prismatik.""" 91 | 92 | def __init__( 93 | self, 94 | hass: HomeAssistant, 95 | name: str, 96 | client: PrismatikClient, 97 | profile: Optional[str] 98 | ) -> None: 99 | """Intialize.""" 100 | self._hass = hass 101 | self._name = name 102 | self._client = client 103 | self._profile = profile 104 | 105 | host = self._client.host.replace(".", "_") 106 | self._unique_id = f"{host}_{self._client.port}" 107 | 108 | self._attr_color_mode = ColorMode.HS 109 | self._attr_supported_color_modes = set({ColorMode.HS}) 110 | self._attr_supported_features = LightEntityFeature.EFFECT 111 | 112 | self._state = { 113 | ATTR_STATE : False, 114 | ATTR_EFFECT : None, 115 | ATTR_EFFECT_LIST : None, 116 | ATTR_BRIGHTNESS : None, 117 | ATTR_HS_COLOR : None, 118 | } 119 | 120 | async def async_will_remove_from_hass(self) -> None: 121 | """Disconnect from update signal.""" 122 | await self._client.disconnect() 123 | 124 | @property 125 | def hs_color(self) -> Optional[List]: 126 | """Return the hue and saturation color value [float, float].""" 127 | return self._state[ATTR_HS_COLOR] 128 | 129 | @property 130 | def name(self) -> str: 131 | """Return the name of the light.""" 132 | return self._name 133 | 134 | @property 135 | def available(self) -> bool: 136 | """Return availability of the light.""" 137 | return self._client.is_connected 138 | 139 | @property 140 | def is_on(self) -> bool: 141 | """Return light status.""" 142 | return self._state[ATTR_STATE] 143 | 144 | @property 145 | def icon(self) -> str: 146 | """Light icon.""" 147 | return DEFAULT_ICON_ON if self.available else DEFAULT_ICON_OFF 148 | 149 | @property 150 | def unique_id(self) -> str: 151 | """Unique ID.""" 152 | return self._unique_id 153 | 154 | @property 155 | def brightness(self) -> Optional[int]: 156 | """Return the brightness of this light between 0..255.""" 157 | return self._state[ATTR_BRIGHTNESS] 158 | 159 | @property 160 | def effect_list(self) -> Optional[List]: 161 | """Return profile list.""" 162 | return self._state[ATTR_EFFECT_LIST] 163 | 164 | @property 165 | def effect(self) -> Optional[str]: 166 | """Return current profile.""" 167 | return self._state[ATTR_EFFECT] 168 | 169 | async def async_update(self) -> None: 170 | """Update light state.""" 171 | self._state[ATTR_STATE] = await self._client.is_on() 172 | 173 | self._state[ATTR_EFFECT] = await self._client.get_profile() 174 | self._state[ATTR_EFFECT_LIST] = await self._client.get_profiles() 175 | 176 | brightness = await self._client.get_brightness() 177 | self._state[ATTR_BRIGHTNESS] = round(brightness * 2.55) if brightness else None 178 | 179 | rgb = await self._client.get_color() 180 | self._state[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb) if rgb else None 181 | 182 | async def async_turn_on(self, **kwargs: Any) -> None: 183 | """Turn the light on.""" 184 | await self._client.turn_on() 185 | if ATTR_EFFECT in kwargs: 186 | await self._client.set_profile(kwargs[ATTR_EFFECT]) 187 | elif ATTR_BRIGHTNESS in kwargs: 188 | await self._client.set_brightness(round(kwargs[ATTR_BRIGHTNESS] / 2.55), self._profile) 189 | elif ATTR_HS_COLOR in kwargs: 190 | rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) 191 | await self._client.set_color(rgb, self._profile) 192 | await self._client.unlock() 193 | 194 | async def async_turn_off(self, **kwargs: Any) -> None: 195 | """Turn the light off.""" 196 | # pylint: disable=unused-argument 197 | await self._client.turn_off() 198 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "prismatik", 3 | "name": "Prismatik", 4 | "version": "2021.3.0.1", 5 | "config_flow": true, 6 | "documentation": "https://github.com/zomfg/home-assistant-prismatik", 7 | "codeowners": [ 8 | "@zomfg" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /prismatik.py: -------------------------------------------------------------------------------- 1 | """Prismatik API client""" 2 | 3 | import asyncio 4 | import logging 5 | import re 6 | from enum import Enum 7 | from typing import Any, List, Optional, Tuple 8 | 9 | from .const import CONNECTION_RETRY_ERRORS 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | class PrismatikAPI(Enum): 14 | """Prismatik API literals.""" 15 | 16 | CMD_LOCK = "lock" 17 | CMD_UNLOCK = "unlock" 18 | 19 | CMD_GET_COLOR = "colors" 20 | CMD_SET_COLOR = "color" 21 | 22 | CMD_APIKEY = "apikey" 23 | 24 | CMD_GET_PROFILE = "profile" 25 | CMD_SET_PROFILE = CMD_GET_PROFILE 26 | CMD_GET_PROFILES = "profiles" 27 | CMD_NEW_PROFILE = "newprofile" 28 | 29 | CMD_GET_BRIGHTNESS = "brightness" 30 | CMD_SET_BRIGHTNESS = CMD_GET_BRIGHTNESS 31 | 32 | CMD_GET_STATUS = "status" 33 | CMD_SET_STATUS = CMD_GET_STATUS 34 | 35 | CMD_GET_COUNTLEDS = "countleds" 36 | 37 | CMD_SET_PERSIST_ON_UNLOCK = "persistonunlock" 38 | 39 | CMD_GET_MODE = "mode" 40 | CMD_SET_MODE = CMD_GET_MODE 41 | 42 | AWR_OK = "ok" 43 | AWR_SUCCESS = "success" 44 | AWR_NOT_LOCKED = "not locked" 45 | AWR_AUTH_REQ = "authorization required" 46 | AWR_HEADER = "Lightpack API" 47 | 48 | STS_ON = "on" 49 | STS_OFF = "off" 50 | 51 | MOD_MOODLIGHT = "moodlight" 52 | 53 | def __str__(self) -> str: 54 | # pylint: disable=invalid-str-returned 55 | return self.value 56 | 57 | def __eq__(self, other: str) -> bool: 58 | # pylint: disable=comparison-with-callable 59 | return self.value == other 60 | 61 | 62 | class PrismatikClient: 63 | """Prismatik Client interface""" 64 | 65 | def __init__( 66 | self, 67 | host: str, 68 | port: int, 69 | apikey: Optional[str], 70 | ) -> None: 71 | """Intialize.""" 72 | self._host = host 73 | self._port = port 74 | self._apikey = apikey 75 | self._tcpreader = None 76 | self._tcpwriter = None 77 | self._retries = CONNECTION_RETRY_ERRORS 78 | self._api_connected = False 79 | 80 | def __del__(self) -> None: 81 | """Clean up.""" 82 | self.disconnect() 83 | 84 | async def _connect(self) -> bool: 85 | """Connect to Prismatik server.""" 86 | try: 87 | self._tcpreader, self._tcpwriter = await asyncio.open_connection(self._host, self._port) 88 | except (ConnectionRefusedError, TimeoutError, OSError): 89 | if self._retries > 0: 90 | self._retries -= 1 91 | _LOGGER.error("Could not connect to Prismatik at %s:%s", self._host, self._port) 92 | await self.disconnect() 93 | else: 94 | # check header 95 | data = await self._tcpreader.readline() 96 | header = data.decode().strip() 97 | _LOGGER.debug("GOT HEADER: %s", header) 98 | if not header.startswith(str(PrismatikAPI.AWR_HEADER)): 99 | _LOGGER.error("Bad API header") 100 | await self.disconnect() 101 | return self._tcpwriter is not None 102 | 103 | async def disconnect(self) -> None: 104 | """Disconnect from Prismatik server.""" 105 | try: 106 | if self._tcpwriter: 107 | self._tcpwriter.close() 108 | await self._tcpwriter.wait_closed() 109 | except OSError: 110 | return 111 | finally: 112 | self._tcpreader = None 113 | self._tcpwriter = None 114 | 115 | async def _send(self, buffer: str) -> Optional[str]: 116 | """Send command to Prismatik server.""" 117 | if self._tcpwriter is None and (await self._connect()) is False: 118 | return None 119 | 120 | _LOGGER.debug("SENDING: [%s]", buffer.strip()) 121 | try: 122 | self._tcpwriter.write(buffer.encode()) 123 | await self._tcpwriter.drain() 124 | await asyncio.sleep(0.01) 125 | data = await self._tcpreader.readline() 126 | answer = data.decode().strip() 127 | except OSError: 128 | if self._retries > 0: 129 | self._retries -= 1 130 | _LOGGER.error("Prismatik went away?") 131 | await self.disconnect() 132 | answer = None 133 | else: 134 | self._retries = CONNECTION_RETRY_ERRORS 135 | _LOGGER.debug("RECEIVED: [%s]", answer) 136 | if answer == PrismatikAPI.AWR_NOT_LOCKED: 137 | if await self._do_cmd(PrismatikAPI.CMD_LOCK): 138 | return await self._send(buffer) 139 | _LOGGER.error("Could not lock Prismatik") 140 | answer = None 141 | if answer == PrismatikAPI.AWR_AUTH_REQ: 142 | if self._apikey and (await self._do_cmd(PrismatikAPI.CMD_APIKEY, self._apikey)): 143 | self._api_connected = True 144 | return await self._send(buffer) 145 | _LOGGER.error("Prismatik authentication failed, check API key") 146 | answer = None 147 | else: 148 | self._api_connected = True 149 | return answer 150 | 151 | async def _get_cmd(self, cmd: PrismatikAPI) -> Optional[str]: 152 | """Execute get-command Prismatik server.""" 153 | answer = await self._send(f"get{cmd}\n") 154 | matches = re.compile(fr"{cmd}:(.+)").match(answer or "") 155 | return matches.group(1) if matches else None 156 | 157 | async def _set_cmd(self, cmd: PrismatikAPI, value: Any) -> bool: 158 | """Execute set-command Prismatik server.""" 159 | return await self._send(f"set{cmd}:{value}\n") == PrismatikAPI.AWR_OK 160 | 161 | async def _do_cmd(self, cmd: PrismatikAPI, value: Optional[Any] = None) -> bool: 162 | """Execute other command Prismatik server.""" 163 | value = f":{value}" if value else "" 164 | answer = await self._send(f"{cmd}{value}\n") 165 | return ( 166 | re.compile( 167 | fr"^({PrismatikAPI.AWR_OK}|{cmd}:{PrismatikAPI.AWR_SUCCESS})$" 168 | ).match(answer or "") 169 | is not None 170 | ) 171 | 172 | async def _set_rgb_color(self, rgb: Tuple[int,int,int]) -> bool: 173 | """Generate and execude setcolor command on Prismatik server.""" 174 | leds = await self.leds() 175 | if leds == 0: 176 | return False 177 | rgb_color = ",".join(map(str, rgb)) 178 | pixels = ";".join([f"{led}-{rgb_color}" for led in range(1, leds + 1)]) 179 | return await self._set_cmd(PrismatikAPI.CMD_SET_COLOR, pixels) 180 | 181 | @property 182 | def is_reachable(self) -> bool: 183 | """network connection status""" 184 | return self._tcpwriter is not None 185 | 186 | @property 187 | def is_connected(self) -> bool: 188 | """network ok and API is talking successfully""" 189 | return self.is_reachable and self._api_connected 190 | 191 | @property 192 | def host(self) -> str: 193 | """Host""" 194 | return self._host 195 | 196 | @property 197 | def port(self) -> int: 198 | """Port""" 199 | return self._port 200 | 201 | async def leds(self) -> int: 202 | """Return the led count of the light.""" 203 | countleds = await self._get_cmd(PrismatikAPI.CMD_GET_COUNTLEDS) 204 | return int(countleds) if countleds else 0 205 | 206 | async def is_on(self) -> bool: 207 | """ON/OFF Status.""" 208 | return await self._get_cmd(PrismatikAPI.CMD_GET_STATUS) == PrismatikAPI.STS_ON 209 | 210 | async def turn_on(self) -> bool: 211 | """Turn ON.""" 212 | return await self._set_cmd(PrismatikAPI.CMD_SET_STATUS, PrismatikAPI.STS_ON) 213 | 214 | async def turn_off(self) -> bool: 215 | """Turn OFF.""" 216 | return await self._set_cmd(PrismatikAPI.CMD_SET_STATUS, PrismatikAPI.STS_OFF) 217 | 218 | async def set_brightness(self, brightness: int, profile: Optional[str]=None) -> bool: 219 | """Set brightness (0-100).""" 220 | if not await self._set_cmd(PrismatikAPI.CMD_SET_BRIGHTNESS, brightness): 221 | return False 222 | if not profile: 223 | return True 224 | on_unlock = PrismatikAPI.STS_OFF 225 | if (await self._get_cmd(PrismatikAPI.CMD_GET_PROFILE)) == profile: 226 | on_unlock = PrismatikAPI.STS_ON 227 | return await self._set_cmd(PrismatikAPI.CMD_SET_PERSIST_ON_UNLOCK, on_unlock) 228 | 229 | async def get_brightness(self) -> Optional[int]: 230 | """Get brightness (0-100).""" 231 | brightness = await self._get_cmd(PrismatikAPI.CMD_GET_BRIGHTNESS) 232 | return int(brightness) if brightness is not None else None 233 | 234 | async def set_color(self, rgb: Tuple[int, int, int], profile: Optional[str]=None) -> bool: 235 | """Set (R,G,B) to all LEDs""" 236 | if profile: 237 | if not await self._do_cmd(PrismatikAPI.CMD_NEW_PROFILE, profile): 238 | return False 239 | if not await self._set_cmd(PrismatikAPI.CMD_SET_PERSIST_ON_UNLOCK, PrismatikAPI.STS_ON): 240 | return False 241 | return await self._set_rgb_color(rgb) 242 | 243 | async def get_color(self) -> Optional[Tuple[int,int,int]]: 244 | """Get current (R,G,B) for the first LED""" 245 | pixels = await self._get_cmd(PrismatikAPI.CMD_GET_COLOR) 246 | rgb = re.match(r"^\d+-(\d+),(\d+),(\d+);", pixels or "") 247 | return (int(rgb.group(1)), int(rgb.group(2)), int(rgb.group(3))) if rgb else None 248 | 249 | async def unlock(self) -> bool: 250 | """Unlock API""" 251 | return await self._do_cmd(PrismatikAPI.CMD_UNLOCK) 252 | 253 | async def lock(self) -> bool: 254 | """Lock API""" 255 | return await self._do_cmd(PrismatikAPI.CMD_LOCK) 256 | 257 | async def get_profiles(self) -> Optional[List]: 258 | """Get profile list""" 259 | profiles = await self._get_cmd(PrismatikAPI.CMD_GET_PROFILES) 260 | return list(filter(None, profiles.split(";"))) if profiles else None 261 | 262 | async def get_profile(self) -> Optional[str]: 263 | """Get current profile name""" 264 | return await self._get_cmd(PrismatikAPI.CMD_GET_PROFILE) 265 | 266 | async def set_profile(self, profile: str) -> bool: 267 | """Set current profile name""" 268 | if not await self._set_cmd(PrismatikAPI.CMD_SET_PERSIST_ON_UNLOCK, PrismatikAPI.STS_OFF): 269 | return False 270 | return await self._set_cmd(PrismatikAPI.CMD_SET_PROFILE, profile) 271 | -------------------------------------------------------------------------------- /strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Prismatik", 3 | "config": { 4 | "flow_title": "Prismatik Configuration", 5 | "step": { 6 | "user": { 7 | "title": "Prismatik", 8 | "description": "Configure the connection details.", 9 | "data": { 10 | "host": "[%key:common::config_flow::data::host%]", 11 | "port": "[%key:common::config_flow::data::port%]", 12 | "api_key": "[%key:common::config_flow::data::api_key%]", 13 | "name": "[%key:common::config_flow::data::name%]", 14 | "profile_name": "Profile name" 15 | } 16 | } 17 | }, 18 | "abort": { 19 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", 20 | "conn_error": "[%key:common::config_flow::error::cannot_connect%]" 21 | }, 22 | "error": { 23 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 24 | "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", 25 | "unknown": "[%key:common::config_flow::error::unknown%]" 26 | } 27 | }, 28 | "options": { 29 | "flow_title": "Prismatik Configuration", 30 | "step": { 31 | "user": { 32 | "title": "Prismatik", 33 | "description": "Configure the connection details.", 34 | "data": { 35 | "host": "[%key:common::config_flow::data::host%]", 36 | "port": "[%key:common::config_flow::data::port%]", 37 | "api_key": "[%key:common::config_flow::data::api_key%]", 38 | "name": "[%key:common::config_flow::data::name%]", 39 | "profile_name": "Profile name" 40 | } 41 | } 42 | }, 43 | "abort": { 44 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", 45 | "conn_error": "[%key:common::config_flow::error::cannot_connect%]" 46 | }, 47 | "error": { 48 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 49 | "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", 50 | "unknown": "[%key:common::config_flow::error::unknown%]" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Smol test server mimicking Prismatik""" 3 | import socket 4 | 5 | LOCAL_IP = "127.0.0.1" 6 | LOCAL_PORT = 3636 7 | 8 | def welcome(): 9 | """prismatik api welcome message""" 10 | return "Lightpack API v1.4 - Prismatik API v2.2 (type 'help' for more info)\n" 11 | 12 | LEDS = 10 13 | COLOR = '255,255,255' 14 | def getcolors(): 15 | """ 16 | getcolors 17 | colors:0-5,255,1;1-1,255,9;2-1,255,22;3-1,255,35;... 18 | """ 19 | colors = ";".join([f"{idx}-{COLOR}" for idx in range(LEDS)]) 20 | return f"colors:{colors};\n" 21 | 22 | STATUS = "on" 23 | def getstatus(): 24 | """ 25 | getstatus 26 | status:on 27 | """ 28 | return f"status:{STATUS}\n" 29 | 30 | BRIGHTNESS = 100 31 | def getbrightness(): 32 | """ 33 | getbrightness 34 | brightness:100 35 | """ 36 | return f"brightness:{BRIGHTNESS}\n" 37 | 38 | PROFILES = ['Lightpack','Призматик','Regnbåge'] 39 | PROFILE_IDX = 0 40 | def getprofile(): 41 | """ 42 | getprofile 43 | profile:hassio 44 | """ 45 | return f"profile:{PROFILES[PROFILE_IDX]}\n" 46 | 47 | def getprofiles(): 48 | """ 49 | getprofiles 50 | profiles:hassio;Lightpack; 51 | """ 52 | return f"profiles:{';'.join(PROFILES)};\n" 53 | 54 | # print all test responses 55 | print(welcome(), end='') 56 | print(getstatus(), end='') 57 | print(getprofile(), end='') 58 | print(getprofiles(), end='') 59 | print(getbrightness(), end='') 60 | print(getcolors(), end='') 61 | 62 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 63 | s.bind((LOCAL_IP, LOCAL_PORT)) 64 | s.listen(4) 65 | client, addr = s.accept() 66 | client.sendall(welcome().encode()) 67 | try: 68 | while True: 69 | data, addr = client.recvfrom(128*1024) 70 | req = data.decode().strip() 71 | resp = globals()[req]() 72 | # print(resp.strip()) 73 | client.sendall(resp.encode()) 74 | except: # pylint: disable=bare-except 75 | pass 76 | finally: 77 | s.close() 78 | -------------------------------------------------------------------------------- /translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Prismatik", 3 | "config": { 4 | "flow_title": "Prismatik Configuration", 5 | "step": { 6 | "user": { 7 | "title": "Prismatik", 8 | "description": "Configure the connection details.", 9 | "data": { 10 | "host": "Host", 11 | "port": "Port", 12 | "api_key": "API Key", 13 | "name": "Name", 14 | "profile_name": "Profile name" 15 | } 16 | } 17 | }, 18 | "abort": { 19 | "already_configured": "Service is already configured", 20 | "conn_error": "Unable to connect." 21 | }, 22 | "error": { 23 | "cannot_connect": "Unable to connect", 24 | "invalid_api_key": "Invalid API Key", 25 | "unknown": "Unknown Error" 26 | } 27 | }, 28 | "options": { 29 | "flow_title": "Prismatik Configuration", 30 | "step": { 31 | "user": { 32 | "title": "Prismatik", 33 | "description": "Configure the connection details.", 34 | "data": { 35 | "host": "Host", 36 | "port": "Port", 37 | "api_key": "API Key", 38 | "name": "Name", 39 | "profile_name": "Profile name" 40 | } 41 | } 42 | }, 43 | "abort": { 44 | "already_configured": "Service is already configured", 45 | "conn_error": "Unable to connect." 46 | }, 47 | "error": { 48 | "cannot_connect": "Unable to connect", 49 | "invalid_api_key": "Invalid API Key", 50 | "unknown": "Unknown Error" 51 | } 52 | } 53 | } 54 | --------------------------------------------------------------------------------