sensio-lib 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ env/
12
+ venv/
13
+ ENV/
14
+ env.bak/
15
+ venv.bak/
16
+ *.egg-info/
17
+ .installed.cfg
18
+ *.egg
19
+ dist/
20
+ build/
21
+ *.whl
22
+
23
+ # Poetry / UV
24
+ poetry.lock
25
+ uv.lock
26
+
27
+ # PyInstaller
28
+ # Usually these files are written by a python script from a template
29
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ docs/_build/
71
+
72
+ # PyBuilder
73
+ target/
74
+
75
+ # Jupyter Notebook
76
+ .ipynb_checkpoints
77
+
78
+ # IPython
79
+ profile_default/
80
+ ipython_config.py
81
+
82
+ # pyenv
83
+ .python-version
84
+
85
+ # celery beat schedule file
86
+ celerybeat-schedule
87
+
88
+ # SageMath parsed files
89
+ *.sage.py
90
+
91
+ # Environments
92
+ .env
93
+ .env.*
94
+ .venv
95
+ .venv.*
96
+
97
+ # Spyder project settings
98
+ .spyderproject
99
+ .spyderworkspace
100
+
101
+ # Rope project settings
102
+ .ropeproject
103
+
104
+ # mkdocs documentation
105
+ /site
106
+
107
+ # mypy
108
+ .mypy_cache/
109
+ .dmypy.json
110
+ dmypy.json
111
+
112
+ # Pyre type checker
113
+ .pyre/
114
+
115
+ # pytype static type analyzer
116
+ .pytype/
117
+
118
+ # Cython debug symbols
119
+ cython_debug/
120
+
121
+ # Tests
122
+ tests/integration/
123
+
124
+ # Ignore vscode files
125
+ .vscode/
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: sensio-lib
3
+ Version: 0.1.0
4
+ Requires-Python: ~=3.10
5
+ Requires-Dist: pytest<9,>=8.3.3
6
+ Requires-Dist: requests<3,>=2.32.3
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Sensio Library
10
+
11
+ A small library designed to control the Sensio smart house controller. This library was developed by reverse engineering the communication between the Sensio smart home app and the controller. It retrieves all device information from the Sensio cloud, but after the initial setup, all devices can be controlled 100% locally from your LAN.
12
+
13
+ More features could probably be implemented by additional reverse engineering.
14
+
15
+ ## Features (v0.1.0)
16
+
17
+ - **Login to Sensio Cloud**
18
+ - Select between multiple projects
19
+ - Get a list of all light devices
20
+ - **Basic Light Control**
21
+ - Turn lights on/off (Note: no actual state of lights is implemented)
22
+
23
+ ## Device Support
24
+
25
+ This library has been tested with a Sensio X1 controller. Testing is limited, so your experience may vary.
@@ -0,0 +1,17 @@
1
+ # Sensio Library
2
+
3
+ A small library designed to control the Sensio smart house controller. This library was developed by reverse engineering the communication between the Sensio smart home app and the controller. It retrieves all device information from the Sensio cloud, but after the initial setup, all devices can be controlled 100% locally from your LAN.
4
+
5
+ More features could probably be implemented by additional reverse engineering.
6
+
7
+ ## Features (v0.1.0)
8
+
9
+ - **Login to Sensio Cloud**
10
+ - Select between multiple projects
11
+ - Get a list of all light devices
12
+ - **Basic Light Control**
13
+ - Turn lights on/off (Note: no actual state of lights is implemented)
14
+
15
+ ## Device Support
16
+
17
+ This library has been tested with a Sensio X1 controller. Testing is limited, so your experience may vary.
@@ -0,0 +1,57 @@
1
+ from sensio_lib import Light
2
+ from sensio_lib.light_state import LightState
3
+ from sensio_lib.hub import Hub
4
+ import logging
5
+
6
+ # Configure the root logger
7
+ logging.basicConfig(
8
+ level=logging.INFO,
9
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
10
+ handlers=[logging.StreamHandler()]
11
+ )
12
+ logger = logging.getLogger(__name__)
13
+
14
+ def main():
15
+ logger.info("Starting Sensio integration")
16
+
17
+ # Configure the system. Replace the IP address with the IP address of your Sensio hub on your LAN.
18
+ # Replace SENSIO_USERNAME and SENSIO_PASSWORD with your Sensio credentials (same username and password you use to log in to the Sensio app)
19
+ hub_adress = '192.168.xx.xx'
20
+ username = 'SENSIO_USERNAME'
21
+ password = 'SENSIO_PASSWORD'
22
+ hub = Hub(hub_adress, username, password)
23
+
24
+ # Login to Sensio cloud
25
+ hub.login()
26
+
27
+ # Retrieve all projects
28
+ if not hub.projects or len(hub.projects) == 0:
29
+ logger.error("No projects found")
30
+ return
31
+ else:
32
+ logger.info(hub.projects)
33
+
34
+ # Set the first project as the active project. Note: If you have multiple projects, you can select the project you want to control instead by using hub.projects['my_project_name'].
35
+ # All available projects should be listed by the log command above when the script is run
36
+ project = hub.projects.popitem()[1]
37
+
38
+ # By setting the project in the hub the hub will add all lights to the hub.lights list
39
+ hub.set_project(project)
40
+
41
+ [logger.info(f'Light {i}: {light}') for i, light in enumerate(hub.lights)]
42
+ while True:
43
+ light = input("Enter light number: ")
44
+ if light.isdigit():
45
+ light = int(light)
46
+ if light < len(hub.lights):
47
+ if hub.lights[light].get_light_state() == LightState.ON or hub.lights[light].get_light_state() == LightState.UNKNOWN:
48
+ logger.info(f'Light state is {hub.lights[light].get_light_state()}, turning off')
49
+ hub.lights[light].turn_off()
50
+ else:
51
+ logger.info(f'Light state is {hub.lights[light].get_light_state()}, turning on')
52
+ hub.lights[light].turn_on()
53
+ else:
54
+ logger.error('Invalid light number to toggle')
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sensio-lib"
7
+ version = "0.1.0"
8
+ description = ""
9
+ requires-python = "~=3.10"
10
+ readme = "README.md"
11
+ dependencies = [
12
+ "requests>=2.32.3,<3",
13
+ "pytest>=8.3.3,<9",
14
+ ]
@@ -0,0 +1,5 @@
1
+ from sensio_lib.light import Light
2
+ from sensio_lib.hub import Hub
3
+ from sensio_lib.light_state import LightState
4
+
5
+ __all__ = ['Light', 'Hub', 'LightState']
@@ -0,0 +1,18 @@
1
+
2
+ SENSIO_AGENT_REQUEST = {
3
+ "DeviceName": "sdk_gphone_x86",
4
+ "DeviceId": "97e2cd6e-002d-4cfe-a596-5eb7a34c7582",
5
+ "DeviceOsVersion": "11 (sdk_gphone_x86-userdebug 11 RSR1.210722.013.A2 10067904 dev-keys)",
6
+ "DeviceType": "Android",
7
+ "ClientType": "SensioHaPanelApp",
8
+ "ClientVersion": "6.12.2 (320)"
9
+ }
10
+
11
+ # URLS
12
+ SENSIO_BASE_URL = 'https://unity.sensio.no/api/v1'
13
+ SENSIO_TOKEN_URL = 'https://unity.sensio.no/api/v1/tokens'
14
+ SENSIO_PROJECTS_URL = 'https://unity.sensio.no/api/v1/users/self/projects?include=controllers&installerProjectsOnly=false&siteIdentifier=self'
15
+
16
+ # Hex
17
+ COMMAND_PREFIX = '\x01'
18
+ COMMAND_POSTFIX = '\x02'
@@ -0,0 +1,85 @@
1
+ from sensio_lib.const import SENSIO_AGENT_REQUEST, SENSIO_TOKEN_URL, SENSIO_PROJECTS_URL, SENSIO_BASE_URL
2
+ from sensio_lib.light import Light
3
+ import requests
4
+ import logging
5
+ import base64
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class Hub:
10
+ def __init__(self, server_address: str, username: str, password: str):
11
+ self.username = username
12
+ self.password = password
13
+ self.server_address = server_address
14
+
15
+ def login(self) -> list:
16
+ ''' Authenticate with the Sensio API and retrieve a list of projects '''
17
+
18
+ self.token = self._get_auth_token()
19
+ return self._get_projects()
20
+
21
+ def get_lights(self, project_id) -> list:
22
+ ''' Retrieve a list of lights for the specified project '''
23
+
24
+ entities_url = f'{SENSIO_BASE_URL}/projects/{project_id}/functions'
25
+ entities_response = requests.get(entities_url, headers={'Authorization': f'Token {self.token}'})
26
+ if entities_response.status_code != 200:
27
+ raise Exception("Failed to retrieve entities")
28
+
29
+ logger.debug(entities_response.json())
30
+
31
+ switches = self._create_function_list(entities_response.json())
32
+ return [Light(switch['switch']['name'], switch['switch']['command_on'], switch['switch']['command_off'], self.server_address) for switch in switches]
33
+
34
+
35
+ def _get_auth_token(self):
36
+ response = requests.post(SENSIO_TOKEN_URL, auth=(self.username, self.password), json=SENSIO_AGENT_REQUEST, headers={"Content-Type": "application/json"})
37
+ if response.status_code != 200:
38
+ raise Exception("Failed to retrieve token")
39
+
40
+ token_str = f'{response.json()["tokenId"]}:{response.json()["tokenSecret"]}'
41
+ token_bytes = token_str.encode('utf-8')
42
+ return base64.b64encode(token_bytes).decode('utf-8')
43
+
44
+ def _get_projects(self):
45
+ if not self.token:
46
+ raise Exception("Token not set")
47
+
48
+ # Get a list of projects
49
+ project_response = requests.get(SENSIO_PROJECTS_URL, headers={'Authorization': f'Token {self.token}'})
50
+ if project_response.status_code != 200:
51
+ raise Exception("Failed to retrieve projects")
52
+
53
+ # We want to populate projects with the project ID and project name for each project
54
+ return {project['name']: project['projectId'] for project in project_response.json()['projects']}
55
+
56
+
57
+ def _create_function_list(self, entity_json) -> list:
58
+ ''' Create a list of functions from the entity data. This is.....ugly.'''
59
+
60
+ switch_info = []
61
+ intermediate_switches = {}
62
+
63
+ for switch in entity_json['functions']:
64
+ name = switch['name']
65
+ sub_type = switch['subType']
66
+ # Strip postfix to get the base name
67
+ base_name = name.rsplit('_', 1)[0]
68
+ # Determine command based on postfix
69
+ if sub_type in ['light_on', 'light_off', 'LightOffRoom', 'LightOnRoom']:
70
+ if base_name not in intermediate_switches:
71
+ intermediate_switches[base_name] = {
72
+ 'name': base_name,
73
+ 'command_on': '',
74
+ 'command_off': ''
75
+ }
76
+
77
+ if name.endswith('_OFF'):
78
+ intermediate_switches[base_name]['command_off'] = switch["address"]
79
+ elif name.endswith('_ON'):
80
+ intermediate_switches[base_name]['command_on'] = switch["address"]
81
+
82
+ if intermediate_switches[base_name]['command_on'] and intermediate_switches[base_name]['command_off']:
83
+ switch_info.append({'switch': intermediate_switches.pop(base_name)})
84
+
85
+ return switch_info
@@ -0,0 +1,32 @@
1
+ from sensio_lib.light_state import LightState
2
+ from sensio_lib.socket_manager import SocketManager
3
+ import uuid
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class Light:
9
+ def __init__(self, name, on_address, off_address, server_address=None):
10
+ self.state = LightState.UNKNOWN
11
+ self.id = uuid.uuid4()
12
+ self.name = name
13
+ self.on_address = on_address
14
+ self.off_address = off_address
15
+ self.server_address = server_address
16
+ self.socket_manager = SocketManager(self.server_address)
17
+
18
+ def turn_on(self):
19
+ logger.debug(f"Turning on light with ID: {self.name}")
20
+ self.socket_manager.send_command(f'new_state {self.on_address} 0')
21
+ self.state = LightState.ON
22
+
23
+ def turn_off(self):
24
+ logger.debug(f"Turning off light with ID: {self.name}")
25
+ self.socket_manager.send_command(f'new_state {self.off_address} 0')
26
+ self.state = LightState.OFF
27
+
28
+ def update(self):
29
+ logger.debug('Not implemented')
30
+
31
+ def __repr__(self):
32
+ return f'LightEntity: {self.name} - {self.state}'
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+ class LightState(Enum):
4
+ ON = "on"
5
+ OFF = "off"
6
+ UNKNOWN = "unknown"
@@ -0,0 +1,25 @@
1
+ import socket
2
+ from sensio_lib.const import COMMAND_PREFIX, COMMAND_POSTFIX
3
+
4
+ class SocketManager:
5
+ _instance = None
6
+
7
+ def __new__(cls, server_address):
8
+ if cls._instance is None:
9
+ cls._instance = super(SocketManager, cls).__new__(cls)
10
+ cls._instance.server_address = server_address
11
+ cls._instance.server_port = 10023
12
+ cls._instance.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
13
+ cls._instance.socket.settimeout(15)
14
+ cls._instance.socket.connect((server_address, 10023))
15
+ return cls._instance
16
+
17
+ def send_command(self, command):
18
+ command = f'{COMMAND_PREFIX}{command}{COMMAND_POSTFIX}'
19
+ self.socket.sendall(command.encode('utf-8'))
20
+ #response = self.socket.recv(1024)
21
+ #return response.decode('utf-8')
22
+
23
+ def close(self):
24
+ self.socket.close()
25
+ SocketManager._instance = None
File without changes
@@ -0,0 +1,70 @@
1
+ import pytest
2
+ import requests
3
+ from unittest.mock import patch, Mock
4
+ from sensio_lib.hub import Hub
5
+
6
+ # Mock constants
7
+ SENSIO_PROJECTS_URL = "https://example.com/projects"
8
+
9
+ @pytest.fixture
10
+ def hub():
11
+ with patch('sensio_lib.hub.SENSIO_PROJECTS_URL', SENSIO_PROJECTS_URL):
12
+ with patch.object(Hub, '_get_auth_token', return_value="mocked_token"):
13
+ with patch.object(Hub, '_get_projects', return_value={}):
14
+ return Hub("1.1.1.1", "username", "password")
15
+
16
+ def test_get_projects_success(hub):
17
+ mock_response = Mock()
18
+ mock_response.status_code = 200
19
+ mock_response.json.return_value = {
20
+ 'projects': [
21
+ {'name': 'Project1', 'projectId': '123'},
22
+ {'name': 'Project2', 'projectId': '456'}
23
+ ]
24
+ }
25
+
26
+ with patch('requests.get', return_value=mock_response):
27
+ projects = hub._get_projects()
28
+ assert projects == {'Project1': '123', 'Project2': '456'}
29
+
30
+ def test_get_auth_token_success(hub):
31
+ mock_response = Mock()
32
+ mock_response.status_code = 200
33
+ mock_response.json.return_value = {
34
+ 'tokenId': 'mocked_token_id',
35
+ 'tokenSecret': 'mocked_token_secret'
36
+ }
37
+
38
+ with patch('requests.post', return_value=mock_response):
39
+ token = hub._get_auth_token()
40
+ assert token == "bW9ja2VkX3Rva2VuX2lkOm1vY2tlZF90b2tlbl9zZWNyZXQ="
41
+
42
+ def test_get_projects_no_token(hub):
43
+ hub.token = None
44
+ with pytest.raises(Exception, match="Token not set"):
45
+ hub._get_projects()
46
+
47
+ def test_get_projects_failure(hub):
48
+ mock_response = Mock()
49
+ mock_response.status_code = 500
50
+
51
+ with patch('requests.get', return_value=mock_response):
52
+ with pytest.raises(Exception, match="Failed to retrieve projects"):
53
+ hub._get_projects()
54
+
55
+ def test_create_function_list(hub):
56
+ mock_entity_json = {
57
+ 'functions': [
58
+ {'name': 'light_1_OFF', 'subType': 'light_off', 'address': '0'},
59
+ {'name': 'light_1_ON', 'subType': 'light_on', 'address': '1'},
60
+ {'name': 'light_2_OFF', 'subType': 'light_off', 'address': '2'},
61
+ {'name': 'light_2_ON', 'subType': 'light_on', 'address': '3'},
62
+ ]
63
+ }
64
+
65
+ light_list = hub._create_function_list(mock_entity_json)
66
+ assert light_list == [
67
+ { 'switch': {'name': 'light_1', 'command_on': '1', 'command_off': '0'}},
68
+ { 'switch': {'name': 'light_2', 'command_on': '3', 'command_off': '2'}}
69
+ ]
70
+