├── .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 |
--------------------------------------------------------------------------------