pyhems 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.
pyhems-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sayurin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pyhems-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyhems
3
+ Version: 0.1.0
4
+ Summary: ECHONET Lite library for Home Energy Management System (HEMS)
5
+ Author: Sayurin
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Requires-Python: >=3.13
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: bidict>=0.23.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=7.0; extra == "dev"
18
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
19
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # pyhems
23
+
24
+ [![Python Version](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
25
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
26
+
27
+ ECHONET Lite library for Home Energy Management System (HEMS).
28
+
29
+ **[🇯🇵 日本語ドキュメント](README.ja.md)**
30
+
31
+ ## Features
32
+
33
+ - ECHONET Lite frame encoding/decoding
34
+ - UDP multicast device discovery
35
+ - Async runtime client with event subscription
36
+ - MRA (Machine Readable Appendix) data fetcher
37
+ - Full type hints (`py.typed`)
38
+
39
+ ## Requirements
40
+
41
+ - Python 3.13+
42
+ - bidict>=0.23.0
43
+
44
+ ## License
45
+
46
+ MIT License
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install pyhems
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ ```python
57
+ import asyncio
58
+ from pyhems.runtime import HemsClient, HemsInstanceListEvent
59
+
60
+ async def main():
61
+ client = HemsClient(interface="0.0.0.0")
62
+ await client.start()
63
+
64
+ def on_event(event):
65
+ if isinstance(event, HemsInstanceListEvent):
66
+ print(f"Node: {event.node_id}, Instances: {event.instances}")
67
+
68
+ unsubscribe = client.subscribe(on_event)
69
+ await asyncio.sleep(60)
70
+ unsubscribe()
71
+ await client.stop()
72
+
73
+ asyncio.run(main())
74
+ ```
pyhems-0.1.0/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # pyhems
2
+
3
+ [![Python Version](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
4
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
5
+
6
+ ECHONET Lite library for Home Energy Management System (HEMS).
7
+
8
+ **[🇯🇵 日本語ドキュメント](README.ja.md)**
9
+
10
+ ## Features
11
+
12
+ - ECHONET Lite frame encoding/decoding
13
+ - UDP multicast device discovery
14
+ - Async runtime client with event subscription
15
+ - MRA (Machine Readable Appendix) data fetcher
16
+ - Full type hints (`py.typed`)
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.13+
21
+ - bidict>=0.23.0
22
+
23
+ ## License
24
+
25
+ MIT License
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install pyhems
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ import asyncio
37
+ from pyhems.runtime import HemsClient, HemsInstanceListEvent
38
+
39
+ async def main():
40
+ client = HemsClient(interface="0.0.0.0")
41
+ await client.start()
42
+
43
+ def on_event(event):
44
+ if isinstance(event, HemsInstanceListEvent):
45
+ print(f"Node: {event.node_id}, Instances: {event.instances}")
46
+
47
+ unsubscribe = client.subscribe(on_event)
48
+ await asyncio.sleep(60)
49
+ unsubscribe()
50
+ await client.stop()
51
+
52
+ asyncio.run(main())
53
+ ```
@@ -0,0 +1,87 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyhems"
7
+ version = "0.1.0"
8
+ license = "MIT"
9
+ license-files = ["LICENSE"]
10
+ description = "ECHONET Lite library for Home Energy Management System (HEMS)"
11
+ readme = "README.md"
12
+ authors = [
13
+ {name = "Sayurin"}
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.13",
21
+ ]
22
+ requires-python = ">=3.13"
23
+ dependencies = [
24
+ "bidict>=0.23.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=7.0",
30
+ "pytest-asyncio>=0.21",
31
+ "pytest-cov>=4.0",
32
+ ]
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ asyncio_mode = "auto"
40
+ asyncio_default_fixture_loop_scope = "function"
41
+
42
+ [tool.ruff]
43
+ required-version = ">=0.13.0"
44
+
45
+ [tool.ruff.lint]
46
+ select = [
47
+ "E", # pycodestyle errors
48
+ "W", # pycodestyle warnings
49
+ "F", # pyflakes
50
+ "I", # isort
51
+ "B", # flake8-bugbear
52
+ "C4", # flake8-comprehensions
53
+ "UP", # pyupgrade
54
+ "D", # pydocstyle (docstrings)
55
+ "RUF", # ruff-specific
56
+ "ASYNC", # flake8-async
57
+ "PT", # flake8-pytest-style
58
+ "SIM", # flake8-simplify
59
+ "T20", # flake8-print
60
+ ]
61
+
62
+ ignore = [
63
+ "E501", # line too long (formatter handles this)
64
+ "D100", # Missing docstring in public module
65
+ "D104", # Missing docstring in public package
66
+ "D202", # No blank lines allowed after function docstring
67
+ "D203", # 1 blank line required before class docstring (conflicts with D211)
68
+ "D213", # Multi-line docstring summary should start at the second line (conflicts with D212)
69
+ ]
70
+
71
+ [tool.ruff.lint.per-file-ignores]
72
+ "tests/**" = ["D103", "S101"]
73
+
74
+ [tool.ruff.lint.pydocstyle]
75
+ convention = "google"
76
+
77
+ [tool.mypy]
78
+ python_version = "3.13"
79
+ strict = true
80
+
81
+ [[tool.mypy.overrides]]
82
+ module = "tests.*"
83
+ strict = false
84
+
85
+ [tool.codespell]
86
+ skip = "./.*,*.csv,*.json"
87
+ quiet-level = 2
pyhems-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,62 @@
1
+ """pyhems - ECHONET Lite library for HEMS."""
2
+
3
+ from .const import (
4
+ CONTROLLER_CLASS,
5
+ CONTROLLER_INSTANCE,
6
+ DISCOVERY_DEFAULT_EPCS,
7
+ ECHONET_MULTICAST,
8
+ ECHONET_PORT,
9
+ EPC_IDENTIFICATION_NUMBER,
10
+ EPC_INSTANCE_LIST,
11
+ EPC_MANUFACTURER_CODE,
12
+ EPC_PRODUCT_CODE,
13
+ EPC_SELF_NODE_INSTANCE_LIST,
14
+ EPC_SERIAL_NUMBER,
15
+ ESV_GET,
16
+ ESV_GET_RES,
17
+ ESV_GET_SNA,
18
+ ESV_INF,
19
+ ESV_INF_REQ,
20
+ ESV_INF_SNA,
21
+ ESV_SET_RES,
22
+ ESV_SET_SNA,
23
+ ESV_SETC,
24
+ NODE_PROFILE_CLASS,
25
+ NODE_PROFILE_INSTANCE,
26
+ )
27
+ from .frame import Frame, Property
28
+ from .transport import EchonetLiteProtocol, create_multicast_socket
29
+ from .utils import decode_ascii_property, parse_property_map
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "CONTROLLER_CLASS",
35
+ "CONTROLLER_INSTANCE",
36
+ "DISCOVERY_DEFAULT_EPCS",
37
+ "ECHONET_MULTICAST",
38
+ "ECHONET_PORT",
39
+ "EPC_IDENTIFICATION_NUMBER",
40
+ "EPC_INSTANCE_LIST",
41
+ "EPC_MANUFACTURER_CODE",
42
+ "EPC_PRODUCT_CODE",
43
+ "EPC_SELF_NODE_INSTANCE_LIST",
44
+ "EPC_SERIAL_NUMBER",
45
+ "ESV_GET",
46
+ "ESV_GET_RES",
47
+ "ESV_GET_SNA",
48
+ "ESV_INF",
49
+ "ESV_INF_REQ",
50
+ "ESV_INF_SNA",
51
+ "ESV_SETC",
52
+ "ESV_SET_RES",
53
+ "ESV_SET_SNA",
54
+ "NODE_PROFILE_CLASS",
55
+ "NODE_PROFILE_INSTANCE",
56
+ "EchonetLiteProtocol",
57
+ "Frame",
58
+ "Property",
59
+ "create_multicast_socket",
60
+ "decode_ascii_property",
61
+ "parse_property_map",
62
+ ]
@@ -0,0 +1,42 @@
1
+ """Constants for ECHONET Lite protocol and HEMS communication."""
2
+
3
+ # ECHONET Lite Transport
4
+ ECHONET_PORT = 3610
5
+ ECHONET_MULTICAST = "224.0.23.0"
6
+
7
+ # Node Profile
8
+ NODE_PROFILE_CLASS = 0x0EF0
9
+ NODE_PROFILE_INSTANCE = 0x0EF001
10
+
11
+ # Controller
12
+ CONTROLLER_CLASS = 0x05FF
13
+ CONTROLLER_INSTANCE = 0x05FF01
14
+
15
+ # ESV (Service Codes)
16
+ ESV_SET_SNA = 0x51 # Set response with some properties unavailable
17
+ ESV_GET_SNA = 0x52 # Get response with some properties unavailable
18
+ ESV_INF_SNA = 0x53 # Notification request SNA
19
+ ESV_SETC = 0x61 # Set with response
20
+ ESV_GET = 0x62 # Get request
21
+ ESV_INF_REQ = 0x63 # Notification request
22
+ ESV_SET_RES = 0x71 # Set response
23
+ ESV_GET_RES = 0x72 # Get response
24
+ ESV_INF = 0x73 # Notification
25
+
26
+ # EPC (Property Codes)
27
+ EPC_IDENTIFICATION_NUMBER = 0x83
28
+ EPC_MANUFACTURER_CODE = 0x8A
29
+ EPC_PRODUCT_CODE = 0x8C
30
+ EPC_SERIAL_NUMBER = 0x8D
31
+ EPC_INSTANCE_LIST = 0xD5
32
+ EPC_SELF_NODE_INSTANCE_LIST = 0xD6
33
+
34
+ # Default EPCs for node discovery (required for identification)
35
+ DISCOVERY_DEFAULT_EPCS: list[int] = [
36
+ EPC_IDENTIFICATION_NUMBER,
37
+ EPC_SELF_NODE_INSTANCE_LIST,
38
+ ]
39
+
40
+ # Retry settings for Get requests
41
+ GET_TIMEOUT = 5.0 # Seconds to wait for response
42
+ GET_MAX_RETRIES = 3 # Maximum retry attempts for failed properties
@@ -0,0 +1,44 @@
1
+ """ECHONET Lite device discovery utilities."""
2
+
3
+ from .const import (
4
+ EPC_IDENTIFICATION_NUMBER,
5
+ EPC_INSTANCE_LIST,
6
+ EPC_SELF_NODE_INSTANCE_LIST,
7
+ )
8
+ from .frame import Frame
9
+
10
+
11
+ def extract_discovery_info(frame: Frame) -> tuple[str | None, list[int]]:
12
+ """Extract node_id and instance list from a frame.
13
+
14
+ Args:
15
+ frame: The received frame.
16
+
17
+ Returns:
18
+ Tuple of (node_id, instances).
19
+ node_id is None if not found.
20
+ instances is a list of instance EOJs.
21
+
22
+ """
23
+ node_id: str | None = None
24
+ instances: list[int] = []
25
+
26
+ for prop in frame.properties:
27
+ if not prop.edt:
28
+ continue
29
+ if prop.epc == EPC_IDENTIFICATION_NUMBER:
30
+ # Identification number (0x83)
31
+ # Format: 1 byte protocol type (0xFE) + 16 bytes unique ID
32
+ # We use the hex string representation of the whole value
33
+ node_id = prop.edt.hex()
34
+ elif prop.epc in (EPC_INSTANCE_LIST, EPC_SELF_NODE_INSTANCE_LIST):
35
+ # Decode instance list from EDT
36
+ # Format: 1 byte count + (count * 3 bytes for each EOJ)
37
+ count = prop.edt[0]
38
+ for i in range(count):
39
+ offset = 1 + (i * 3)
40
+ if offset + 3 <= len(prop.edt):
41
+ eoj = int.from_bytes(prop.edt[offset : offset + 3], "big")
42
+ instances.append(eoj)
43
+
44
+ return node_id, instances
@@ -0,0 +1,102 @@
1
+ """ECHONET Lite protocol frame handling."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Self
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class Property:
9
+ """ECHONET Lite property in a frame."""
10
+
11
+ epc: int
12
+ edt: bytes = field(default=b"")
13
+
14
+ @property
15
+ def pdc(self) -> int:
16
+ """Return the property data counter (length of EDT)."""
17
+ return len(self.edt)
18
+
19
+ def __repr__(self) -> str:
20
+ """Return a string representation of the property."""
21
+ return f"Property(epc=0x{self.epc:02X}, edt={self.edt.hex()})"
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class Frame:
26
+ """ECHONET Lite frame structure."""
27
+
28
+ seoj: bytes
29
+ deoj: bytes
30
+ esv: int
31
+ tid: int = 0
32
+ properties: list[Property] = field(default_factory=list)
33
+
34
+ # ECHONET Lite header constants
35
+ EHD1 = 0x10 # ECHONET Lite
36
+ EHD2 = 0x81 # Format 1
37
+
38
+ # Class-level TID generator
39
+ _tid_counter = 0
40
+
41
+ def is_response_frame(self) -> bool:
42
+ """Check if frame is a response (success or failure).
43
+
44
+ Success responses: 0x70-0x7F
45
+ Failure responses: 0x50-0x5F
46
+ """
47
+ return (0x70 <= self.esv <= 0x7F) or (0x50 <= self.esv <= 0x5F)
48
+
49
+ @classmethod
50
+ def next_tid(cls) -> int:
51
+ """Generate next transaction ID."""
52
+ cls._tid_counter = cls._tid_counter + 1
53
+ if cls._tid_counter > 0xFFFF:
54
+ cls._tid_counter = 1
55
+ return cls._tid_counter
56
+
57
+ @classmethod
58
+ def decode(cls, data: bytes) -> Self:
59
+ """Decode an ECHONET Lite frame from bytes."""
60
+ if len(data) < 12:
61
+ raise ValueError("Frame too short")
62
+
63
+ ehd1, ehd2 = data[0], data[1]
64
+ if ehd1 != cls.EHD1 or ehd2 != cls.EHD2:
65
+ raise ValueError(f"Invalid ECHONET Lite header: {ehd1:#x} {ehd2:#x}")
66
+
67
+ tid = int.from_bytes(data[2:4], "big")
68
+ seoj = data[4:7]
69
+ deoj = data[7:10]
70
+ esv = data[10]
71
+ opc = data[11]
72
+
73
+ properties: list[Property] = []
74
+ offset = 12
75
+ for _ in range(opc):
76
+ if offset >= len(data):
77
+ raise ValueError("Incomplete property data")
78
+ epc = data[offset]
79
+ pdc = data[offset + 1]
80
+ edt = data[offset + 2 : offset + 2 + pdc]
81
+ properties.append(Property(epc=epc, edt=edt))
82
+ offset += 2 + pdc
83
+
84
+ return cls(seoj=seoj, deoj=deoj, esv=esv, tid=tid, properties=properties)
85
+
86
+ def encode(self) -> bytes:
87
+ """Encode the frame to bytes."""
88
+ result = bytearray()
89
+ result.append(self.EHD1)
90
+ result.append(self.EHD2)
91
+ result.extend(self.tid.to_bytes(2, "big"))
92
+ result.extend(self.seoj)
93
+ result.extend(self.deoj)
94
+ result.append(self.esv)
95
+ result.append(len(self.properties))
96
+
97
+ for prop in self.properties:
98
+ result.append(prop.epc)
99
+ result.append(prop.pdc)
100
+ result.extend(prop.edt)
101
+
102
+ return bytes(result)