emerald-hws 0.0.10__tar.gz → 0.0.12__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,13 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 5
8
+
9
+ - package-ecosystem: "pip"
10
+ directory: "/"
11
+ schedule:
12
+ interval: "weekly"
13
+ open-pull-requests-limit: 5
@@ -0,0 +1,30 @@
1
+ name: "Lint"
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - "main"
7
+ pull_request:
8
+ branches:
9
+ - "main"
10
+ workflow_call: # Allow this workflow to be called by other workflows
11
+
12
+ jobs:
13
+ ruff:
14
+ name: "Ruff"
15
+ runs-on: "ubuntu-latest"
16
+ steps:
17
+ - name: "Checkout the repository"
18
+ uses: "actions/checkout@v4.2.2"
19
+
20
+ - name: "Set up Python"
21
+ uses: actions/setup-python@v5.6.0
22
+ with:
23
+ python-version: "3.11"
24
+ cache: "pip"
25
+
26
+ - name: "Install requirements"
27
+ run: python3 -m pip install -e ".[dev]"
28
+
29
+ - name: "Run"
30
+ run: python3 -m ruff check .
@@ -0,0 +1,222 @@
1
+ name: "Publish to PyPI"
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+ inputs:
8
+ test_pypi:
9
+ description: "Publish to Test PyPI instead of PyPI"
10
+ required: false
11
+ default: false
12
+ type: boolean
13
+
14
+ jobs:
15
+ # Security validation (defense in depth - primary security is OIDC)
16
+ security-check:
17
+ name: "Security Validation"
18
+ runs-on: ubuntu-latest
19
+ outputs:
20
+ is-authorized: ${{ steps.check.outputs.authorized }}
21
+ steps:
22
+ - name: "Validate repository and context"
23
+ id: check
24
+ run: |
25
+ echo "🔒 Security validation:"
26
+ echo "Repository: ${{ github.repository }}"
27
+ echo "Actor: ${{ github.actor }}"
28
+ echo "Event: ${{ github.event_name }}"
29
+ echo "Ref: ${{ github.ref }}"
30
+ echo ""
31
+
32
+ # Check repository (defense in depth - OIDC is primary security)
33
+ if [[ "${{ github.repository }}" == "ross-w/emerald_hws_py" ]]; then
34
+ echo "✅ Repository validation passed"
35
+ echo "authorized=true" >> $GITHUB_OUTPUT
36
+ else
37
+ echo "❌ Repository validation failed"
38
+ echo "Expected: ross-w/emerald_hws_py"
39
+ echo "Actual: ${{ github.repository }}"
40
+ echo "authorized=false" >> $GITHUB_OUTPUT
41
+ echo ""
42
+ echo "ℹ️ Note: Even if this check was bypassed, PyPI trusted publishing"
43
+ echo " would reject the upload due to OIDC repository mismatch."
44
+ fi
45
+
46
+ lint:
47
+ name: "Lint Check"
48
+ needs: security-check
49
+ if: needs.security-check.outputs.is-authorized == 'true'
50
+ uses: ./.github/workflows/lint.yml
51
+
52
+ smoke-test:
53
+ name: "Smoke Test"
54
+ needs: security-check
55
+ if: needs.security-check.outputs.is-authorized == 'true'
56
+ uses: ./.github/workflows/smoke-test.yml
57
+
58
+ # Additional publish-specific validation
59
+ version-check:
60
+ name: "Version Validation"
61
+ needs: security-check
62
+ if: needs.security-check.outputs.is-authorized == 'true'
63
+ runs-on: ubuntu-latest
64
+
65
+ steps:
66
+ - name: "Checkout repository"
67
+ uses: "actions/checkout@v4"
68
+ with:
69
+ # Fetch full history for setuptools-scm version detection
70
+ fetch-depth: 0
71
+
72
+ - name: "Set up Python"
73
+ uses: "actions/setup-python@v5"
74
+ with:
75
+ python-version: "3.11"
76
+ cache: "pip"
77
+
78
+ - name: "Install package"
79
+ run: |
80
+ python -m pip install --upgrade pip
81
+ python -m pip install -e .
82
+
83
+ - name: "Verify dynamic versioning for release"
84
+ run: |
85
+ python -c "
86
+ import emerald_hws
87
+ from importlib.metadata import version
88
+ pkg_version = version('emerald_hws')
89
+ print(f'📦 Detected version: {pkg_version}')
90
+
91
+ # For releases, ensure we have a proper version
92
+ if '${{ github.event_name }}' == 'release':
93
+ if 'dev' in pkg_version or '+' in pkg_version:
94
+ print('❌ Development version detected for release')
95
+ print(' This suggests the release tag may not be properly formatted')
96
+ exit(1)
97
+ else:
98
+ print('✅ Release version detected')
99
+ else:
100
+ print('ℹ️ Manual trigger - version validation skipped')
101
+ "
102
+
103
+ # Build package
104
+ build:
105
+ name: "Build Package"
106
+ needs: [security-check, lint, smoke-test, version-check]
107
+ if: needs.security-check.outputs.is-authorized == 'true'
108
+ runs-on: ubuntu-latest
109
+
110
+ steps:
111
+ - name: "Checkout repository"
112
+ uses: "actions/checkout@v4"
113
+ with:
114
+ fetch-depth: 0
115
+
116
+ - name: "Set up Python"
117
+ uses: "actions/setup-python@v5"
118
+ with:
119
+ python-version: "3.11"
120
+ cache: "pip"
121
+
122
+ - name: "Install build tools"
123
+ run: |
124
+ python -m pip install --upgrade pip
125
+ python -m pip install "packaging>=24.2"
126
+ python -m pip install build twine
127
+
128
+ - name: "Build distribution packages"
129
+ run: python -m build
130
+
131
+ - name: "Validate package"
132
+ run: |
133
+ echo "📦 Built packages:"
134
+ ls -la dist/
135
+ echo ""
136
+ echo "🔍 Package validation:"
137
+ python -m twine check dist/*
138
+ echo ""
139
+ echo "📋 Package metadata:"
140
+ python -m pip install pkginfo
141
+ python -c "
142
+ import pkginfo
143
+ import glob
144
+
145
+ for wheel in glob.glob('dist/*.whl'):
146
+ info = pkginfo.get_metadata(wheel)
147
+ print(f'Name: {info.name}')
148
+ print(f'Version: {info.version}')
149
+ print(f'Author: {info.author}')
150
+ break
151
+ "
152
+
153
+ - name: "Upload build artifacts"
154
+ uses: actions/upload-artifact@v4
155
+ with:
156
+ name: dist-packages
157
+ path: dist/
158
+ retention-days: 7
159
+
160
+ # Publish using PyPI Trusted Publishing (OIDC)
161
+ publish:
162
+ name: "Publish to PyPI"
163
+ needs: [security-check, lint, smoke-test, version-check, build]
164
+ if: needs.security-check.outputs.is-authorized == 'true'
165
+ runs-on: ubuntu-latest
166
+
167
+ # CRITICAL SECURITY: This environment provides additional protection
168
+ # beyond OIDC trusted publishing. Forks cannot access this environment.
169
+ environment:
170
+ name: pypi
171
+ url: https://pypi.org/p/emerald-hws
172
+
173
+ # CRITICAL SECURITY: These permissions enable OIDC trusted publishing
174
+ # The id-token is cryptographically tied to this specific repository
175
+ permissions:
176
+ id-token: write # Required for PyPI trusted publishing
177
+ contents: read # Required to download artifacts
178
+
179
+ steps:
180
+ - name: "Download build artifacts"
181
+ uses: actions/download-artifact@v4
182
+ with:
183
+ name: dist-packages
184
+ path: dist/
185
+
186
+ - name: "Final security and package validation"
187
+ run: |
188
+ echo "🔒 Final security context:"
189
+ echo "Repository: ${{ github.repository }}"
190
+ echo "Environment: pypi"
191
+ echo "OIDC Token: Will be generated for this specific repository"
192
+ echo ""
193
+ echo "📦 Final package validation:"
194
+ ls -la dist/
195
+ python -m pip install "packaging>=24.2"
196
+ python -m pip install twine
197
+ python -m twine check dist/*
198
+
199
+ - name: "Publish to Test PyPI"
200
+ if: github.event.inputs.test_pypi == 'true'
201
+ uses: pypa/gh-action-pypi-publish@release/v1
202
+ with:
203
+ repository-url: https://test.pypi.org/legacy/
204
+ print-hash: true
205
+
206
+ - name: "Publish to PyPI"
207
+ if: github.event.inputs.test_pypi != 'true'
208
+ uses: pypa/gh-action-pypi-publish@release/v1
209
+ with:
210
+ print-hash: true
211
+
212
+ - name: "Publication summary"
213
+ run: |
214
+ if [[ "${{ github.event.inputs.test_pypi }}" == "true" ]]; then
215
+ echo "✅ Package successfully published to Test PyPI"
216
+ echo "🔗 View at: https://test.pypi.org/project/emerald-hws/"
217
+ else
218
+ echo "✅ Package successfully published to PyPI"
219
+ echo "🔗 View at: https://pypi.org/project/emerald-hws/"
220
+ fi
221
+ echo "📦 Published from release: ${{ github.ref_name }}"
222
+ echo "🔒 Security: Verified via PyPI trusted publishing (OIDC)"
@@ -0,0 +1,34 @@
1
+ name: "Smoke Test"
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - "main"
7
+ pull_request:
8
+ branches:
9
+ - "main"
10
+ workflow_call: # Allow this workflow to be called by other workflows
11
+
12
+ jobs:
13
+ smoke-test:
14
+ name: "Import Test"
15
+ runs-on: "ubuntu-latest"
16
+ strategy:
17
+ matrix:
18
+ python-version: ["3.11", "3.12", "3.13"]
19
+
20
+ steps:
21
+ - name: "Checkout repository"
22
+ uses: "actions/checkout@v4"
23
+
24
+ - name: "Set up Python ${{ matrix.python-version }}"
25
+ uses: "actions/setup-python@v5"
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+ cache: "pip"
29
+
30
+ - name: "Install package"
31
+ run: python -m pip install -e .
32
+
33
+ - name: "Test import"
34
+ run: python -c "import emerald_hws; print('✓ emerald_hws imported successfully')"
@@ -0,0 +1,160 @@
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
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # PyCharm
156
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
159
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
+ #.idea/
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: emerald_hws
3
+ Version: 0.0.12
4
+ Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
+ Author-email: Ross Williamson <ross@inertia.net.nz>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ross-w/emerald_hws_py
8
+ Project-URL: Bug Tracker, https://github.com/ross-w/emerald_hws_py/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: boto3<2.0.0,>=1.40.0
14
+ Requires-Dist: awsiotsdk<2.0.0,>=1.24.0
15
+ Requires-Dist: requests>=2.25.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: ruff<1.0.0,>=0.12.0; extra == "dev"
18
+
19
+ # emerald_hws_py
20
+ Python package for controlling Emerald Heat Pump Hot Water Systems
21
+
22
+ ## Overview
23
+ This package provides an interface to control and monitor Emerald Heat Pump Hot Water Systems through their API and MQTT service.
24
+
25
+ ## Installation
26
+ ```bash
27
+ pip install emerald_hws
28
+ ```
29
+
30
+ ## Usage
31
+ ```python
32
+ from emerald_hws.emeraldhws import EmeraldHWS
33
+
34
+ # Basic usage with default connection settings
35
+ client = EmeraldHWS("your_email@example.com", "your_password")
36
+ client.connect()
37
+
38
+ # List all hot water systems
39
+ hws_list = client.listHWS()
40
+ print(f"Found {len(hws_list)} hot water systems")
41
+
42
+ # Get status of first HWS
43
+ hws_id = hws_list[0]
44
+ status = client.getFullStatus(hws_id)
45
+ print(f"Current temperature: {status['last_state'].get('temp_current')}")
46
+
47
+ # Turn on the hot water system
48
+ client.turnOn(hws_id)
49
+ ```
50
+
51
+ ## Configuration Options
52
+
53
+ ### Connection Timeout
54
+ The module will automatically reconnect to the MQTT service periodically to prevent stale connections. You can configure this timeout:
55
+
56
+ ```python
57
+ # Set connection timeout to 6 hours (360 minutes)
58
+ client = EmeraldHWS("your_email@example.com", "your_password", connection_timeout_minutes=360)
59
+ ```
60
+
61
+ ### Health Check
62
+ The module can proactively check for message activity and reconnect if no messages have been received for a specified period:
63
+
64
+ ```python
65
+ # Set health check to check every 30 minutes
66
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=30)
67
+
68
+ # Disable health check
69
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=0)
70
+ ```
71
+
72
+ ## Callback for Updates
73
+ You can register a callback function to be notified when the state of any hot water system changes:
74
+
75
+ ```python
76
+ def my_callback():
77
+ print("Hot water system state updated!")
78
+
79
+ client = EmeraldHWS("your_email@example.com", "your_password", update_callback=my_callback)
80
+ ```
@@ -0,0 +1,62 @@
1
+ # emerald_hws_py
2
+ Python package for controlling Emerald Heat Pump Hot Water Systems
3
+
4
+ ## Overview
5
+ This package provides an interface to control and monitor Emerald Heat Pump Hot Water Systems through their API and MQTT service.
6
+
7
+ ## Installation
8
+ ```bash
9
+ pip install emerald_hws
10
+ ```
11
+
12
+ ## Usage
13
+ ```python
14
+ from emerald_hws.emeraldhws import EmeraldHWS
15
+
16
+ # Basic usage with default connection settings
17
+ client = EmeraldHWS("your_email@example.com", "your_password")
18
+ client.connect()
19
+
20
+ # List all hot water systems
21
+ hws_list = client.listHWS()
22
+ print(f"Found {len(hws_list)} hot water systems")
23
+
24
+ # Get status of first HWS
25
+ hws_id = hws_list[0]
26
+ status = client.getFullStatus(hws_id)
27
+ print(f"Current temperature: {status['last_state'].get('temp_current')}")
28
+
29
+ # Turn on the hot water system
30
+ client.turnOn(hws_id)
31
+ ```
32
+
33
+ ## Configuration Options
34
+
35
+ ### Connection Timeout
36
+ The module will automatically reconnect to the MQTT service periodically to prevent stale connections. You can configure this timeout:
37
+
38
+ ```python
39
+ # Set connection timeout to 6 hours (360 minutes)
40
+ client = EmeraldHWS("your_email@example.com", "your_password", connection_timeout_minutes=360)
41
+ ```
42
+
43
+ ### Health Check
44
+ The module can proactively check for message activity and reconnect if no messages have been received for a specified period:
45
+
46
+ ```python
47
+ # Set health check to check every 30 minutes
48
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=30)
49
+
50
+ # Disable health check
51
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=0)
52
+ ```
53
+
54
+ ## Callback for Updates
55
+ You can register a callback function to be notified when the state of any hot water system changes:
56
+
57
+ ```python
58
+ def my_callback():
59
+ print("Hot water system state updated!")
60
+
61
+ client = EmeraldHWS("your_email@example.com", "your_password", update_callback=my_callback)
62
+ ```
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["setuptools>=70.0", "setuptools-scm>=8.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "emerald_hws"
7
+ dynamic = ["version"]
8
+ license = "MIT"
9
+ dependencies = [
10
+ "boto3>=1.40.0,<2.0.0",
11
+ "awsiotsdk>=1.24.0,<2.0.0",
12
+ "requests>=2.25.0"
13
+ ]
14
+ authors = [{ name = "Ross Williamson", email = "ross@inertia.net.nz" }]
15
+ description = "A package to manipulate and monitor Emerald Heat Pump Hot Water Systems"
16
+ readme = "README.md"
17
+ requires-python = ">=3.7"
18
+ classifiers = [
19
+ "Programming Language :: Python :: 3",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "ruff>=0.12.0,<1.0.0"
26
+ ]
27
+
28
+ [tool.setuptools]
29
+ include-package-data = true
30
+ license-files = []
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.setuptools.package-data]
36
+ "*" = ["*.*"]
37
+
38
+ [tool.setuptools_scm]
39
+ # Use default version scheme for better compatibility
40
+
41
+ [project.urls]
42
+ "Homepage" = "https://github.com/ross-w/emerald_hws_py"
43
+ "Bug Tracker" = "https://github.com/ross-w/emerald_hws_py/issues"
44
+
45
+ [tool.ruff]
46
+ # Exclude a variety of commonly ignored directories.
47
+ exclude = [
48
+ ".git",
49
+ ".ruff_cache",
50
+ "__pypackages__",
51
+ "build",
52
+ "dist",
53
+ ]
54
+ line-length = 88
55
+
56
+ [tool.ruff.lint]
57
+ # Enable flake8-bugbear (`B`) rules.
58
+ select = ["E", "F", "B"]
59
+ # Allow unused variables when underscore-prefixed.
60
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
61
+ # Ignore specific rules
62
+ ignore = [
63
+ "E501", # Line too long (handled by formatter)
64
+ "F401", # Unused imports (some imports are used in commented code)
65
+ ]
@@ -0,0 +1,8 @@
1
+ # Dependencies are now managed in pyproject.toml
2
+ # Install with: pip install -e .
3
+ # For development: pip install -e ".[dev]"
4
+ #
5
+ # This file is kept for compatibility but pyproject.toml is the source of truth
6
+ boto3>=1.40.0,<2.0.0
7
+ awsiotsdk>=1.24.0,<2.0.0
8
+ requests>=2.25.0
@@ -0,0 +1,3 @@
1
+ from .emeraldhws import EmeraldHWS
2
+
3
+ __all__ = ["EmeraldHWS"]
@@ -1,12 +1,14 @@
1
1
  import json
2
- import requests
3
- import os
4
2
  import logging
5
- import boto3
3
+ import os
6
4
  import random
7
5
  import threading
8
- from awsiot import mqtt5_client_builder, mqtt_connection_builder
9
- from awscrt import mqtt5, http, auth, io
6
+ import time
7
+
8
+ import boto3
9
+ import requests
10
+ from awscrt import mqtt5, auth, io
11
+ from awsiot import mqtt5_client_builder
10
12
 
11
13
 
12
14
  class EmeraldHWS():
@@ -22,11 +24,13 @@ class EmeraldHWS():
22
24
  MQTT_HOST = "a13v32g67itvz9-ats.iot.ap-southeast-2.amazonaws.com"
23
25
  COGNITO_IDENTITY_POOL_ID = "ap-southeast-2:f5bbb02c-c00e-4f10-acb3-e7d1b05268e8"
24
26
 
25
- def __init__(self, email, password, update_callback=None):
27
+ def __init__(self, email, password, update_callback=None, connection_timeout_minutes=720, health_check_minutes=60):
26
28
  """ Initialise the API client
27
29
  :param email: The email address for logging into the Emerald app
28
30
  :param password: The password for the supplied user account
29
31
  :param update_callback: Optional callback function to be called when an update is available
32
+ :param connection_timeout_minutes: Optional timeout in minutes before reconnecting MQTT (default: 720 minutes/12 hours)
33
+ :param health_check_minutes: Optional interval in minutes to check for message activity (default: 60 minutes/1 hour)
30
34
  """
31
35
 
32
36
  self.email = email
@@ -35,6 +39,27 @@ class EmeraldHWS():
35
39
  self.properties = {}
36
40
  self.logger = logging.getLogger()
37
41
  self.update_callback = update_callback
42
+
43
+ # Convert minutes to seconds for internal use
44
+ self.connection_timeout = connection_timeout_minutes * 60.0
45
+ self.health_check_interval = health_check_minutes * 60.0 if health_check_minutes > 0 else 0
46
+ self.last_message_time = None
47
+ self.health_check_timer = None
48
+
49
+ # Connection state tracking
50
+ self.connection_state = "initial" # possible states: initial, connected, failed
51
+ self.consecutive_failures = 0
52
+ self.max_backoff_seconds = 60 # Maximum backoff of 1 minute
53
+
54
+ # Ensure reasonable minimum values (e.g., at least 5 minutes for connection timeout)
55
+ if connection_timeout_minutes < 5 and connection_timeout_minutes != 0:
56
+ self.logger.warning("emeraldhws: Connection timeout too short, setting to minimum of 5 minutes")
57
+ self.connection_timeout = 5 * 60.0
58
+
59
+ # Ensure reasonable minimum values for health check (e.g., at least 5 minutes)
60
+ if 0 < health_check_minutes < 5:
61
+ self.logger.warning("emeraldhws: Health check interval too short, setting to minimum of 5 minutes")
62
+ self.health_check_interval = 5 * 60.0
38
63
 
39
64
  def getLoginToken(self):
40
65
  """ Performs an API request to get a token from the API
@@ -87,20 +112,48 @@ class EmeraldHWS():
87
112
 
88
113
  self.update_callback = update_callback
89
114
 
90
- def reconnectMQTT(self):
115
+ def reconnectMQTT(self, reason="scheduled"):
91
116
  """ Stops an existing MQTT connection and creates a new one
117
+ :param reason: Reason for reconnection (scheduled, health_check, etc.)
92
118
  """
93
-
94
- self.logger.debug("emeraldhws: awsiot: Tearing down and reconnecting to prevent stale connection")
119
+ self.logger.info(f"emeraldhws: awsiot: Reconnecting MQTT connection (reason: {reason})")
120
+
121
+ # Store current temperature values for comparison after reconnect
122
+ temp_values = {}
123
+ for properties in self.properties:
124
+ heat_pumps = properties.get('heat_pump', [])
125
+ for heat_pump in heat_pumps:
126
+ hws_id = heat_pump['id']
127
+ if 'last_state' in heat_pump and 'temp_current' in heat_pump['last_state']:
128
+ temp_values[hws_id] = heat_pump['last_state']['temp_current']
129
+
95
130
  self.mqttClient.stop()
96
131
  self.connectMQTT()
97
132
  self.subscribeAllHWS()
133
+
134
+ # After reconnection, check if temperatures have changed
135
+ def check_temp_changes():
136
+ for properties in self.properties:
137
+ heat_pumps = properties.get('heat_pump', [])
138
+ for heat_pump in heat_pumps:
139
+ hws_id = heat_pump['id']
140
+ if (hws_id in temp_values and
141
+ 'last_state' in heat_pump and
142
+ 'temp_current' in heat_pump['last_state']):
143
+ old_temp = temp_values[hws_id]
144
+ new_temp = heat_pump['last_state']['temp_current']
145
+ if old_temp != new_temp:
146
+ self.logger.info(f"emeraldhws: Temperature changed after reconnect for {hws_id}: {old_temp} → {new_temp}")
147
+
148
+ # Check for temperature changes after a short delay to allow for updates
149
+ threading.Timer(10.0, check_temp_changes).start()
98
150
 
99
151
  def connectMQTT(self):
100
152
  """ Establishes a connection to Amazon IOT core's MQTT service
101
153
  """
102
154
 
103
- cert_path = os.path.join(os.path.dirname(__file__), '__assets__', 'SFSRootCAG2.pem')
155
+ # Certificate path is available but not currently used in the connection
156
+ # os.path.join(os.path.dirname(__file__), '__assets__', 'SFSRootCAG2.pem')
104
157
  identityPoolID = self.COGNITO_IDENTITY_POOL_ID
105
158
  region = self.MQTT_HOST.split('.')[2]
106
159
  cognito_endpoint = "cognito-identity." + region + ".amazonaws.com"
@@ -131,7 +184,16 @@ class EmeraldHWS():
131
184
 
132
185
  client.start()
133
186
  self.mqttClient = client
134
- threading.Timer(43200.0, self.reconnectMQTT).start() # 12 hours
187
+
188
+ # Schedule periodic reconnection using configurable timeout
189
+ if self.connection_timeout > 0:
190
+ threading.Timer(self.connection_timeout, self.reconnectMQTT).start()
191
+
192
+ # Start health check timer if enabled
193
+ if self.health_check_interval > 0:
194
+ self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
195
+ self.health_check_timer.daemon = True
196
+ self.health_check_timer.start()
135
197
 
136
198
  def mqttDecodeUpdate(self, topic, payload):
137
199
  """ Attempt to decode a received MQTT message and direct appropriately
@@ -153,12 +215,15 @@ class EmeraldHWS():
153
215
  publish_packet = publish_packet_data.publish_packet
154
216
  assert isinstance(publish_packet, mqtt5.PublishPacket)
155
217
  self.logger.debug("emeraldhws: awsiot: Received message from MQTT topic {}: {}".format(publish_packet.topic, publish_packet.payload))
218
+ self.last_message_time = time.time() # Update the last message time
156
219
  self.mqttDecodeUpdate(publish_packet.topic, publish_packet.payload)
157
220
 
158
221
  def on_connection_interrupted(self, connection, error, **kwargs):
159
222
  """ Log error when MQTT is interrupted
160
223
  """
161
- self.logger.debug("emeraldhws: awsiot: Connection interrupted. error: {}".format(error))
224
+ error_code = getattr(error, 'code', 'unknown')
225
+ error_name = getattr(error, 'name', 'unknown')
226
+ self.logger.info(f"emeraldhws: awsiot: Connection interrupted. Error: {error_name} (code: {error_code}), Message: {error}")
162
227
 
163
228
  def on_connection_resumed(self, connection, return_code, session_present, **kwargs):
164
229
  """ Log message when MQTT is resumed
@@ -169,12 +234,35 @@ class EmeraldHWS():
169
234
  """ Log message when connection succeeded
170
235
  """
171
236
  self.logger.debug("emeraldhws: awsiot: connection succeeded")
237
+ # Reset failure counter and update connection state
238
+ self.consecutive_failures = 0
239
+ self.connection_state = "connected"
172
240
  return
173
241
 
174
242
  def on_lifecycle_connection_failure(self, lifecycle_connection_failure: mqtt5.LifecycleConnectFailureData):
175
243
  """ Log message when connection failed
176
244
  """
177
- self.logger.debug("emeraldhws: awsiot: connection failed")
245
+ error = lifecycle_connection_failure.error
246
+ error_code = getattr(error, 'code', 'unknown')
247
+ error_name = getattr(error, 'name', 'unknown')
248
+ error_message = str(error)
249
+
250
+ # Update connection state and increment failure counter
251
+ self.connection_state = "failed"
252
+ self.consecutive_failures += 1
253
+
254
+ # Log at INFO level since this is important for troubleshooting
255
+ self.logger.info(f"emeraldhws: awsiot: connection failed - Error: {error_name} (code: {error_code}), Message: {error_message}")
256
+
257
+ # If there's a CONNACK packet available, log its details too
258
+ if hasattr(lifecycle_connection_failure, 'connack_packet') and lifecycle_connection_failure.connack_packet:
259
+ connack = lifecycle_connection_failure.connack_packet
260
+ reason_code = getattr(connack, 'reason_code', 'unknown')
261
+ reason_string = getattr(connack, 'reason_string', '')
262
+ if reason_string:
263
+ self.logger.info(f"emeraldhws: awsiot: MQTT CONNACK reason: {reason_code} - {reason_string}")
264
+ else:
265
+ self.logger.info(f"emeraldhws: awsiot: MQTT CONNACK reason code: {reason_code}")
178
266
  return
179
267
 
180
268
  def on_lifecycle_stopped(self, lifecycle_stopped_data: mqtt5.LifecycleStoppedData):
@@ -186,14 +274,56 @@ class EmeraldHWS():
186
274
  def on_lifecycle_disconnection(self, lifecycle_disconnect_data: mqtt5.LifecycleDisconnectData):
187
275
  """ Log message when disconnected
188
276
  """
189
- self.logger.debug("emeraldhws: awsiot: disconnected")
277
+ # Extract disconnect reason if available
278
+ reason = "unknown reason"
279
+ if hasattr(lifecycle_disconnect_data, 'disconnect_packet') and lifecycle_disconnect_data.disconnect_packet:
280
+ reason_code = getattr(lifecycle_disconnect_data.disconnect_packet, 'reason_code', 'unknown')
281
+ reason_string = getattr(lifecycle_disconnect_data.disconnect_packet, 'reason_string', '')
282
+ reason = f"reason code: {reason_code}" + (f" - {reason_string}" if reason_string else "")
283
+
284
+ self.logger.info(f"emeraldhws: awsiot: disconnected - {reason}")
190
285
  return
191
286
 
192
287
  def on_lifecycle_attempting_connect(self, lifecycle_attempting_connect_data: mqtt5.LifecycleAttemptingConnectData):
193
288
  """ Log message when attempting connect
194
289
  """
195
- self.logger.debug("emeraldhws: awsiot: attempting to connect")
290
+ # Include endpoint information if available
291
+ endpoint = getattr(lifecycle_attempting_connect_data, 'endpoint', 'unknown')
292
+ self.logger.debug(f"emeraldhws: awsiot: attempting to connect to {endpoint}")
196
293
  return
294
+
295
+ def check_connection_health(self):
296
+ """ Check if we've received any messages recently, reconnect if not
297
+ """
298
+ if self.last_message_time is None:
299
+ # No messages received yet, don't reconnect
300
+ self.logger.debug("emeraldhws: awsiot: Health check - No messages received yet")
301
+ else:
302
+ current_time = time.time()
303
+ time_since_last_message = current_time - self.last_message_time
304
+ minutes_since_last = time_since_last_message / 60.0
305
+
306
+ if time_since_last_message > self.health_check_interval:
307
+ # This is an INFO level log because it's an important event
308
+ self.logger.info(f"emeraldhws: awsiot: No messages received for {minutes_since_last:.1f} minutes, reconnecting")
309
+
310
+ # If we're in a failed state, apply exponential backoff
311
+ if self.connection_state == "failed" and self.consecutive_failures > 0:
312
+ # Calculate backoff time with exponential increase, capped at max_backoff_seconds
313
+ backoff_seconds = min(2 ** (self.consecutive_failures - 1), self.max_backoff_seconds)
314
+ self.logger.info(f"emeraldhws: awsiot: Connection in failed state, applying backoff of {backoff_seconds} seconds before retry (attempt {self.consecutive_failures})")
315
+ time.sleep(backoff_seconds)
316
+
317
+ self.reconnectMQTT(reason="health_check")
318
+ else:
319
+ # This is a DEBUG level log to avoid cluttering logs
320
+ self.logger.debug(f"emeraldhws: awsiot: Health check - Last message received {minutes_since_last:.1f} minutes ago")
321
+
322
+ # Schedule next health check
323
+ if self.health_check_interval > 0:
324
+ self.health_check_timer = threading.Timer(self.health_check_interval, self.check_connection_health)
325
+ self.health_check_timer.daemon = True
326
+ self.health_check_timer.start()
197
327
 
198
328
  def updateHWSState(self, id, key, value):
199
329
  """ Updates the specified value for the supplied key in the HWS id specified
@@ -207,7 +337,7 @@ class EmeraldHWS():
207
337
  for heat_pump in heat_pumps:
208
338
  if heat_pump['id'] == id:
209
339
  heat_pump['last_state'][key] = value
210
- if self.update_callback != None:
340
+ if self.update_callback is not None:
211
341
  self.update_callback()
212
342
 
213
343
  def subscribeForUpdates(self, id):
@@ -224,7 +354,8 @@ class EmeraldHWS():
224
354
  topic_filter=mqtt_topic,
225
355
  qos=mqtt5.QoS.AT_LEAST_ONCE)]))
226
356
 
227
- suback = subscribe_future.result(20)
357
+ # Wait for subscription to complete
358
+ subscribe_future.result(20)
228
359
 
229
360
  def getFullStatus(self, id):
230
361
  """ Returns a dict with the full status of the specified HWS
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: emerald_hws
3
+ Version: 0.0.12
4
+ Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
+ Author-email: Ross Williamson <ross@inertia.net.nz>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ross-w/emerald_hws_py
8
+ Project-URL: Bug Tracker, https://github.com/ross-w/emerald_hws_py/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: boto3<2.0.0,>=1.40.0
14
+ Requires-Dist: awsiotsdk<2.0.0,>=1.24.0
15
+ Requires-Dist: requests>=2.25.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: ruff<1.0.0,>=0.12.0; extra == "dev"
18
+
19
+ # emerald_hws_py
20
+ Python package for controlling Emerald Heat Pump Hot Water Systems
21
+
22
+ ## Overview
23
+ This package provides an interface to control and monitor Emerald Heat Pump Hot Water Systems through their API and MQTT service.
24
+
25
+ ## Installation
26
+ ```bash
27
+ pip install emerald_hws
28
+ ```
29
+
30
+ ## Usage
31
+ ```python
32
+ from emerald_hws.emeraldhws import EmeraldHWS
33
+
34
+ # Basic usage with default connection settings
35
+ client = EmeraldHWS("your_email@example.com", "your_password")
36
+ client.connect()
37
+
38
+ # List all hot water systems
39
+ hws_list = client.listHWS()
40
+ print(f"Found {len(hws_list)} hot water systems")
41
+
42
+ # Get status of first HWS
43
+ hws_id = hws_list[0]
44
+ status = client.getFullStatus(hws_id)
45
+ print(f"Current temperature: {status['last_state'].get('temp_current')}")
46
+
47
+ # Turn on the hot water system
48
+ client.turnOn(hws_id)
49
+ ```
50
+
51
+ ## Configuration Options
52
+
53
+ ### Connection Timeout
54
+ The module will automatically reconnect to the MQTT service periodically to prevent stale connections. You can configure this timeout:
55
+
56
+ ```python
57
+ # Set connection timeout to 6 hours (360 minutes)
58
+ client = EmeraldHWS("your_email@example.com", "your_password", connection_timeout_minutes=360)
59
+ ```
60
+
61
+ ### Health Check
62
+ The module can proactively check for message activity and reconnect if no messages have been received for a specified period:
63
+
64
+ ```python
65
+ # Set health check to check every 30 minutes
66
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=30)
67
+
68
+ # Disable health check
69
+ client = EmeraldHWS("your_email@example.com", "your_password", health_check_minutes=0)
70
+ ```
71
+
72
+ ## Callback for Updates
73
+ You can register a callback function to be notified when the state of any hot water system changes:
74
+
75
+ ```python
76
+ def my_callback():
77
+ print("Hot water system state updated!")
78
+
79
+ client = EmeraldHWS("your_email@example.com", "your_password", update_callback=my_callback)
80
+ ```
@@ -1,6 +1,12 @@
1
+ .gitignore
1
2
  LICENSE
2
3
  README.md
3
4
  pyproject.toml
5
+ requirements.txt
6
+ .github/dependabot.yml
7
+ .github/workflows/lint.yml
8
+ .github/workflows/publish.yml
9
+ .github/workflows/smoke-test.yml
4
10
  src/emerald_hws/__init__.py
5
11
  src/emerald_hws/emeraldhws.py
6
12
  src/emerald_hws.egg-info/PKG-INFO
@@ -0,0 +1,6 @@
1
+ boto3<2.0.0,>=1.40.0
2
+ awsiotsdk<2.0.0,>=1.24.0
3
+ requests>=2.25.0
4
+
5
+ [dev]
6
+ ruff<1.0.0,>=0.12.0
@@ -1,18 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: emerald_hws
3
- Version: 0.0.10
4
- Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
- Author-email: Ross Williamson <ross@inertia.net.nz>
6
- Project-URL: Homepage, https://github.com/ross-w/emerald_hws_py
7
- Project-URL: Bug Tracker, https://github.com/ross-w/emerald_hws_py/issues
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.7
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: boto3
15
- Requires-Dist: awsiotsdk
16
-
17
- # emerald_hws_py
18
- Python package for controlling Emerald Heat Pump Hot Water Systems
@@ -1,2 +0,0 @@
1
- # emerald_hws_py
2
- Python package for controlling Emerald Heat Pump Hot Water Systems
@@ -1,30 +0,0 @@
1
- [build-system]
2
- requires = ["setuptools>=61.0"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "emerald_hws"
7
- version = "0.0.10"
8
- dependencies = ["boto3", "awsiotsdk"]
9
- authors = [{ name = "Ross Williamson", email = "ross@inertia.net.nz" }]
10
- description = "A package to manipulate and monitor Emerald Heat Pump Hot Water Systems"
11
- readme = "README.md"
12
- requires-python = ">=3.7"
13
- classifiers = [
14
- "Programming Language :: Python :: 3",
15
- "License :: OSI Approved :: MIT License",
16
- "Operating System :: OS Independent",
17
- ]
18
-
19
- [tool.setuptools]
20
- include-package-data = true
21
-
22
- [tool.setuptools.packages.find]
23
- where = ["src"]
24
-
25
- [tool.setuptools.package-data]
26
- "*" = ["*.*"]
27
-
28
- [project.urls]
29
- "Homepage" = "https://github.com/ross-w/emerald_hws_py"
30
- "Bug Tracker" = "https://github.com/ross-w/emerald_hws_py/issues"
@@ -1,18 +0,0 @@
1
- Metadata-Version: 2.2
2
- Name: emerald_hws
3
- Version: 0.0.10
4
- Summary: A package to manipulate and monitor Emerald Heat Pump Hot Water Systems
5
- Author-email: Ross Williamson <ross@inertia.net.nz>
6
- Project-URL: Homepage, https://github.com/ross-w/emerald_hws_py
7
- Project-URL: Bug Tracker, https://github.com/ross-w/emerald_hws_py/issues
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Operating System :: OS Independent
11
- Requires-Python: >=3.7
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: boto3
15
- Requires-Dist: awsiotsdk
16
-
17
- # emerald_hws_py
18
- Python package for controlling Emerald Heat Pump Hot Water Systems
@@ -1,2 +0,0 @@
1
- boto3
2
- awsiotsdk
File without changes
File without changes