├── .github
├── dependabot.yml
├── release-drafter.yml
└── workflows
│ ├── deploy-pypi.yml
│ ├── format-code.yml
│ └── release-drafter.yml
├── .gitignore
├── .vscode
├── launch.json
└── settings.json
├── Files
├── Codes
│ ├── Error.txt
│ └── Status.txt
├── Commands.txt
├── Status and error codes.txt
├── mqtt_response.json
├── mqtt_response_Kress_New_Style_API.json
└── mqtt_response_VISION.json
├── LICENSE
├── README.md
├── poetry.lock
├── pyproject.toml
├── pyworxcloud
├── __init__.py
├── api.py
├── clouds.py
├── const.py
├── day_map.py
├── events.py
├── exceptions.py
├── helpers
│ ├── __init__.py
│ ├── logger.py
│ └── time_format.py
└── utils
│ ├── __init__.py
│ ├── battery.py
│ ├── blades.py
│ ├── capability.py
│ ├── devices.py
│ ├── firmware.py
│ ├── landroid_class.py
│ ├── lawn.py
│ ├── location.py
│ ├── mqtt.py
│ ├── orientation.py
│ ├── product.py
│ ├── rainsensor.py
│ ├── requests.py
│ ├── schedules.py
│ ├── state.py
│ ├── statistics.py
│ ├── warranty.py
│ └── zone.py
├── test.py
├── test_async.py
└── test_with.py
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "pip" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 | assignees:
13 | - "MTrab"
14 | ignore:
15 | - dependency-name: "paho-mqtt"
16 | - dependency-name: "urllib3"
17 | - dependency-name: "requests"
18 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: "v$RESOLVED_VERSION"
2 | tag-template: "v$RESOLVED_VERSION"
3 | change-template: "- #$NUMBER $TITLE @$AUTHOR"
4 | sort-direction: ascending
5 | exclude-labels:
6 | - "skip-changelog"
7 | categories:
8 | - title: "🚀 Features"
9 | labels:
10 | - "feature request"
11 | - "enhancement"
12 |
13 | - title: "🐛 Bug Fixes"
14 | labels:
15 | - "fix"
16 | - "bugfix"
17 | - "bug"
18 |
19 | - title: "🧰 Maintenance"
20 | label: "chore"
21 |
22 | - title: ":package: Dependencies"
23 | labels:
24 | - "dependencies"
25 |
26 | version-resolver:
27 | major:
28 | labels:
29 | - "major"
30 | minor:
31 | labels:
32 | - "minor"
33 | patch:
34 | labels:
35 | - "patch"
36 | default: patch
37 | template: |
38 | ## Changes
39 |
40 | $CHANGES
41 |
42 | ## Say thanks
43 |
44 |
45 |
46 | autolabeler:
47 | - label: "bug"
48 | branch:
49 | - '/fix\/.+/'
50 | - label: "feature request"
51 | branch:
52 | - '/feature\/.+/'
53 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-pypi.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to PyPI
2 |
3 | on:
4 | workflow_dispatch:
5 | release:
6 | types: [published]
7 |
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 | env:
12 | POETRY_VIRTUALENVS_CREATE: "false"
13 | steps:
14 | - uses: actions/checkout@v3
15 |
16 | # - name: Install poetry
17 | # run: |
18 | # pipx install poetry
19 | # pipx inject poetry .
20 |
21 | - name: Install poetry
22 | run: |
23 | pipx install poetry
24 | pipx inject poetry poetry-bumpversion
25 |
26 | - name: Build
27 | run: |
28 | poetry version ${{ github.ref_name }}
29 | poetry build
30 |
31 | - name: Publish
32 | run: |
33 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}
34 | poetry publish
35 |
36 | # NOTE: Make sure you have added PYPI_API_TOKEN repository secret
37 |
--------------------------------------------------------------------------------
/.github/workflows/format-code.yml:
--------------------------------------------------------------------------------
1 | name: Format code
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - master
8 |
9 | jobs:
10 | format:
11 | name: Format with black and isort
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 | with:
17 | fetch-depth: 0
18 | - name: Set up Python 3.11
19 | uses: actions/setup-python@v5.4.0
20 | with:
21 | python-version: 3.11
22 | - name: Cache
23 | uses: actions/cache@v4.2.2
24 | with:
25 | path: ~/.cache/pip
26 | key: pip-format
27 | - name: Install dependencies
28 | run: |
29 | python -m pip install --upgrade pip wheel
30 | python -m pip install --upgrade black isort
31 | - name: Pull again
32 | run: git pull || true
33 | - name: Run formatting
34 | run: |
35 | python -m isort -v --multi-line 3 --trailing-comma -l 88 --recursive .
36 | python -m black -v .
37 | - name: Commit files
38 | run: |
39 | if [ $(git diff HEAD | wc -l) -gt 30 ]
40 | then
41 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
42 | git config user.name "GitHub Actions"
43 | git commit -m "Run formatting" -a || true
44 | git push || true
45 | fi
46 |
--------------------------------------------------------------------------------
/.github/workflows/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - master
8 | pull_request:
9 | types: [opened, reopened, synchronize]
10 |
11 | jobs:
12 | update_release_draft:
13 | name: Update release draft
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v3
18 | with:
19 | fetch-depth: 0
20 | - name: Create Release
21 | uses: release-drafter/release-drafter@v5
22 | with:
23 | disable-releaser: github.ref != 'refs/heads/main'
24 | env:
25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | pyworxcloud/__pycache__/
2 | pyworxcloud.egg-info/
3 | dist/
4 | ## Ignore Visual Studio temporary files, build results, and
5 | ## files generated by popular Visual Studio add-ons.
6 | ##
7 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
8 |
9 | # User-specific files
10 | *.rsuser
11 | *.suo
12 | *.user
13 | *.userosscache
14 | *.sln.docstates
15 |
16 | # User-specific files (MonoDevelop/Xamarin Studio)
17 | *.userprefs
18 |
19 | # Mono auto generated files
20 | mono_crash.*
21 |
22 | # Build results
23 | [Dd]ebug/
24 | [Dd]ebugPublic/
25 | [Rr]elease/
26 | [Rr]eleases/
27 | x64/
28 | x86/
29 | [Ww][Ii][Nn]32/
30 | [Aa][Rr][Mm]/
31 | [Aa][Rr][Mm]64/
32 | bld/
33 | [Bb]in/
34 | [Oo]bj/
35 | [Ll]og/
36 | [Ll]ogs/
37 |
38 | # Visual Studio 2015/2017 cache/options directory
39 | .vs/
40 | # Uncomment if you have tasks that create the project's static files in wwwroot
41 | #wwwroot/
42 |
43 | # Visual Studio 2017 auto generated files
44 | Generated\ Files/
45 |
46 | # MSTest test Results
47 | [Tt]est[Rr]esult*/
48 | [Bb]uild[Ll]og.*
49 |
50 | # NUnit
51 | *.VisualState.xml
52 | TestResult.xml
53 | nunit-*.xml
54 |
55 | # Build Results of an ATL Project
56 | [Dd]ebugPS/
57 | [Rr]eleasePS/
58 | dlldata.c
59 |
60 | # Benchmark Results
61 | BenchmarkDotNet.Artifacts/
62 |
63 | # .NET Core
64 | project.lock.json
65 | project.fragment.lock.json
66 | artifacts/
67 |
68 | # ASP.NET Scaffolding
69 | ScaffoldingReadMe.txt
70 |
71 | # StyleCop
72 | StyleCopReport.xml
73 |
74 | # Files built by Visual Studio
75 | *_i.c
76 | *_p.c
77 | *_h.h
78 | *.ilk
79 | *.meta
80 | *.obj
81 | *.iobj
82 | *.pch
83 | *.pdb
84 | *.ipdb
85 | *.pgc
86 | *.pgd
87 | *.rsp
88 | *.sbr
89 | *.tlb
90 | *.tli
91 | *.tlh
92 | *.tmp
93 | *.tmp_proj
94 | *_wpftmp.csproj
95 | *.log
96 | *.vspscc
97 | *.vssscc
98 | .builds
99 | *.pidb
100 | *.svclog
101 | *.scc
102 |
103 | # Chutzpah Test files
104 | _Chutzpah*
105 |
106 | # Visual C++ cache files
107 | ipch/
108 | *.aps
109 | *.ncb
110 | *.opendb
111 | *.opensdf
112 | *.sdf
113 | *.cachefile
114 | *.VC.db
115 | *.VC.VC.opendb
116 |
117 | # Visual Studio profiler
118 | *.psess
119 | *.vsp
120 | *.vspx
121 | *.sap
122 |
123 | # Visual Studio Trace Files
124 | *.e2e
125 |
126 | # TFS 2012 Local Workspace
127 | $tf/
128 |
129 | # Guidance Automation Toolkit
130 | *.gpState
131 |
132 | # ReSharper is a .NET coding add-in
133 | _ReSharper*/
134 | *.[Rr]e[Ss]harper
135 | *.DotSettings.user
136 |
137 | # TeamCity is a build add-in
138 | _TeamCity*
139 |
140 | # DotCover is a Code Coverage Tool
141 | *.dotCover
142 |
143 | # AxoCover is a Code Coverage Tool
144 | .axoCover/*
145 | !.axoCover/settings.json
146 |
147 | # Coverlet is a free, cross platform Code Coverage Tool
148 | coverage*[.json, .xml, .info]
149 |
150 | # Visual Studio code coverage results
151 | *.coverage
152 | *.coveragexml
153 |
154 | # NCrunch
155 | _NCrunch_*
156 | .*crunch*.local.xml
157 | nCrunchTemp_*
158 |
159 | # MightyMoose
160 | *.mm.*
161 | AutoTest.Net/
162 |
163 | # Web workbench (sass)
164 | .sass-cache/
165 |
166 | # Installshield output folder
167 | [Ee]xpress/
168 |
169 | # DocProject is a documentation generator add-in
170 | DocProject/buildhelp/
171 | DocProject/Help/*.HxT
172 | DocProject/Help/*.HxC
173 | DocProject/Help/*.hhc
174 | DocProject/Help/*.hhk
175 | DocProject/Help/*.hhp
176 | DocProject/Help/Html2
177 | DocProject/Help/html
178 |
179 | # Click-Once directory
180 | publish/
181 |
182 | # Publish Web Output
183 | *.[Pp]ublish.xml
184 | *.azurePubxml
185 | # Note: Comment the next line if you want to checkin your web deploy settings,
186 | # but database connection strings (with potential passwords) will be unencrypted
187 | *.pubxml
188 | *.publishproj
189 |
190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
191 | # checkin your Azure Web App publish settings, but sensitive information contained
192 | # in these scripts will be unencrypted
193 | PublishScripts/
194 |
195 | # NuGet Packages
196 | *.nupkg
197 | # NuGet Symbol Packages
198 | *.snupkg
199 | # The packages folder can be ignored because of Package Restore
200 | **/[Pp]ackages/*
201 | # except build/, which is used as an MSBuild target.
202 | !**/[Pp]ackages/build/
203 | # Uncomment if necessary however generally it will be regenerated when needed
204 | #!**/[Pp]ackages/repositories.config
205 | # NuGet v3's project.json files produces more ignorable files
206 | *.nuget.props
207 | *.nuget.targets
208 |
209 | # Microsoft Azure Build Output
210 | csx/
211 | *.build.csdef
212 |
213 | # Microsoft Azure Emulator
214 | ecf/
215 | rcf/
216 |
217 | # Windows Store app package directories and files
218 | AppPackages/
219 | BundleArtifacts/
220 | Package.StoreAssociation.xml
221 | _pkginfo.txt
222 | *.appx
223 | *.appxbundle
224 | *.appxupload
225 |
226 | # Visual Studio cache files
227 | # files ending in .cache can be ignored
228 | *.[Cc]ache
229 | # but keep track of directories ending in .cache
230 | !?*.[Cc]ache/
231 |
232 | # Others
233 | ClientBin/
234 | ~$*
235 | *~
236 | *.dbmdl
237 | *.dbproj.schemaview
238 | *.jfm
239 | *.pfx
240 | *.publishsettings
241 | orleans.codegen.cs
242 |
243 | # Including strong name files can present a security risk
244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
245 | #*.snk
246 |
247 | # Since there are multiple workflows, uncomment next line to ignore bower_components
248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
249 | #bower_components/
250 |
251 | # RIA/Silverlight projects
252 | Generated_Code/
253 |
254 | # Backup & report files from converting an old project file
255 | # to a newer Visual Studio version. Backup files are not needed,
256 | # because we have git ;-)
257 | _UpgradeReport_Files/
258 | Backup*/
259 | UpgradeLog*.XML
260 | UpgradeLog*.htm
261 | ServiceFabricBackup/
262 | *.rptproj.bak
263 |
264 | # SQL Server files
265 | *.mdf
266 | *.ldf
267 | *.ndf
268 |
269 | # Business Intelligence projects
270 | *.rdl.data
271 | *.bim.layout
272 | *.bim_*.settings
273 | *.rptproj.rsuser
274 | *- [Bb]ackup.rdl
275 | *- [Bb]ackup ([0-9]).rdl
276 | *- [Bb]ackup ([0-9][0-9]).rdl
277 |
278 | # Microsoft Fakes
279 | FakesAssemblies/
280 |
281 | # GhostDoc plugin setting file
282 | *.GhostDoc.xml
283 |
284 | # Node.js Tools for Visual Studio
285 | .ntvs_analysis.dat
286 | node_modules/
287 |
288 | # Visual Studio 6 build log
289 | *.plg
290 |
291 | # Visual Studio 6 workspace options file
292 | *.opt
293 |
294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
295 | *.vbw
296 |
297 | # Visual Studio LightSwitch build output
298 | **/*.HTMLClient/GeneratedArtifacts
299 | **/*.DesktopClient/GeneratedArtifacts
300 | **/*.DesktopClient/ModelManifest.xml
301 | **/*.Server/GeneratedArtifacts
302 | **/*.Server/ModelManifest.xml
303 | _Pvt_Extensions
304 |
305 | # Paket dependency manager
306 | .paket/paket.exe
307 | paket-files/
308 |
309 | # FAKE - F# Make
310 | .fake/
311 |
312 | # CodeRush personal settings
313 | .cr/personal
314 |
315 | # Python Tools for Visual Studio (PTVS)
316 | __pycache__/
317 | *.pyc
318 |
319 | # Cake - Uncomment if you are using it
320 | # tools/**
321 | # !tools/packages.config
322 |
323 | # Tabs Studio
324 | *.tss
325 |
326 | # Telerik's JustMock configuration file
327 | *.jmconfig
328 |
329 | # BizTalk build output
330 | *.btp.cs
331 | *.btm.cs
332 | *.odx.cs
333 | *.xsd.cs
334 |
335 | # OpenCover UI analysis results
336 | OpenCover/
337 |
338 | # Azure Stream Analytics local run output
339 | ASALocalRun/
340 |
341 | # MSBuild Binary and Structured Log
342 | *.binlog
343 |
344 | # NVidia Nsight GPU debugger configuration file
345 | *.nvuser
346 |
347 | # MFractors (Xamarin productivity tool) working folder
348 | .mfractor/
349 |
350 | # Local History for Visual Studio
351 | .localhistory/
352 |
353 | # BeatPulse healthcheck temp database
354 | healthchecksdb
355 |
356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
357 | MigrationBackup/
358 |
359 | # Ionide (cross platform F# VS Code tools) working folder
360 | .ionide/
361 |
362 | # Fody - auto-generated XML schema
363 | FodyWeavers.xsd
364 |
365 | build/
366 | __pycache__/
367 | .env
368 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "test.py",
9 | "type": "debugpy",
10 | "request": "launch",
11 | "program": "${workspaceFolder}/test.py",
12 | "console": "integratedTerminal",
13 | "justMyCode": true,
14 | "subProcess": true
15 | },
16 | {
17 | "name": "test_with.py",
18 | "type": "debugpy",
19 | "request": "launch",
20 | "program": "${workspaceFolder}/test_with.py",
21 | "console": "integratedTerminal",
22 | "justMyCode": true,
23 | "subProcess": true
24 | },
25 | {
26 | "name": "test_async.py",
27 | "type": "debugpy",
28 | "request": "launch",
29 | "program": "${workspaceFolder}/test_async.py",
30 | "console": "integratedTerminal",
31 | "justMyCode": true,
32 | "subProcess": true
33 | }
34 | ]
35 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.envFile": "${workspaceFolder}/.env",
3 | "files.eol": "\n",
4 | "editor.tabSize": 4,
5 | "python.analysis.autoSearchPaths": false,
6 | "python.linting.pylintEnabled": true,
7 | "python.linting.enabled": true,
8 | "python.formatting.provider": "none",
9 | "editor.formatOnPaste": false,
10 | "editor.formatOnSave": true,
11 | "editor.formatOnType": true,
12 | "files.trimTrailingWhitespace": true,
13 | "[python]": {
14 | "editor.defaultFormatter": "ms-python.black-formatter"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Files/Codes/Error.txt:
--------------------------------------------------------------------------------
1 | 0: "No error"
2 | 1: "Trapped"
3 | 2: "Lifted"
4 | 3: "Wire missing"
5 | 4: "Outside wire"
6 | 5: "Rain delay"
7 | 6: "Close door to mow"
8 | 7: "Close door to go home"
9 | 8: "Blade motor blocked"
10 | 9: "Wheel motor blocked"
11 | 10: "Trapped timeout"
12 | 11: "Upside down"
13 | 12: "Battery low"
14 | 13: "Reverse wire"
15 | 14: "Charge error"
16 | 15: "Timeout finding home"
--------------------------------------------------------------------------------
/Files/Codes/Status.txt:
--------------------------------------------------------------------------------
1 | 0: "Idle"
2 | 1: "Home"
3 | 2: "Start sequence"
4 | 3: "Leaving home"
5 | 4: "Follow wire"
6 | 5: "Searching home"
7 | 6: "Searching wire"
8 | 7: "Mowing"
9 | 8: "Lifted"
10 | 9: "Trapped"
11 | 10: "Blade blocked"
12 | 11: "Debug"
13 | 12: "Remote control"
14 | 30: "Going home"
15 | 32: "Cutting edge"
16 | 33: "Searching area"
17 | 34: "Pause"
--------------------------------------------------------------------------------
/Files/Commands.txt:
--------------------------------------------------------------------------------
1 | 0 no command
2 | 1 start
3 | 2 pause
4 | 3 go home
5 | 4 follow border for training
6 | 5 enable wi-fi lock
7 | 6 disable wi-fi lock
8 | 7 reset log
9 | 8 pause over border
10 | 9 safe go home
--------------------------------------------------------------------------------
/Files/Status and error codes.txt:
--------------------------------------------------------------------------------
1 | Status
2 | 0 idle - Manual stop - mower cannot be started or sent home remotely
3 | 1 home - The mower is at the charging station
4 | 2 start sequence - The mower is starting
5 | 3 leaving home - The mower is starting
6 | 5 searching home - The mower is going home
7 | 6 searching border
8 | 7 cutting grass - The mower is cutting the grass
9 | 8 lifted recovery - The mower is recovering automatically from a lifted condition
10 | 9 trapped recovery - The mower is recovering automatically from a trap condition
11 | 10 blade blocked recovery - The mower is recovering automatically from a blade blocked condition
12 | 13 digital fence escape
13 | 30 following border - going home
14 | 31 following border - training
15 | 32 following border - border cut
16 | 33 following border - area search
17 | 34 pause - The mower is paused and can be started again remotely. Automatic power off in 20 minutes.
18 |
19 | Error
20 | 0 no error
21 | 1 trapped
22 | 2 lifted
23 | 3 wire missing
24 | 4 outside boundary
25 | 5 raining
26 | 6 close door to cut grass
27 | 7 close door to go home
28 | 8 blade motor fault
29 | 9 wheel motor fault
30 | 10 trapped timeout fault
31 | 11 upside down
32 | 12 battery low
33 | 13 wire reversed
34 | 14 battery charge error
35 | 15 home search timeout
36 | 16 wifi locked
37 | 17 battery temperature out of range
38 | 19 trunk open timeout
39 | 20 wire signal out of sync
--------------------------------------------------------------------------------
/Files/mqtt_response.json:
--------------------------------------------------------------------------------
1 | {
2 | "cfg":{
3 | "id":12345, // Random numbers
4 | "lg":"it", // Language setting
5 | "tm":"17:12:33", // Time for last update
6 | "dt":"13/11/2022", // Date for last update
7 | "sc":{ // Schedule
8 | "m":1, // Mower schedule active
9 | "p":0, // Schedule time variation in %
10 | "d":[ // Days (Values pr. day: start, duration, do boundary)
11 | [ "10:00", 300, 0 ], // Sunday
12 | [ "10:00", 300, 1 ], // Monday
13 | [ "10:00", 300, 0 ], // Tuesday
14 | [ "10:00", 300, 1 ], // Wednesday
15 | [ "10:00", 300, 0 ], // Thursday
16 | [ "10:00", 300, 1 ], // Friday
17 | [ "10:00", 300, 0 ] // Saturday
18 | ]
19 | },
20 | "cmd":0, // Command
21 | "mz":[ 0, 0, 0, 0 ], // Zones
22 | "mzv":[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ],
23 | "rd":30, // Rain delay
24 | "sn":"1234567890ABCDEFGHIJ" // Serial number
25 | },
26 | "dat":{
27 | "mac":"ABCDEF123456", // MAC address
28 | "fw":3.52, // Firmware
29 | "bt":{ // Battery states
30 | "t":15.6, // Temperature
31 | "v":20.47, // Voltage
32 | "p":99, // Charge percent
33 | "nr":3014, // Charge cycles
34 | "c":1, // Charging
35 | "m":1 // ?
36 | },
37 | "dmp":[ // blah
38 | -3.5, // Roll
39 | 2.3, // Yaw
40 | 344.6 // Picth
41 | ],
42 | "st":{ // Statistics
43 | "b":113287, // Blade on time (minutes)
44 | "d":2146986, // Driven distance (meters)
45 | "wt":129895 // Time mowing (minutes)
46 | },
47 | "ls":1, // Status code
48 | "le":0, // Error code
49 | "lz":9, // Current mowing zone index
50 | "rsi":-74, // Wifi link quality
51 | "lk":1 // ?
52 | }
53 | }
--------------------------------------------------------------------------------
/Files/mqtt_response_Kress_New_Style_API.json:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MTrab/pyworxcloud/9118fec0535a49fb9fafaabc50bac3b12dbfebaa/Files/mqtt_response_Kress_New_Style_API.json
--------------------------------------------------------------------------------
/Files/mqtt_response_VISION.json:
--------------------------------------------------------------------------------
1 | {
2 | "cfg":{
3 | "id":0,
4 | "tz":"Europe/Berlin",
5 | "lg":"de",
6 | "cmd":0,
7 | "log":{
8 | "imp":1,
9 | "diag":1
10 | },
11 | "vis":{
12 | "slab":0
13 | },
14 | "sc":{
15 | "enabled":1,
16 | "paused":0,
17 | "once":{
18 | "time":0,
19 | "cfg":{
20 | "cut":{
21 | "b":0,
22 | "z":[
23 |
24 | ]
25 | }
26 | }
27 | },
28 | "slots":[
29 | {
30 | "e":1,
31 | "d":0,
32 | "s":435,
33 | "t":345,
34 | "cfg":{
35 | "cut":{
36 | "b":1,
37 | "z":[
38 |
39 | ]
40 | }
41 | }
42 | },
43 | {
44 | "e":1,
45 | "d":0,
46 | "s":1080,
47 | "t":180,
48 | "cfg":{
49 | "cut":{
50 | "b":0,
51 | "z":[
52 |
53 | ]
54 | }
55 | }
56 | },
57 | {
58 | "e":1,
59 | "d":1,
60 | "s":435,
61 | "t":345,
62 | "cfg":{
63 | "cut":{
64 | "b":0,
65 | "z":[
66 |
67 | ]
68 | }
69 | }
70 | },
71 | {
72 | "e":1,
73 | "d":1,
74 | "s":1080,
75 | "t":180,
76 | "cfg":{
77 | "cut":{
78 | "b":0,
79 | "z":[
80 |
81 | ]
82 | }
83 | }
84 | },
85 | {
86 | "e":1,
87 | "d":2,
88 | "s":435,
89 | "t":345,
90 | "cfg":{
91 | "cut":{
92 | "b":0,
93 | "z":[
94 |
95 | ]
96 | }
97 | }
98 | },
99 | {
100 | "e":1,
101 | "d":2,
102 | "s":1080,
103 | "t":180,
104 | "cfg":{
105 | "cut":{
106 | "b":0,
107 | "z":[
108 |
109 | ]
110 | }
111 | }
112 | },
113 | {
114 | "e":1,
115 | "d":3,
116 | "s":435,
117 | "t":345,
118 | "cfg":{
119 | "cut":{
120 | "b":1,
121 | "z":[
122 |
123 | ]
124 | }
125 | }
126 | },
127 | {
128 | "e":1,
129 | "d":3,
130 | "s":1080,
131 | "t":180,
132 | "cfg":{
133 | "cut":{
134 | "b":0,
135 | "z":[
136 |
137 | ]
138 | }
139 | }
140 | },
141 | {
142 | "e":1,
143 | "d":4,
144 | "s":435,
145 | "t":345,
146 | "cfg":{
147 | "cut":{
148 | "b":0,
149 | "z":[
150 |
151 | ]
152 | }
153 | }
154 | },
155 | {
156 | "e":1,
157 | "d":4,
158 | "s":1080,
159 | "t":180,
160 | "cfg":{
161 | "cut":{
162 | "b":0,
163 | "z":[
164 |
165 | ]
166 | }
167 | }
168 | },
169 | {
170 | "e":1,
171 | "d":5,
172 | "s":435,
173 | "t":345,
174 | "cfg":{
175 | "cut":{
176 | "b":0,
177 | "z":[
178 |
179 | ]
180 | }
181 | }
182 | },
183 | {
184 | "e":1,
185 | "d":5,
186 | "s":1080,
187 | "t":180,
188 | "cfg":{
189 | "cut":{
190 | "b":0,
191 | "z":[
192 |
193 | ]
194 | }
195 | }
196 | },
197 | {
198 | "e":1,
199 | "d":6,
200 | "s":435,
201 | "t":345,
202 | "cfg":{
203 | "cut":{
204 | "b":0,
205 | "z":[
206 |
207 | ]
208 | }
209 | }
210 | },
211 | {
212 | "e":1,
213 | "d":6,
214 | "s":1080,
215 | "t":180,
216 | "cfg":{
217 | "cut":{
218 | "b":0,
219 | "z":[
220 |
221 | ]
222 | }
223 | }
224 | }
225 | ]
226 | },
227 | "cut":{
228 | "b":0,
229 | "bd":0,
230 | "ob":0,
231 | "z":[
232 |
233 | ]
234 | },
235 | "mz":{
236 | "s":[
237 |
238 | ],
239 | "p":[
240 |
241 | ]
242 | },
243 | "rd":0,
244 | "al":{
245 | "lvl":0,
246 | "t":60
247 | },
248 | "tq":0,
249 | "modules":{
250 | "DF":{
251 | "fh":0,
252 | "cut":1
253 | },
254 | "HL":{
255 | "enabled":0
256 | }
257 | }
258 | },
259 | "dat":{
260 | "uuid":"988b3a46-1341-4002-92fa-548102a78918",
261 | "mac":"B48C9D4884D1",
262 | "tm":"2023-06-01T14:01:41.646Z",
263 | "fw":"3.30.0+2",
264 | "ls":1,
265 | "le":0,
266 | "conn":"wifi",
267 | "bt":{
268 | "t":30.8,
269 | "v":19.6,
270 | "p":100,
271 | "nr":56,
272 | "c":0,
273 | "m":0
274 | },
275 | "head":{
276 | "uuid":"988b3a4b-6489-4003-a8eb-5ccf5c9b37de",
277 | "fw":"1.2.9+1",
278 | "act":1
279 | },
280 | "dmp":[
281 | 0.3,
282 | 1.1,
283 | 260.6
284 | ],
285 | "st":{
286 | "b":2747,
287 | "d":37481,
288 | "wt":2845,
289 | "bl":90
290 | },
291 | "act":1,
292 | "features":{
293 | "ble":0
294 | },
295 | "rsi":-78,
296 | "lk":0,
297 | "tr":0,
298 | "rain":{
299 | "s":0,
300 | "cnt":0
301 | },
302 | "sh":0,
303 | "sc":{
304 | "slot":0,
305 | "once":0
306 | },
307 | "cut":{
308 | "z":0
309 | },
310 | "mz":[
311 | {
312 | "id":1,
313 | "p":0,
314 | "a":0
315 | }
316 | ],
317 | "cam":{
318 | "status":0,
319 | "error":0
320 | },
321 | "rfid":{
322 | "status":0
323 | },
324 | "modules":{
325 | "DF":{
326 | "stat":"ok"
327 | }
328 | }
329 | }
330 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # pyWorxCloud
4 |
5 | This is a PyPI module for communicating with Worx Cloud mowers, primarily developed for use with [Home Assistant](https://home-assistant.io), but I try to keep it as widely usable as possible.
6 |
7 | The module are compatible with cloud enabled devices from [these vendors](https://github.com/MTrab/pyworxcloud/wiki#current-supported-brands--vendors)
8 |
9 | ## Documentation
10 |
11 | The documentation have been moved to the [Wiki](https://github.com/MTrab/pyworxcloud/wiki)
12 |
--------------------------------------------------------------------------------
/poetry.lock:
--------------------------------------------------------------------------------
1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
2 |
3 | [[package]]
4 | name = "certifi"
5 | version = "2024.7.4"
6 | description = "Python package for providing Mozilla's CA Bundle."
7 | optional = false
8 | python-versions = ">=3.6"
9 | files = [
10 | {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
11 | {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
12 | ]
13 |
14 | [[package]]
15 | name = "charset-normalizer"
16 | version = "2.0.12"
17 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
18 | optional = false
19 | python-versions = ">=3.5.0"
20 | files = [
21 | {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
22 | {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
23 | ]
24 |
25 | [package.extras]
26 | unicode-backport = ["unicodedata2"]
27 |
28 | [[package]]
29 | name = "idna"
30 | version = "3.7"
31 | description = "Internationalized Domain Names in Applications (IDNA)"
32 | optional = false
33 | python-versions = ">=3.5"
34 | files = [
35 | {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
36 | {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
37 | ]
38 |
39 | [[package]]
40 | name = "paho-mqtt"
41 | version = "1.6.1"
42 | description = "MQTT version 5.0/3.1.1 client class"
43 | optional = false
44 | python-versions = "*"
45 | files = [
46 | {file = "paho-mqtt-1.6.1.tar.gz", hash = "sha256:2a8291c81623aec00372b5a85558a372c747cbca8e9934dfe218638b8eefc26f"},
47 | ]
48 |
49 | [package.extras]
50 | proxy = ["PySocks"]
51 |
52 | [[package]]
53 | name = "requests"
54 | version = "2.32.3"
55 | description = "Python HTTP for Humans."
56 | optional = false
57 | python-versions = ">=3.8"
58 | files = [
59 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"},
60 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"},
61 | ]
62 |
63 | [package.dependencies]
64 | certifi = ">=2017.4.17"
65 | charset-normalizer = ">=2,<4"
66 | idna = ">=2.5,<4"
67 | urllib3 = ">=1.21.1,<3"
68 |
69 | [package.extras]
70 | socks = ["PySocks (>=1.5.6,!=1.5.7)"]
71 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
72 |
73 | [[package]]
74 | name = "urllib3"
75 | version = "1.26.18"
76 | description = "HTTP library with thread-safe connection pooling, file post, and more."
77 | optional = false
78 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
79 | files = [
80 | {file = "urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07"},
81 | {file = "urllib3-1.26.18.tar.gz", hash = "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0"},
82 | ]
83 |
84 | [package.extras]
85 | brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
86 | secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
87 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
88 |
89 | [metadata]
90 | lock-version = "2.0"
91 | python-versions = "^3.9"
92 | content-hash = "e5d1c33d76c26d759a564812f1304a7171ddc8ab86a096d81a5cfe489166c78c"
93 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["poetry-core>=1.0.0"]
3 | build-backend = "poetry.core.masonry.api"
4 |
5 | [tool.poetry]
6 | name = "pyworxcloud"
7 | version = "4.1.28"
8 | description = "Landroid cloud (Positec) API library"
9 | authors = ["Malene Trab "]
10 | documentation = "https://github.com/mtrab/pyworxcloud"
11 | classifiers = [
12 | "Topic :: Software Development :: Libraries :: Python Modules"
13 | ]
14 |
15 | license = "MIT"
16 | readme = "README.md"
17 |
18 | [tool.poetry.urls]
19 | "Bug Tracker" = "https://github.com/mtrab/pyworxcloud/issues"
20 |
21 | [tool.poetry.dependencies]
22 | python = "^3.9"
23 | urllib3 = "^1.26.5"
24 | requests = "^2.32.0"
25 | paho-mqtt = ">=1.6.1"
26 |
--------------------------------------------------------------------------------
/pyworxcloud/__init__.py:
--------------------------------------------------------------------------------
1 | """pyWorxCloud definition."""
2 |
3 | # pylint: disable=undefined-loop-variable
4 | # pylint: disable=line-too-long
5 | # pylint: disable=too-many-lines
6 | from __future__ import annotations
7 |
8 | import json
9 | import logging
10 | import sys
11 | import threading
12 | from datetime import datetime, timedelta
13 | from random import randint
14 | from typing import Any
15 | from zoneinfo import ZoneInfo
16 |
17 | from .api import LandroidCloudAPI
18 | from .clouds import CloudType
19 | from .events import EventHandler, LandroidEvent
20 | from .exceptions import (
21 | AuthorizationError,
22 | MowerNotFoundError,
23 | NoACSModuleError,
24 | NoConnectionError,
25 | NoCuttingHeightError,
26 | NoOfflimitsError,
27 | NoOneTimeScheduleError,
28 | NoPartymodeError,
29 | OfflineError,
30 | TooManyRequestsError,
31 | ZoneNoProbability,
32 | ZoneNotDefined,
33 | )
34 | from .helpers import convert_to_time, get_logger
35 | from .utils import MQTT, DeviceCapability, DeviceHandler
36 | from .utils.mqtt import Command
37 | from .utils.requests import HEADERS, POST
38 |
39 | if sys.version_info < (3, 9, 0):
40 | sys.exit("The pyWorxcloud module requires Python 3.9.0 or later")
41 |
42 | _LOGGER = logging.getLogger(__name__)
43 |
44 | API_REFRESH_TIME_MIN = 5
45 | API_REFRESH_TIME_MAX = 10
46 |
47 |
48 | class WorxCloud(dict):
49 | """
50 | Worx by Landroid Cloud connector.
51 |
52 | Used for handling API connection to Worx, Kress and Landxcape devices which are cloud connected.
53 |
54 | This uses a reverse engineered API protocol, so no guarantee that this will keep working.
55 | There are no public available API documentation available.
56 | """
57 |
58 | # __device: str | None = None
59 |
60 | def __init__(
61 | self,
62 | username: str,
63 | password: str,
64 | cloud: (
65 | CloudType.WORX | CloudType.KRESS | CloudType.LANDXCAPE | str
66 | ) = CloudType.WORX,
67 | verify_ssl: bool = True,
68 | tz: str | None = None, # pylint: disable=invalid-name
69 | ) -> None:
70 | """
71 | Initialize :class:WorxCloud class and set default attribute values.
72 |
73 | 1. option for connecting and printing the current states from the API, using :code:`with`
74 |
75 | .. testcode::
76 | from pyworxcloud import WorxCloud
77 | from pprint import pprint
78 |
79 | with WorxCloud("your@email","password","worx", 0, False) as cloud:
80 | pprint(vars(cloud))
81 |
82 | 2. option for connecting and printing the current states from the API, using :code:`connect` and :code:`disconnect`
83 |
84 | .. testcode::
85 | from pyworxcloud import WorxCloud
86 | from pprint import pprint
87 |
88 | cloud = WorxCloud("your@email", "password", "worx")
89 |
90 | # Initialize connection
91 | auth = cloud.authenticate()
92 |
93 | if not auth:
94 | # If invalid credentials are used, or something happend during
95 | # authorize, then exit
96 | exit(0)
97 |
98 | # Connect to device with index 0 (devices are enumerated 0, 1, 2 ...)
99 | # and do not verify SSL (False)
100 | cloud.connect(0, False)
101 |
102 | # Read latest states received from the device
103 | cloud.update()
104 |
105 | # Print all vars and attributes of the cloud object
106 | pprint(vars(cloud))
107 |
108 | # Disconnect from the API
109 | cloud.disconnect()
110 |
111 | For further information, see the Wiki for documentation: https://github.com/MTrab/pyworxcloud/wiki
112 |
113 | Args:
114 | username (str): Email used for logging into the app for your device.
115 | password (str): Password for your account.
116 | cloud (CloudType.WORX | CloudType.KRESS | CloudType.LANDXCAPE | str, optional): The CloudType matching your device. Defaults to CloudType.WORX.
117 | index (int, optional): Device number if more than one is connected to your account (starting from 0 representing the first added device). Defaults to 0.
118 | verify_ssl (bool, optional): Should this module verify the API endpoint SSL certificate? Defaults to True.
119 |
120 | Raise:
121 | TypeError: Error raised if invalid CloudType was specified.
122 | """
123 | _LOGGER.debug("Initializing connector...")
124 | super().__init__()
125 |
126 | self._worx_mqtt_client_id = None
127 |
128 | if not isinstance(
129 | cloud,
130 | (
131 | type(CloudType.WORX),
132 | type(CloudType.LANDXCAPE),
133 | type(CloudType.KRESS),
134 | ),
135 | ):
136 | try:
137 | _LOGGER.debug("Try getting correct CloudType from %s", cloud.upper())
138 | cloud = getattr(CloudType, cloud.upper())
139 | _LOGGER.debug("Found cloud type %s", cloud)
140 | except AttributeError:
141 | raise TypeError(
142 | "Wrong type specified, valid types are: worx, landxcape or kress"
143 | ) from None
144 |
145 | _LOGGER.debug("Initializing the API connector ...")
146 | self._api = LandroidCloudAPI(username, password, cloud, tz, self._token_updated)
147 | self._username = username
148 | self._cloud = cloud
149 | self._auth_result = False
150 | _LOGGER.debug("Getting logger ...")
151 | self._log = get_logger("pyworxcloud")
152 | self._raw = None
153 | self._tz = tz
154 |
155 | self._save_zones = None
156 | self._verify_ssl = verify_ssl
157 | _LOGGER.debug("Initializing EventHandler ...")
158 | self._events = EventHandler()
159 |
160 | self._endpoint = None
161 | self._user_id = None
162 | self._mowers = None
163 |
164 | self._decoding: bool = False
165 |
166 | # Dict holding refresh timers
167 | self._timers = {}
168 |
169 | # Dict of devices, identified by name
170 | self.devices: DeviceHandler = {}
171 |
172 | self.mqtt = None
173 |
174 | def __enter__(self) -> Any:
175 | """Default actions using with statement."""
176 | self.authenticate()
177 |
178 | self.connect()
179 |
180 | return self
181 |
182 | def __exit__(self, exc_type, exc_value, traceback) -> Any:
183 | """Called on end of with statement."""
184 | self.disconnect()
185 |
186 | def authenticate(self) -> bool:
187 | """Authenticate against the API."""
188 | self._log.debug("Authenticating %s", self._username)
189 |
190 | try:
191 | self._api.get_token()
192 | except TooManyRequestsError:
193 | raise TooManyRequestsError from None
194 |
195 | auth = self._api.authenticate()
196 | if auth is False:
197 | self._auth_result = False
198 | self._log.debug("Authentication for %s failed!", self._username)
199 | raise AuthorizationError("Unauthorized")
200 |
201 | self._auth_result = True
202 | self._log.debug("Authentication for %s successful", self._username)
203 |
204 | return True
205 |
206 | def update_attribute(self, device: str, attr: str, key: str, value: Any) -> None:
207 | """Used as callback to update value."""
208 | chattr = self.devices[device]
209 | if not isinstance(attr, type(None)):
210 | for level in attr.split(";;"):
211 | if hasattr(chattr, level):
212 | chattr = getattr(chattr, level)
213 | else:
214 | chattr = chattr[level]
215 |
216 | if hasattr(chattr, key):
217 | setattr(chattr, key, value)
218 | elif isinstance(chattr, dict):
219 | chattr.update({key: value})
220 |
221 | def set_callback(self, event: LandroidEvent, func: Any) -> None:
222 | """Set callback which is called when data is received.
223 |
224 | Args:
225 | event: LandroidEvent for this callback
226 | func: Function to be called.
227 | """
228 | self._events.set_handler(event, func)
229 |
230 | def disconnect(self) -> None:
231 | """Close API connections."""
232 | # pylint: disable=bare-except
233 | logger = self._log.getChild("Disconnect")
234 |
235 | # Cancel force refresh timer on disconnect
236 | try:
237 | for _, tmr in self._timers.items():
238 | tmr.cancel()
239 | except:
240 | logger.debug("Could not cancel timers - skipping.")
241 |
242 | # Disconnect MQTT connection
243 | try:
244 | self.mqtt.disconnect()
245 | except:
246 | logger.debug("Could not disconnect MQTT - skipping.")
247 |
248 | def connect(
249 | self,
250 | ) -> bool:
251 | """
252 | Connect to the cloud service endpoint
253 |
254 | Returns:
255 | bool: True if connection was successful, otherwise False.
256 | """
257 | self._log.debug("Fetching basic API data")
258 | self._fetch()
259 | self._log.debug("Done fetching basic API data")
260 |
261 | if len(self._mowers) == 0:
262 | self._log.debug("no mowers connected to account")
263 | return False
264 |
265 | self._endpoint = self._mowers[0]["mqtt_endpoint"]
266 | self._user_id = self._mowers[0]["user_id"]
267 |
268 | self._log.debug("Setting up MQTT handler")
269 | # setup MQTT handler
270 | self.mqtt = MQTT(
271 | self._api,
272 | self._cloud.BRAND_PREFIX,
273 | self._endpoint,
274 | self._user_id,
275 | self._log,
276 | self._on_update,
277 | )
278 |
279 | self.mqtt.connect()
280 |
281 | for mower in self._mowers:
282 | self.mqtt.subscribe(mower["mqtt_topics"]["command_out"], True)
283 |
284 | # Convert time strings to objects.
285 | for name, device in self.devices.items():
286 | convert_to_time(
287 | name, device, device.time_zone, callback=self.update_attribute
288 | )
289 |
290 | self._log.debug("Connection tasks all done")
291 |
292 | return True
293 |
294 | def _token_updated(self) -> None:
295 | """Called when token is updated."""
296 | self.mqtt.update_token()
297 |
298 | @property
299 | def auth_result(self) -> bool:
300 | """Return current authentication result."""
301 | return self._auth_result
302 |
303 | def _on_update(self, payload): # , topic, payload, dup, qos, retain, **kwargs):
304 | """Triggered when a MQTT message was received."""
305 | logger = self._log.getChild("MQTT_data_in")
306 | try:
307 | data = json.loads(payload)
308 | logger.debug("MQTT data received")
309 |
310 | # "Malformed" message, we are missing a serial number and
311 | # MAC address to identify the mower.
312 | if (
313 | not "sn" in data["cfg"] and not "uuid" in data["dat"]
314 | ) and not "mac" in data["dat"]:
315 | logger.debug("Malformed message received")
316 | return
317 |
318 | found_match = False
319 |
320 | for mower in self._mowers:
321 | if "sn" in data["cfg"]:
322 | if mower["serial_number"] == data["cfg"]["sn"]:
323 | found_match = True
324 | break
325 | elif "uuid" in data["dat"]:
326 | if mower["uuid"] == data["dat"]["uuid"]:
327 | found_match = True
328 | break
329 | elif "mac" in data["dat"]:
330 | if mower["mac_address"] == data["dat"]["mac"]:
331 | found_match = True
332 | break
333 |
334 | if not found_match:
335 | logger.debug("Could not match incoming data with a known mower!")
336 | return
337 | else:
338 | logger.debug("Matched to '%s'", mower["name"])
339 |
340 | device: DeviceHandler = self.devices[mower["name"]]
341 |
342 | if not device.online:
343 | logger.debug("Device is marked offline - refreshing")
344 | self._fetch()
345 | device: DeviceHandler = self.devices[mower["name"]]
346 |
347 | if "raw_data" in mower and mower["raw_data"] == data:
348 | self._log.debug("Data was already present and not changed.")
349 | return # Dataset was not changed, no update needed
350 |
351 | mower["raw_data"] = data
352 | device: DeviceHandler = self.devices[mower["name"]]
353 | device.raw_data = data
354 |
355 | self._events.call(
356 | LandroidEvent.DATA_RECEIVED, name=mower["name"], device=device
357 | )
358 | except json.decoder.JSONDecodeError:
359 | logger.debug("Malformed MQTT message received")
360 |
361 | def _on_api_update(self, data): # , topic, payload, dup, qos, retain, **kwargs):
362 | """Triggered when API has been updated."""
363 | logger = self._log.getChild("API_update")
364 | try:
365 | self._events.call(LandroidEvent.API, api_data=data)
366 | except json.decoder.JSONDecodeError:
367 | logger.debug("Malformed MQTT message received")
368 |
369 | def _fetch(self, forced: bool = False) -> None:
370 | """Fetch base API information."""
371 | self._mowers = self._api.get_mowers()
372 | # self.devices = {}
373 | for mower in self._mowers:
374 | try:
375 | device = DeviceHandler(self._api, mower, self._tz, False)
376 | if not isinstance(mower["last_status"], type(None)):
377 | device.raw_data = mower["last_status"]["payload"]
378 |
379 | _LOGGER.debug("Mower '%s' data: %s", mower["name"], mower)
380 | self.devices.update({mower["name"]: device})
381 |
382 | if isinstance(mower["mac_address"], type(None)):
383 | mower["mac_address"] = (
384 | device.raw_data["dat"]["mac"]
385 | if "mac" in device.raw_data["dat"]
386 | else "__UUID__"
387 | )
388 |
389 | if forced:
390 | self._events.call(
391 | LandroidEvent.API, name=mower["name"], device=device
392 | )
393 | except TypeError:
394 | pass
395 |
396 | self._schedule_api_refresh()
397 |
398 | def _schedule_api_refresh(self) -> None:
399 | """Schedule the API refresh."""
400 | logger = self._log.getChild("API_Refresh_Scheduler")
401 |
402 | try:
403 | self._timers["api"].cancel()
404 | except KeyError:
405 | pass
406 |
407 | refresh_secs = (randint(API_REFRESH_TIME_MIN, API_REFRESH_TIME_MAX)) * 60
408 | timezone = (
409 | ZoneInfo(self._tz)
410 | if not isinstance(self._tz, type(None))
411 | else ZoneInfo("UTC")
412 | )
413 | now = datetime.now().astimezone(timezone)
414 | next_api_refresh = now + timedelta(seconds=refresh_secs)
415 | logger.debug(
416 | "Scheduling an API refresh at %s",
417 | next_api_refresh,
418 | )
419 |
420 | force_api_refresh = threading.Timer(refresh_secs, self._fetch, args=[True])
421 | force_api_refresh.start()
422 | self._timers.update({"api": force_api_refresh})
423 |
424 | def get_mower(self, serial_number: str, device: bool = False) -> dict:
425 | """Get a specific mower object.
426 |
427 | Args:
428 | serial_number (str): Serial number of the device
429 | """
430 |
431 | if device:
432 | for mower in self.devices.items():
433 | if mower[1].serial_number == serial_number:
434 | return mower[1]
435 | else:
436 | for mower in self._mowers:
437 | if mower["serial_number"] == serial_number:
438 | return mower
439 |
440 | raise MowerNotFoundError(
441 | f"Mower with serialnumber {serial_number} was not found."
442 | )
443 |
444 | def update(self, serial_number: str) -> None:
445 | """Request a state refresh."""
446 | mower = self.get_mower(serial_number)
447 | _LOGGER.debug("Trying to refresh '%s'", serial_number)
448 |
449 | try:
450 | self.mqtt.ping(
451 | serial_number if mower["protocol"] == 0 else mower["uuid"],
452 | mower["mqtt_topics"]["command_in"],
453 | mower["protocol"],
454 | )
455 | except NoConnectionError:
456 | raise NoConnectionError from None
457 |
458 | def start(self, serial_number: str) -> None:
459 | """Start mowing task
460 |
461 | Args:
462 | serial_number (str): Serial number of the device
463 |
464 | Raises:
465 | OfflineError: Raised if the device is offline.
466 | """
467 | mower = self.get_mower(serial_number)
468 | if mower["online"]:
469 | _LOGGER.debug("Sending start command to '%s'", serial_number)
470 | self.mqtt.command(
471 | serial_number if mower["protocol"] == 0 else mower["uuid"],
472 | mower["mqtt_topics"]["command_in"],
473 | Command.START,
474 | mower["protocol"],
475 | )
476 | else:
477 | raise OfflineError("The device is currently offline, no action was sent.")
478 |
479 | def home(self, serial_number: str) -> None:
480 | """Stop the current task and go home.
481 | If the knifes was turned on when this is called,
482 | it will return home with knifes still turned on.
483 |
484 | Args:
485 | serial_number (str): Serial number of the device
486 |
487 | Raises:
488 | OfflineError: Raised if the device is offline.
489 | """
490 | mower = self.get_mower(serial_number)
491 |
492 | if mower["online"]:
493 | self.mqtt.command(
494 | serial_number if mower["protocol"] == 0 else mower["uuid"],
495 | mower["mqtt_topics"]["command_in"],
496 | Command.HOME,
497 | mower["protocol"],
498 | )
499 | else:
500 | raise OfflineError("The device is currently offline, no action was sent.")
501 |
502 | def safehome(self, serial_number: str) -> None:
503 | """Stop and go home with the blades off
504 |
505 | Args:
506 | serial_number (str): Serial number of the device
507 |
508 | Raises:
509 | OfflineError: Raised if the device is offline.
510 | """
511 | mower = self.get_mower(serial_number)
512 | if mower["online"]:
513 | self.mqtt.command(
514 | serial_number if mower["protocol"] == 0 else mower["uuid"],
515 | mower["mqtt_topics"]["command_in"],
516 | Command.SAFEHOME,
517 | mower["protocol"],
518 | )
519 | else:
520 | raise OfflineError("The device is currently offline, no action was sent.")
521 |
522 | def pause(self, serial_number: str) -> None:
523 | """Pause the mowing task
524 |
525 | Args:
526 | serial_number (str): Serial number of the device
527 |
528 | Raises:
529 | OfflineError: Raised if the device is offline.
530 | """
531 | mower = self.get_mower(serial_number)
532 | if mower["online"]:
533 | self.mqtt.command(
534 | serial_number if mower["protocol"] == 0 else mower["uuid"],
535 | mower["mqtt_topics"]["command_in"],
536 | Command.PAUSE,
537 | mower["protocol"],
538 | )
539 | else:
540 | raise OfflineError("The device is currently offline, no action was sent.")
541 |
542 | def raindelay(self, serial_number: str, rain_delay: str) -> None:
543 | """Set new rain delay.
544 |
545 | Args:
546 | serial_number (str): Serial number of the device
547 | rain_delay (str): Rain delay in minutes.
548 |
549 | Raises:
550 | OfflineError: Raised if the device is offline.
551 | """
552 | mower = self.get_mower(serial_number)
553 | if mower["online"]:
554 | if not isinstance(rain_delay, int):
555 | rain_delay = int(rain_delay)
556 | self.mqtt.publish(
557 | serial_number if mower["protocol"] == 0 else mower["uuid"],
558 | mower["mqtt_topics"]["command_in"],
559 | {"rd": rain_delay},
560 | )
561 | else:
562 | raise OfflineError("The device is currently offline, no action was sent.")
563 |
564 | def set_lock(self, serial_number: str, state: bool) -> None:
565 | """Set the device locked state.
566 |
567 | Args:
568 | serial_number (str): Serial number of the device
569 | state (bool): True will lock the device, False will unlock the device.
570 |
571 | Raises:
572 | OfflineError: Raised if the device is offline.
573 | """
574 | mower = self.get_mower(serial_number)
575 | if mower["online"]:
576 | self.mqtt.command(
577 | serial_number if mower["protocol"] == 0 else mower["uuid"],
578 | mower["mqtt_topics"]["command_in"],
579 | Command.LOCK if state else Command.UNLOCK,
580 | mower["protocol"],
581 | )
582 | else:
583 | raise OfflineError("The device is currently offline, no action was sent.")
584 |
585 | def set_partymode(self, serial_number: str, state: bool) -> None:
586 | """Turn on or off the partymode.
587 |
588 | Args:
589 | serial_number (str): Serial number of the device
590 | state (bool): True is enabling partymode, False is disabling partymode.
591 |
592 | Raises:
593 | NoPartymodeError: Raised if the device does not support partymode.
594 | OfflineError: Raised if the device is offline.
595 | """
596 | mower = self.get_mower(serial_number)
597 |
598 | if mower["online"]:
599 | device = DeviceHandler(self._api, mower, self._tz)
600 | if device.capabilities.check(DeviceCapability.PARTY_MODE):
601 | if mower["protocol"] == 0:
602 | self.mqtt.publish(
603 | serial_number if mower["protocol"] == 0 else mower["uuid"],
604 | mower["mqtt_topics"]["command_in"],
605 | (
606 | {"sc": {"m": 2, "distm": 0}}
607 | if state
608 | else {"sc": {"m": 1, "distm": 0}}
609 | ),
610 | mower["protocol"],
611 | )
612 | else:
613 | self.mqtt.publish(
614 | serial_number if mower["protocol"] == 0 else mower["uuid"],
615 | mower["mqtt_topics"]["command_in"],
616 | {"sc": {"enabled": 0}} if state else {"sc": {"enabled": 1}},
617 | mower["protocol"],
618 | )
619 | elif not device.capabilities.check(DeviceCapability.PARTY_MODE):
620 | raise NoPartymodeError("This device does not support Partymode")
621 | elif not mower["online"]:
622 | raise OfflineError("The device is currently offline, no action was sent.")
623 |
624 | def set_offlimits(self, serial_number: str, state: bool) -> None:
625 | """Turn on or off the off limits module.
626 |
627 | Args:
628 | serial_number (str): Serial number of the device
629 | state (bool): True is enabling off limits module, False is disabling off limits module.
630 |
631 | Raises:
632 | NoOfflimitsError: Raised if the device does not support off limits.
633 | OfflineError: Raised if the device is offline.
634 | """
635 | mower = self.get_mower(serial_number)
636 |
637 | if mower["online"]:
638 | _LOGGER.debug("Setting offlimits")
639 | device = DeviceHandler(self._api, mower, self._tz)
640 | if device.capabilities.check(DeviceCapability.OFF_LIMITS):
641 | self.mqtt.publish(
642 | serial_number if device.protocol == 0 else device.uuid,
643 | mower["mqtt_topics"]["command_in"],
644 | (
645 | {
646 | "modules": {
647 | "DF": {
648 | "cut": 1,
649 | "fh": 1 if device.offlimit_shortcut else 0,
650 | }
651 | }
652 | }
653 | if state
654 | else {
655 | "modules": {
656 | "DF": {
657 | "cut": 0,
658 | "fh": 1 if device.offlimit_shortcut else 0,
659 | }
660 | }
661 | }
662 | ),
663 | device.protocol,
664 | )
665 | elif not device.capabilities.check(DeviceCapability.OFF_LIMITS):
666 | raise NoOfflimitsError("This device does not support Off Limits")
667 | elif not mower["online"]:
668 | raise OfflineError("The device is currently offline, no action was sent.")
669 |
670 | def set_offlimits_shortcut(self, serial_number: str, state: bool) -> None:
671 | """Turn on or off the off limits shortcut function.
672 |
673 | Args:
674 | serial_number (str): Serial number of the device
675 | state (bool): True is enabling shortcut, False is disabling shortcut.
676 |
677 | Raises:
678 | NoOfflimitsError: Raised if the device does not support off limits.
679 | OfflineError: Raised if the device is offline.
680 | """
681 | mower = self.get_mower(serial_number)
682 |
683 | if mower["online"]:
684 | _LOGGER.debug("Setting offlimits")
685 | device = DeviceHandler(self._api, mower, self._tz)
686 | if device.capabilities.check(DeviceCapability.OFF_LIMITS):
687 | self.mqtt.publish(
688 | serial_number if device.protocol == 0 else device.uuid,
689 | mower["mqtt_topics"]["command_in"],
690 | (
691 | {
692 | "modules": {
693 | "DF": {
694 | "cut": 1 if device.offlimit else 0,
695 | "fh": 1,
696 | }
697 | }
698 | }
699 | if state
700 | else {
701 | "modules": {
702 | "DF": {
703 | "cut": 1 if device.offlimit else 0,
704 | "fh": 0,
705 | }
706 | }
707 | }
708 | ),
709 | device.protocol,
710 | )
711 | elif not device.capabilities.check(DeviceCapability.OFF_LIMITS):
712 | raise NoOfflimitsError("This device does not support Off Limits")
713 | elif not mower["online"]:
714 | raise OfflineError("The device is currently offline, no action was sent.")
715 |
716 | def setzone(self, serial_number: str, zone: str | int) -> None:
717 | """Set zone to be mowed when next mowing task is started.
718 |
719 | Args:
720 | serial_number (str): Serial number of the device
721 | zone (str | int): Zone to mow, valid possibilities are a number from 1 to 4.
722 |
723 | Raises:
724 | OfflineError: Raised if the device is offline.
725 | """
726 | mower = self.get_mower(serial_number)
727 | if mower["online"]:
728 | device = DeviceHandler(self._api, mower, self._tz)
729 | if not isinstance(zone, int):
730 | zone = int(zone)
731 |
732 | if (
733 | zone >= len(device.zone["starting_point"])
734 | or device.zone["starting_point"][zone] == 0
735 | ):
736 | raise ZoneNotDefined(
737 | f"Cannot request zone {zone} as it is not defined."
738 | )
739 |
740 | if not zone in device.zone["indicies"]:
741 | raise ZoneNoProbability(
742 | f"Cannot request zone {zone} as it has no probability set."
743 | )
744 |
745 | current_zones = device.zone["indicies"]
746 | requested_zone_index = current_zones.index(zone)
747 | next_zone_index = device.zone["index"]
748 |
749 | no_indices = len(current_zones)
750 | offset = (requested_zone_index - next_zone_index) % no_indices
751 | new_zones = []
752 | for i in range(0, no_indices):
753 | new_zones.append(current_zones[(offset + i) % no_indices])
754 |
755 | device = DeviceHandler(self._api, mower, self._tz)
756 | self.mqtt.publish(
757 | serial_number if mower["protocol"] == 0 else mower["uuid"],
758 | mower["mqtt_topics"]["command_in"],
759 | {"mzv": new_zones},
760 | mower["protocol"],
761 | )
762 | else:
763 | raise OfflineError("The device is currently offline, no action was sent.")
764 |
765 | def zonetraining(self, serial_number: str) -> None:
766 | """Start the zone training task.
767 |
768 | Args:
769 | serial_number (str): Serial number of the device
770 |
771 | Raises:
772 | OfflineError: Raised if the device is offline.
773 | """
774 | mower = self.get_mower(serial_number)
775 | if mower["online"]:
776 | _LOGGER.debug("Sending ZONETRAINING command to %s", mower["name"])
777 | self.mqtt.command(
778 | serial_number if mower["protocol"] == 0 else mower["uuid"],
779 | mower["mqtt_topics"]["command_in"],
780 | Command.ZONETRAINING,
781 | mower["protocol"],
782 | )
783 | else:
784 | raise OfflineError("The device is currently offline, no action was sent.")
785 |
786 | def restart(self, serial_number: str):
787 | """Reboot the device baseboard.
788 |
789 | Args:
790 | serial_number (str): Serial number of the device
791 |
792 | Raises:
793 | OfflineError: Raised if the device is offline.
794 | """
795 | mower = self.get_mower(serial_number)
796 | if mower["online"]:
797 | _LOGGER.debug("Sending RESTART command to %s", mower["name"])
798 | self.mqtt.command(
799 | serial_number if mower["protocol"] == 0 else mower["uuid"],
800 | mower["mqtt_topics"]["command_in"],
801 | Command.RESTART,
802 | mower["protocol"],
803 | )
804 | else:
805 | raise OfflineError("The device is currently offline, no action was sent.")
806 |
807 | def toggle_schedule(self, serial_number: str, enable: bool) -> None:
808 | """Turn on or off the schedule.
809 |
810 | Args:
811 | serial_number (str): Serial number of the device
812 | enable (bool): True is enabling the schedule, Fasle is disabling the schedule.
813 |
814 | Raises:
815 | OfflineError: Raised if the device is offline.
816 | """
817 | mower = self.get_mower(serial_number)
818 | if mower["online"]:
819 | self.mqtt.publish(
820 | serial_number if mower["protocol"] == 0 else mower["uuid"],
821 | mower["mqtt_topics"]["command_in"],
822 | {"sc": {"m": 1}} if enable else {"sc": {"m": 0}},
823 | mower["protocol"],
824 | )
825 | else:
826 | raise OfflineError("The device is currently offline, no action was sent.")
827 |
828 | def ots(self, serial_number: str, boundary: bool, runtime: str) -> None:
829 | """Start a One-Time-Schedule task
830 |
831 | Args:
832 | serial_number (str): Serial number of the device
833 | boundary (bool): If True the device will start the task cutting the edge.
834 | runtime (str | int): Minutes to run the task before returning to dock.
835 |
836 | Raises:
837 | NoOneTimeScheduleError: OTS is not supported by the device.
838 | OfflineError: Raised when the device is offline.
839 | """
840 | mower = self.get_mower(serial_number)
841 | if mower["online"]:
842 | device = DeviceHandler(self._api, mower, self._tz)
843 | if device.capabilities.check(DeviceCapability.ONE_TIME_SCHEDULE):
844 | if not isinstance(runtime, int):
845 | runtime = int(runtime)
846 |
847 | device = DeviceHandler(self._api, mower, self._tz)
848 | self.mqtt.publish(
849 | serial_number if mower["protocol"] == 0 else mower["uuid"],
850 | mower["mqtt_topics"]["command_in"],
851 | {"sc": {"ots": {"bc": int(boundary), "wtm": runtime}}},
852 | mower["protocol"],
853 | )
854 | elif not device.capabilities.check(DeviceCapability.ONE_TIME_SCHEDULE):
855 | raise NoOneTimeScheduleError(
856 | "This device does not support Edgecut-on-demand"
857 | )
858 | else:
859 | raise OfflineError("The device is currently offline, no action was sent.")
860 |
861 | def send(self, serial_number: str, data: str) -> None:
862 | """Send raw JSON data to the device.
863 |
864 | Args:
865 | serial_number (str): Serial number of the device
866 | data (str): Data to be sent, formatted as a valid JSON object.
867 |
868 | Raises:
869 | OfflineError: Raised if the device isn't online.
870 | """
871 | mower = self.get_mower(serial_number)
872 | if mower["online"]:
873 | _LOGGER.debug("Sending %s to %s", data, mower["name"])
874 | self.mqtt.publish(
875 | serial_number if mower["protocol"] == 0 else mower["uuid"],
876 | mower["mqtt_topics"]["command_in"],
877 | json.loads(data),
878 | mower["protocol"],
879 | )
880 | else:
881 | raise OfflineError("The device is currently offline, no action was sent.")
882 |
883 | def reset_charge_cycle_counter(self, serial_number: str) -> None:
884 | """Resets charge cycle counter.
885 |
886 | Args:
887 | serial_number (str): Serial number of the device
888 | data (str): Data to be sent, formatted as a valid JSON object.
889 |
890 | Raises:
891 | OfflineError: Raised if the device isn't online.
892 | """
893 | mower = self.get_mower(serial_number)
894 | if mower["online"]:
895 | _LOGGER.debug("Resetting charge cycle counter for %s", mower["name"])
896 | self._api.check_token()
897 | POST(
898 | f"https://{self._api.cloud.ENDPOINT}/api/v2/product-items/{serial_number}/counters/battery/reset",
899 | "",
900 | HEADERS(self._api.access_token),
901 | )
902 | self._fetch(True)
903 |
904 | def reset_blade_counter(self, serial_number: str) -> None:
905 | """Resets blade counter.
906 |
907 | Args:
908 | serial_number (str): Serial number of the device
909 | data (str): Data to be sent, formatted as a valid JSON object.
910 |
911 | Raises:
912 | OfflineError: Raised if the device isn't online.
913 | """
914 | mower = self.get_mower(serial_number)
915 | if mower["online"]:
916 | _LOGGER.debug("Resetting blade counter for %s", mower["name"])
917 | self._api.check_token()
918 | POST(
919 | f"https://{self._api.cloud.ENDPOINT}/api/v2/product-items/{serial_number}/counters/blade/reset",
920 | "",
921 | HEADERS(self._api.access_token),
922 | )
923 | self._fetch(True)
924 |
925 | def get_cutting_height(self, serial_number: str) -> int:
926 | """Get the current cutting height of the device.
927 |
928 | Args:
929 | serial_number (str): Serial number of the device
930 |
931 | Returns:
932 | int: Cutting height in mm
933 |
934 | Raises:
935 | NoCuttingHeightError: Raised if the device does not support cutting height.
936 | """
937 | mower = self.get_mower(serial_number)
938 | try:
939 | return int(mower["last_status"]["payload"]["cfg"]["modules"]["EA"]["h"])
940 | except KeyError:
941 | raise NoCuttingHeightError("This device does not support cutting height")
942 |
943 | def set_cutting_height(self, serial_number: str, height: int) -> None:
944 | """Set the cutting height of the device.
945 |
946 | Args:
947 | serial_number (str): Serial number of the device
948 | height (int): Cutting height in mm
949 |
950 | Raises:
951 | NoCuttingHeightError: Raised if the device does not support cutting height.
952 | OfflineError: Raised if the device is offline.
953 | """
954 | mower = self.get_mower(serial_number)
955 | if mower["online"]:
956 | device = DeviceHandler(self._api, mower, self._tz)
957 | if device.capabilities.check(DeviceCapability.CUTTING_HEIGHT):
958 | self.mqtt.publish(
959 | serial_number if mower["protocol"] == 0 else mower["uuid"],
960 | mower["mqtt_topics"]["command_in"],
961 | {"cmd": 0, "modules": {"EA": {"h": height}}},
962 | mower["protocol"],
963 | )
964 | else:
965 | raise NoCuttingHeightError(
966 | "This device does not support cutting height"
967 | )
968 | else:
969 | raise OfflineError("The device is currently offline, no action was sent.")
970 |
971 | def set_acs(self, serial_number: str, state: bool) -> None:
972 | """Enable or disable the ACS module.
973 |
974 | Args:
975 | serial_number (str): Serial number of the device
976 | state (bool): True is enabling ACS, False is disabling ACS.
977 |
978 | Raises:
979 | NoACSModuleError: Raised if the device does not support ACS.
980 | OfflineError: Raised if the device is offline.
981 | """
982 | mower = self.get_mower(serial_number)
983 | if mower["online"]:
984 | device = DeviceHandler(self._api, mower, self._tz)
985 | if device.capabilities.check(DeviceCapability.ACS):
986 | self.mqtt.publish(
987 | serial_number if mower["protocol"] == 0 else mower["uuid"],
988 | mower["mqtt_topics"]["command_in"],
989 | {"cmd": 0, "modules": {"US": {"enabled": 1 if state else 0}}},
990 | mower["protocol"],
991 | )
992 | else:
993 | raise NoACSModuleError(
994 | "This device does not have an ACS module installed."
995 | )
996 | else:
997 | raise OfflineError("The device is currently offline, no action was sent.")
998 |
--------------------------------------------------------------------------------
/pyworxcloud/api.py:
--------------------------------------------------------------------------------
1 | """Landroid Cloud API implementation"""
2 |
3 | # pylint: disable=unnecessary-lambda
4 | from __future__ import annotations
5 |
6 | import logging
7 | import time
8 |
9 | from .clouds import CloudType
10 | from .exceptions import TooManyRequestsError
11 | from .utils.requests import GET, HEADERS, POST
12 |
13 | _LOGGER = logging.getLogger(__name__)
14 |
15 |
16 | class LandroidCloudAPI:
17 | """Landroid Cloud API definition."""
18 |
19 | def __init__(
20 | self,
21 | username: str,
22 | password: str,
23 | cloud: CloudType.WORX | CloudType.KRESS | CloudType.LANDXCAPE,
24 | tz: str | None = None, # pylint: disable=invalid-name
25 | token_callback: callable | None = None,
26 | ) -> None:
27 | """Initialize a new instance of the API broker.
28 |
29 | Args:
30 | username (str): Email for the user account.
31 | password (str): Password for the user account.
32 | cloud (CloudType.WORX | CloudType.KRESS | CloudType.LANDXCAPE , optional): CloudType representing the device. Defaults to CloudType.WORX.
33 | """
34 | self.cloud: CloudType = cloud
35 | self._token_type = "app"
36 | self.access_token = None
37 | self.refresh_token = None
38 | self._token_expire = 0
39 | self.uuid = None
40 | self._api_host = None
41 | self.api_data = None
42 | self._tz = tz
43 | self._callback = token_callback
44 |
45 | self.username = username
46 | self.password = password
47 |
48 | def get_token(self) -> None:
49 | """Get the access and refresh tokens."""
50 | url = f"https://{self.cloud.AUTH_ENDPOINT}/oauth/token"
51 | request_body = {
52 | "grant_type": "password",
53 | "client_id": self.cloud.AUTH_CLIENT_ID,
54 | "scope": "*",
55 | "username": self.username,
56 | "password": self.password,
57 | }
58 |
59 | try:
60 | resp = POST(url, request_body, HEADERS())
61 | self.access_token = resp["access_token"]
62 | self.refresh_token = resp["refresh_token"]
63 | now = int(time.time())
64 | self._token_expire = now + int(resp["expires_in"])
65 | except TooManyRequestsError:
66 | raise TooManyRequestsError from None
67 | except:
68 | return
69 |
70 | def _update_token(self) -> None:
71 | """Refresh the tokens."""
72 | url = f"https://{self.cloud.AUTH_ENDPOINT}/oauth/token"
73 | request_body = {
74 | "grant_type": "refresh_token",
75 | "client_id": self.cloud.AUTH_CLIENT_ID,
76 | "scope": "*",
77 | "refresh_token": self.refresh_token,
78 | }
79 |
80 | resp = POST(url, request_body, HEADERS())
81 | self.access_token = resp["access_token"]
82 | self.refresh_token = resp["refresh_token"]
83 | now = int(time.time())
84 | self._token_expire = now + int(resp["expires_in"])
85 |
86 | def _get_headers(self, tokenheaders: bool = False) -> dict:
87 | """Create header object for communication packets."""
88 | header_data = {}
89 | if tokenheaders:
90 | header_data["Content-Type"] = "application/x-www-form-urlencoded"
91 | else:
92 | header_data["Content-Type"] = "application/json"
93 | header_data["Authorization"] = self._token_type + " " + self.access_token
94 |
95 | return header_data
96 |
97 | def authenticate(self) -> bool:
98 | """Check tokens."""
99 | if isinstance(self.access_token, type(None)) or isinstance(
100 | self.refresh_token, type(None)
101 | ):
102 | return False
103 | return True
104 |
105 | def check_token(self) -> None:
106 | """Check token and refresh if needed."""
107 | now = int(time.time())
108 |
109 | if (now + 1800) >= self._token_expire:
110 | _LOGGER.debug("Updating access_token")
111 | self._update_token()
112 | if self._callback:
113 | self._callback()
114 |
115 | def get_mowers(self) -> str:
116 | """Get mowers associated with the account.
117 |
118 | Returns:
119 | str: JSON object containing available mowers associated with the account.
120 | """
121 | self.check_token()
122 |
123 | mowers = GET(
124 | f"https://{self.cloud.ENDPOINT}/api/v2/product-items?status=1",
125 | HEADERS(self.access_token),
126 | )
127 | for mower in mowers:
128 | _LOGGER.debug("Matching models for mower '%s'", mower["name"])
129 | model = self.get_model(mower["product_id"])
130 | mower["model"] = {
131 | "code": model["code"],
132 | "friendly_name": str.format(
133 | "{}{}", model["default_name"], model["meters"]
134 | ),
135 | "model_year": model["product_year"],
136 | "cutting_width": model["cutting_width"],
137 | }
138 |
139 | return mowers
140 |
141 | def get_model(self, product_id: int) -> str | None:
142 | """Get model from product_id.
143 |
144 | Returns:
145 | str: JSON object containing detailed product information.
146 | None: Returned when product_id couldn't be matched to a product.
147 | """
148 | self.check_token()
149 |
150 | products = GET(
151 | f"https://{self.cloud.ENDPOINT}/api/v2/products",
152 | HEADERS(self.access_token),
153 | )
154 |
155 | product_info = None
156 | for product in products:
157 | if product["id"] == product_id:
158 | product_info = product
159 | break
160 |
161 | return product_info
162 |
163 | @property
164 | def data(self) -> str:
165 | """Return the latest dataset of information and states from the API."""
166 | return self.api_data
167 |
--------------------------------------------------------------------------------
/pyworxcloud/clouds.py:
--------------------------------------------------------------------------------
1 | """Supported cloud endpoints."""
2 |
3 | from __future__ import annotations
4 |
5 | WORX = "worx"
6 | KRESS = "kress"
7 | LANDXCAPE = "landxcape"
8 |
9 |
10 | class CloudType(object):
11 | """Supported cloud types.
12 |
13 | CloudType.WORX: For Worx Landroid devices.
14 |
15 | CloudType.KRESS: For Kress devices.
16 |
17 | CloudType.LANDXCAPE: For Landxcape devices.
18 | """
19 |
20 | class WORX(str):
21 | """Settings for Worx devices."""
22 |
23 | BRAND_PREFIX: str = "WX"
24 | ENDPOINT: str = "api.worxlandroid.com"
25 | AUTH_ENDPOINT: str = "id.worx.com"
26 | AUTH_CLIENT_ID: str = "150da4d2-bb44-433b-9429-3773adc70a2a"
27 |
28 | class KRESS(str):
29 | """Settings for Kress devices."""
30 |
31 | BRAND_PREFIX: str = "KR"
32 | ENDPOINT: str = "api.kress-robotik.com"
33 | AUTH_ENDPOINT: str = "id.kress.com"
34 | AUTH_CLIENT_ID: str = "931d4bc4-3192-405a-be78-98e43486dc59"
35 |
36 | class LANDXCAPE(str):
37 | """Settings for Landxcape devices."""
38 |
39 | BRAND_PREFIX: str = "LX"
40 | ENDPOINT: str = "api.landxcape-services.com"
41 | AUTH_ENDPOINT: str = "id.landxcape-services.com"
42 | AUTH_CLIENT_ID: str = "dec998a9-066f-433b-987a-f5fc54d3af7c"
43 |
--------------------------------------------------------------------------------
/pyworxcloud/const.py:
--------------------------------------------------------------------------------
1 | """Constants used by Landroid Cloud."""
2 |
3 | from __future__ import annotations
4 |
5 | API_BASE = "https://{}/api/v2"
6 |
7 | UNWANTED_ATTRIBS = [
8 | "distance_covered",
9 | "blade_work_time",
10 | "blade_work_time_reset",
11 | "blade_work_time_reset_at",
12 | "battery_charge_cycles",
13 | "battery_charge_cycles_reset",
14 | "battery_charge_cycles_reset_at",
15 | "app_settings",
16 | "features",
17 | "iot_registered",
18 | "pending_radio_link_validation",
19 | "purchased_at",
20 | "push_notifications",
21 | "push_notifications_level",
22 | "created_at",
23 | "test",
24 | "updated_at",
25 | "warranty_registered",
26 | "warranty_expires_at",
27 | "user_id",
28 | "firmware_auto_upgrade",
29 | "firmware_version",
30 | "auto_schedule",
31 | "auto_schedule_settings",
32 | "lawn_perimeter",
33 | "lawn_size",
34 | "mqtt_topics",
35 | "mqtt_endpoint",
36 | "messages_in",
37 | "messages_out",
38 | "raw_messages_in",
39 | "raw_messages_out",
40 | ]
41 |
42 | CONST_UNKNOWN = "unknown"
43 |
--------------------------------------------------------------------------------
/pyworxcloud/day_map.py:
--------------------------------------------------------------------------------
1 | """Map days to integer."""
2 |
3 | from __future__ import annotations
4 |
5 | DAY_MAP = {
6 | 0: "sunday",
7 | 1: "monday",
8 | 2: "tuesday",
9 | 3: "wednesday",
10 | 4: "thursday",
11 | 5: "friday",
12 | 6: "saturday",
13 | }
14 |
--------------------------------------------------------------------------------
/pyworxcloud/events.py:
--------------------------------------------------------------------------------
1 | """Landroid Cloud callback events"""
2 |
3 | # pylint: disable=unnecessary-lambda
4 | from __future__ import annotations
5 |
6 | import logging
7 | from enum import IntEnum
8 | from typing import Any
9 |
10 |
11 | class LandroidEvent(IntEnum):
12 | """Enum for Landroid event types."""
13 |
14 | DATA_RECEIVED = 0
15 | MQTT_CONNECTION = 1
16 | MQTT_RATELIMIT = 2
17 | MQTT_PUBLISH = 3
18 | LOG = 4
19 | API = 5
20 |
21 |
22 | _LOGGER = logging.getLogger("pyworxcloud.events")
23 |
24 |
25 | def check_syntax(args: dict[str, Any], objs: list[str], expected_type: Any) -> bool:
26 | """Check if the object is of the expected type."""
27 | _LOGGER.debug("Checking %s against %s", objs, args)
28 | for obj in objs:
29 | if not obj in args:
30 | _LOGGER.debug("%s was not found in %s", obj, args)
31 | return False
32 | if not isinstance(args[obj], expected_type):
33 | _LOGGER.debug(
34 | "%s was of type %s and not as expected %s",
35 | obj,
36 | type(obj),
37 | expected_type,
38 | )
39 | return False
40 |
41 | return True
42 |
43 |
44 | class EventHandler:
45 | """Event handler for Landroid Cloud."""
46 |
47 | __events: dict[LandroidEvent, Any] = {}
48 |
49 | def __init__(self) -> None:
50 | """Initialize the event handler object."""
51 |
52 | def set_handler(self, event: LandroidEvent, func: Any) -> None:
53 | """Set handler for a LandroidEvent"""
54 | self.__events.update({event: func})
55 |
56 | def del_handler(self, event: LandroidEvent) -> None:
57 | """Remove a handler for a LandroidEvent."""
58 | self.__events.pop(event)
59 |
60 | def call(self, event: LandroidEvent, **kwargs) -> bool:
61 | """Call a handler if it was set."""
62 | if not event in self.__events:
63 | # Event was not set
64 | return False
65 |
66 | if LandroidEvent.DATA_RECEIVED == event or LandroidEvent.API == event:
67 | from .utils.devices import DeviceHandler
68 |
69 | if not check_syntax(kwargs, ["name"], str) or not check_syntax(
70 | kwargs, ["device"], DeviceHandler
71 | ):
72 | _LOGGER.warning(
73 | "requirements for attributes was not fulfilled, not sending event!"
74 | )
75 | return False
76 |
77 | self.__events[event](name=kwargs["name"], device=kwargs["device"])
78 | return True
79 | elif LandroidEvent.MQTT_CONNECTION == event:
80 | if not check_syntax(kwargs, ["state"], bool):
81 | return False
82 |
83 | self.__events[event](state=kwargs["state"])
84 | return True
85 | elif LandroidEvent.MQTT_RATELIMIT == event:
86 | if not check_syntax(kwargs, ["message"], str):
87 | return False
88 |
89 | self.__events[event](message=kwargs["message"])
90 | return True
91 | elif LandroidEvent.MQTT_PUBLISH == event:
92 | if not check_syntax(kwargs, ["message", "device", "topic"], str):
93 | return False
94 |
95 | if not check_syntax(kwargs, ["qos"], int):
96 | return False
97 |
98 | if not check_syntax(kwargs, ["retain"], bool):
99 | return False
100 |
101 | self.__events[event](
102 | message=kwargs["message"],
103 | qos=kwargs["qos"],
104 | retain=kwargs["retain"],
105 | device=kwargs["device"],
106 | topic=kwargs["topic"],
107 | )
108 | return True
109 | elif LandroidEvent.LOG == event:
110 | if not check_syntax(kwargs, ["message", "level"], str):
111 | return False
112 |
113 | self.__events[event](
114 | message=kwargs["message"],
115 | level=kwargs["level"],
116 | )
117 | return True
118 | else:
119 | # Not a valid LandroidEvent
120 | return False
121 |
--------------------------------------------------------------------------------
/pyworxcloud/exceptions.py:
--------------------------------------------------------------------------------
1 | """Landroid Cloud exception definitions."""
2 |
3 | from __future__ import annotations
4 |
5 |
6 | class InvalidDataDecodeException(Exception):
7 | """Raised when there was an error decoding data."""
8 |
9 |
10 | class NoPartymodeError(Exception):
11 | """Define an error when partymode is not supported."""
12 |
13 |
14 | class NoOfflimitsError(Exception):
15 | """Define an error when Off Limits module isn't present or supported."""
16 |
17 |
18 | class NoOneTimeScheduleError(Exception):
19 | """Define an error when OTS is not supported."""
20 |
21 |
22 | class OfflineError(Exception):
23 | """Define an offline error."""
24 |
25 |
26 | class TokenError(Exception):
27 | """Define an token error."""
28 |
29 |
30 | class APIException(Exception):
31 | """Define an error when communicating with the API."""
32 |
33 |
34 | class TimeoutException(Exception):
35 | """Define a timeout error."""
36 |
37 |
38 | class RequestException(Exception):
39 | """Define a request exception."""
40 |
41 |
42 | class RateLimit(Exception):
43 | """Defines a ratelimit exception."""
44 |
45 | def __init__(self, message):
46 | """Custom ratelimit exception class"""
47 | super(RateLimit, self).__init__(message)
48 | self.message = message
49 |
50 |
51 | class MQTTException(Exception):
52 | """Defines a MQTT exception."""
53 |
54 |
55 | # Exception classes for URL requests
56 | class RequestError(Exception):
57 | """Define a bad request error (400)."""
58 |
59 |
60 | class AuthorizationError(Exception):
61 | """Represents an authorization error (401)."""
62 |
63 |
64 | class ForbiddenError(Exception):
65 | """Represents an access forbidden error (403)."""
66 |
67 |
68 | class NotFoundError(Exception):
69 | """Represents a not found error (404)."""
70 |
71 |
72 | class TooManyRequestsError(Exception):
73 | """Represents a error when request quota have been exceeded (429)."""
74 |
75 |
76 | class InternalServerError(Exception):
77 | """Represents an internal server error (500)."""
78 |
79 |
80 | class ServiceUnavailableError(Exception):
81 | """Represents a service unavailable error (503)."""
82 |
83 |
84 | class APIError(Exception):
85 | """Error representing a generic API error."""
86 |
87 |
88 | class MowerNotFoundError(Exception):
89 | """Error raised when a specific requested mower was not found in the result."""
90 |
91 |
92 | class NoConnectionError(Exception):
93 | """Raised when the endpoint cannot be reached."""
94 |
95 |
96 | class ZoneNotDefined(Exception):
97 | """Raised when the requested zone is not defined."""
98 |
99 |
100 | class ZoneNoProbability(Exception):
101 | """Raised when the requested zone is has no probability set."""
102 |
103 |
104 | class NoCuttingHeightError(Exception):
105 | """Raised when the mower doesn't support setting or retrieving cutting height."""
106 |
107 |
108 | class NoACSModuleError(Exception):
109 | """Raised when the mower doesn't have ACS module."""
110 |
--------------------------------------------------------------------------------
/pyworxcloud/helpers/__init__.py:
--------------------------------------------------------------------------------
1 | """Helpers classes."""
2 |
3 | from __future__ import annotations
4 |
5 | from .logger import get_logger
6 | from .time_format import convert_to_time, string_to_time
7 |
8 | __all__ = [convert_to_time, get_logger, string_to_time]
9 |
--------------------------------------------------------------------------------
/pyworxcloud/helpers/logger.py:
--------------------------------------------------------------------------------
1 | """Handling logger setup."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 |
7 |
8 | def get_logger(name: str) -> logging.Logger:
9 | """Configure the logger component."""
10 |
11 | logger = logging.getLogger(name)
12 |
13 | # configure log formatter
14 | logFormatter = logging.Formatter(
15 | "%(asctime)s [%(filename)s] [%(funcName)s] [%(levelname)s] [%(lineno)d] %(message)s"
16 | )
17 |
18 | # configure stream handler
19 | consoleHandler = logging.StreamHandler()
20 | consoleHandler.setFormatter(logFormatter)
21 |
22 | if not len(logger.root.handlers):
23 | logger.setLevel(logging.DEBUG)
24 | logger.addHandler(consoleHandler)
25 |
26 | return logger
27 |
--------------------------------------------------------------------------------
/pyworxcloud/helpers/time_format.py:
--------------------------------------------------------------------------------
1 | """Time formatting helpers."""
2 |
3 | from __future__ import annotations
4 |
5 | import re
6 | from datetime import datetime
7 | from typing import Any
8 | from zoneinfo import ZoneInfo
9 |
10 | from ..utils.schedules import Schedule
11 |
12 | # try:
13 | # from ..utils import __all__ as all_utils
14 | # except:
15 | # pass
16 |
17 | DATE_FORMATS = [
18 | "%Y-%m-%d %H:%M:%S",
19 | "%d-%m-%Y %H:%M:%S",
20 | "%Y/%m/%d %H:%M:%S",
21 | "%d/%m/%Y %H:%M:%S",
22 | ]
23 |
24 |
25 | def string_to_time(dt_string: str, tz: str = "UTC") -> datetime | str:
26 | """Convert string to datetime object.
27 | Trying all known date/time formats as defined in DATE_FORMATS constant.
28 |
29 | Args:
30 | dt_string (str): String containing the date/time
31 | tz (str): Timezone for the string. default = "UTC"
32 |
33 | Returns:
34 | datetime: datatime object
35 | """
36 | timezone = ZoneInfo(tz) if not isinstance(tz, type(None)) else ZoneInfo("UTC")
37 | for format in DATE_FORMATS:
38 | try:
39 | dt_object = datetime.strptime(dt_string, format).replace(
40 | tzinfo=timezone
41 | ) # .astimezone(timezone)
42 | break
43 | except ValueError:
44 | pass
45 | except TypeError:
46 | # Something wasn't right with the provided string, just return it as it was
47 | dt_object = dt_string
48 |
49 | return dt_object
50 |
51 |
52 | def convert_to_time(
53 | device: str,
54 | data: Any,
55 | tz: str = "UTC",
56 | expression: str = None,
57 | parent: str = None,
58 | subkey: str = None,
59 | callback: Any = None,
60 | ) -> None:
61 | """Find and convert all strings resembling timestamps."""
62 | expression = (
63 | expression or r"\d{2,4}[-\/]\d{1,2}[-\/]\d{1,4} \d{1,2}:\d{1,2}:\d{1,2}"
64 | )
65 | from ..utils import __all__ as all_utils
66 |
67 | if hasattr(data, "__dict__"):
68 | if isinstance(data, Schedule):
69 | pass
70 | data = data.__dict__ if len(data.__dict__) > 0 else data
71 |
72 | if isinstance(subkey, type(None)):
73 | parent = subkey
74 | else:
75 | if isinstance(parent, type(None)):
76 | parent = subkey
77 | else:
78 | parent += f";;{subkey}"
79 |
80 | for key in data:
81 | if key.startswith("_") or key == "devices":
82 | continue
83 |
84 | if not key in data:
85 | continue
86 |
87 | hits = []
88 | value = data[key]
89 |
90 | if isinstance(value, tuple(all_utils)) or isinstance(value, dict):
91 | convert_to_time(
92 | device=device,
93 | data=value,
94 | tz=tz,
95 | expression=expression,
96 | parent=parent,
97 | subkey=key,
98 | callback=callback,
99 | )
100 | elif isinstance(value, str):
101 | hits = re.findall(expression, value)
102 | else:
103 | continue
104 |
105 | if len(hits) == 1:
106 | newtime = string_to_time(hits[0], tz)
107 | callback(device, parent, key, newtime)
108 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """Utils."""
2 |
3 | from __future__ import annotations
4 |
5 | from .battery import Battery
6 | from .blades import Blades
7 | from .capability import Capability, DeviceCapability
8 | from .devices import DeviceHandler
9 | from .location import Location
10 | from .mqtt import MQTT, Command
11 | from .orientation import Orientation
12 | from .product import ProductInfo
13 | from .rainsensor import Rainsensor
14 | from .schedules import Schedule, ScheduleType, Weekdays
15 | from .state import States, StateType
16 | from .statistics import Statistic
17 | from .warranty import Warranty
18 | from .zone import Zone
19 |
20 | __all__ = [
21 | Battery,
22 | Blades,
23 | Capability,
24 | Command,
25 | DeviceHandler,
26 | DeviceCapability,
27 | Location,
28 | MQTT,
29 | Orientation,
30 | ProductInfo,
31 | Rainsensor,
32 | Schedule,
33 | ScheduleType,
34 | States,
35 | StateType,
36 | Statistic,
37 | Warranty,
38 | Weekdays,
39 | Zone,
40 | ]
41 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/battery.py:
--------------------------------------------------------------------------------
1 | """Battery information."""
2 |
3 | from __future__ import annotations
4 |
5 | from enum import IntEnum
6 | from typing import Any
7 |
8 | from .landroid_class import LDict
9 |
10 |
11 | class BatteryState(IntEnum):
12 | """Battery states."""
13 |
14 | UNKNOWN = -1
15 | NOT_CHARGING = 0
16 | CHARGING = 1
17 | ERROR_CHARGING = 2
18 |
19 |
20 | CHARGE_MAP = {
21 | BatteryState.UNKNOWN: "unknown",
22 | BatteryState.NOT_CHARGING: False,
23 | BatteryState.CHARGING: True,
24 | BatteryState.ERROR_CHARGING: "error",
25 | }
26 |
27 |
28 | class Battery(LDict):
29 | """Battery information."""
30 |
31 | def __init__(
32 | self, indata: list | None = None, cycle_info: Any | None = None
33 | ) -> None:
34 | """Initialize a battery object."""
35 | super().__init__()
36 |
37 | if not indata and not cycle_info:
38 | return
39 |
40 | if not "cycles" in self:
41 | self["cycles"] = {
42 | "total": 0,
43 | "current": 0,
44 | "reset_at": None,
45 | "reset_time": None,
46 | }
47 |
48 | if not "temperature" in self:
49 | self["temperature"] = None
50 |
51 | if not "voltage" in self:
52 | self["voltage"] = None
53 |
54 | if not "percent" in self:
55 | self["percent"] = None
56 |
57 | if not "charging" in self:
58 | self["charging"] = None
59 |
60 | if indata:
61 | self.set_data(indata)
62 | self._update_cycles()
63 |
64 | if cycle_info:
65 | self._set_cycles(cycle_info)
66 |
67 | def set_data(self, indata: list):
68 | """Update data on existing dataset."""
69 | if "t" in indata:
70 | self["temperature"] = indata["t"]
71 | if "v" in indata:
72 | self["voltage"] = indata["v"]
73 | if "p" in indata:
74 | self["percent"] = indata["p"]
75 | if "c" in indata:
76 | self["charging"] = CHARGE_MAP[indata["c"]]
77 | if "nr" in indata:
78 | self["cycles"].update({"total": indata["nr"]})
79 |
80 | def _update_cycles(self) -> None:
81 | """Update cycles info."""
82 | if self["cycles"]["total"] == 0:
83 | return
84 | elif (
85 | isinstance(self["cycles"]["reset_at"], type(None))
86 | and self["cycles"]["total"] > 0
87 | ):
88 | self["cycles"].update({"current": self["cycles"]["total"]})
89 | else:
90 | self["cycles"].update(
91 | {"current": int(self["cycles"]["total"] - self["cycles"]["reset_at"])}
92 | )
93 |
94 | def _set_cycles(self, indata) -> None:
95 | """Set battery cycles information."""
96 | from ..helpers import string_to_time
97 |
98 | if self["cycles"]["total"] == 0:
99 | self["cycles"].update({"total": indata.battery_charge_cycles})
100 |
101 | if indata.battery_charge_cycles_reset is not None:
102 | if self["cycles"]["total"] == 0:
103 | self["cycles"].update(
104 | {
105 | "current": int(
106 | self["cycles"]["total"] - indata.battery_charge_cycles_reset
107 | )
108 | }
109 | )
110 | if self["cycles"]["current"] < 0:
111 | self["cycles"].update({"current": 0})
112 | self["cycles"].update(
113 | {
114 | "reset_at": int(indata.battery_charge_cycles_reset),
115 | "reset_time": (
116 | string_to_time(
117 | indata.battery_charge_cycles_reset_at, indata.time_zone
118 | )
119 | if not isinstance(
120 | indata.battery_charge_cycles_reset_at, type(None)
121 | )
122 | else None
123 | ),
124 | }
125 | )
126 | else:
127 | if self["cycles"]["total"] > 0:
128 | self["cycles"].update({"current": self["cycles"]["total"]})
129 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/blades.py:
--------------------------------------------------------------------------------
1 | """Blade information."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 |
7 | from .landroid_class import LDict
8 |
9 |
10 | class Blades(LDict):
11 | """Blade information."""
12 |
13 | def __init__(
14 | self,
15 | data: Any | None = None,
16 | ) -> None:
17 | """Initialize blade object."""
18 | super().__init__()
19 | from ..helpers import string_to_time
20 |
21 | if isinstance(data, type(None)):
22 | return
23 |
24 | if "blade_work_time" in data:
25 | # Total time with blades on in minutes
26 | self["total_on"] = (
27 | int(data["blade_work_time"])
28 | if not isinstance(data["blade_work_time"], type(None))
29 | else None
30 | )
31 | else:
32 | self["total_on"] = None
33 |
34 | if "blade_work_time_reset" in data:
35 | # Blade time reset at minutes
36 | self["reset_at"] = (
37 | int(data["blade_work_time_reset"])
38 | if not isinstance(data["blade_work_time_reset"], type(None))
39 | else None
40 | )
41 | else:
42 | self["reset_at"] = None
43 |
44 | if "blade_work_time_reset_at" in data:
45 | # Blade time reset time and date
46 | self["reset_time"] = (
47 | string_to_time(data["blade_work_time_reset_at"], None)
48 | if not isinstance(data["blade_work_time_reset_at"], type(None))
49 | else None
50 | )
51 | else:
52 | self["reset_time"] = None
53 |
54 | self._calculate_current_on()
55 |
56 | def set_data(self, indata: list):
57 | """Update data on existing dataset."""
58 | if "b" in indata:
59 | self["total_on"] = indata["b"]
60 |
61 | self._calculate_current_on()
62 |
63 | def _calculate_current_on(self) -> None:
64 | """Calculate current_on attribute."""
65 |
66 | # Calculate blade data since reset, if possible
67 | if self["reset_at"] and self["total_on"]:
68 | # Blade time since last reset
69 | self["current_on"] = int(self["total_on"] - self["reset_at"])
70 | else:
71 | self["current_on"] = self["total_on"]
72 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/capability.py:
--------------------------------------------------------------------------------
1 | """Device capabilities."""
2 |
3 | from __future__ import annotations
4 |
5 | import logging
6 | from enum import IntEnum
7 | from typing import Any
8 |
9 | _LOGGER = logging.getLogger(__name__)
10 |
11 |
12 | class DeviceCapability(IntEnum):
13 | """Available device capabilities."""
14 |
15 | EDGE_CUT = 1
16 | ONE_TIME_SCHEDULE = 2
17 | PARTY_MODE = 4
18 | TORQUE = 8
19 | OFF_LIMITS = 16
20 | CUTTING_HEIGHT = 32
21 | ACS = 64
22 |
23 |
24 | CAPABILITY_TO_TEXT = {
25 | DeviceCapability.EDGE_CUT: "Edge Cut",
26 | DeviceCapability.ONE_TIME_SCHEDULE: "One-Time-Schedule",
27 | DeviceCapability.PARTY_MODE: "Party Mode",
28 | DeviceCapability.TORQUE: "Motor Torque",
29 | DeviceCapability.OFF_LIMITS: "Off Limits",
30 | DeviceCapability.CUTTING_HEIGHT: "Cutting Height",
31 | DeviceCapability.ACS: "ACS",
32 | }
33 |
34 |
35 | class Capability:
36 | """Class for handling device capabilities."""
37 |
38 | def __init__(self, device_data: Any | None = None) -> int:
39 | """Initialize the capability list."""
40 | # super().__init__()
41 | self.__int__: int = 0
42 | self.ready: bool = False
43 | if isinstance(device_data, type(None)):
44 | return
45 |
46 | cfg = (
47 | device_data["cfg"]
48 | if "cfg" in device_data
49 | else device_data["last_status"]["payload"]["cfg"]
50 | )
51 | dat = (
52 | device_data["dat"]
53 | if "dat" in device_data
54 | else device_data["last_status"]["payload"]["dat"]
55 | )
56 |
57 | try:
58 | if "sc" in cfg:
59 | if "ots" in cfg["sc"] or "once" in cfg["sc"]:
60 | self.add(DeviceCapability.ONE_TIME_SCHEDULE)
61 | self.add(DeviceCapability.EDGE_CUT)
62 |
63 | if "distm" in cfg["sc"] or "enabled" in cfg["sc"]:
64 | self.add(DeviceCapability.PARTY_MODE)
65 |
66 | except TypeError:
67 | pass
68 |
69 | try:
70 | if "modules" in dat:
71 | # Offlimits module
72 | if "DF" in dat["modules"]:
73 | self.add(DeviceCapability.OFF_LIMITS)
74 |
75 | # Set cutting height
76 | if "EA" in dat["modules"]:
77 | self.add(DeviceCapability.CUTTING_HEIGHT)
78 |
79 | # ACS module
80 | if "US" in dat["modules"]:
81 | self.add(DeviceCapability.ACS)
82 | except TypeError:
83 | pass
84 |
85 | try:
86 | if "tq" in cfg:
87 | self.add(DeviceCapability.TORQUE)
88 | except TypeError:
89 | pass
90 |
91 | def add(self, capability: DeviceCapability) -> None:
92 | """Add capability to the list."""
93 | if capability & self.__int__ == 0:
94 | self.__int__ = self.__int__ | capability
95 |
96 | def check(self, capability: DeviceCapability) -> bool:
97 | """Check if device has capability."""
98 | if capability & self.__int__ == 0:
99 | return False
100 | else:
101 | return True
102 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/devices.py:
--------------------------------------------------------------------------------
1 | """Class for handling device info and states."""
2 |
3 | from __future__ import annotations
4 |
5 | import json
6 | import logging
7 | from datetime import datetime, timedelta
8 | from typing import Any
9 |
10 | from pyworxcloud.day_map import DAY_MAP
11 |
12 | from ..const import UNWANTED_ATTRIBS
13 | from ..exceptions import APIException, InvalidDataDecodeException
14 | from ..helpers import convert_to_time
15 | from .battery import Battery
16 | from .blades import Blades
17 | from .capability import Capability, DeviceCapability
18 | from .firmware import Firmware
19 | from .landroid_class import LDict
20 | from .lawn import Lawn
21 | from .location import Location
22 | from .orientation import Orientation
23 | from .rainsensor import Rainsensor
24 | from .schedules import TYPE_TO_STRING, Schedule, ScheduleType, Weekdays
25 | from .state import States, StateType
26 | from .statistics import Statistic
27 | from .warranty import Warranty
28 | from .zone import Zone
29 |
30 | LOGGER = logging.getLogger(__name__)
31 |
32 |
33 | class DeviceHandler(LDict):
34 | """DeviceHandler for Landroid Cloud devices."""
35 |
36 | __is_decoded: bool = True
37 | __raw_data: str = None
38 | __json_data: str = None
39 |
40 | def __init__(
41 | self,
42 | api: Any = None,
43 | mower: Any = None,
44 | tz: str | None = None,
45 | decode: bool = True,
46 | ) -> dict:
47 | """Initialize the object."""
48 | super().__init__()
49 |
50 | self._api = api
51 | self.mower = mower
52 | self._tz = tz
53 | self._decode = decode
54 |
55 | self.battery = Battery()
56 | self.blades = Blades()
57 | self.error = States(StateType.ERROR)
58 | self.orientation = Orientation([0, 0, 0])
59 | self.capabilities = Capability()
60 | self.rainsensor = Rainsensor()
61 | self.status = States()
62 | self.zone = Zone()
63 | self.warranty = Warranty()
64 | self.firmware = Firmware()
65 | self.schedules = Schedule()
66 | self.statistics = Statistic([])
67 | self.in_topic = None
68 | self.out_topic = None
69 |
70 | if not isinstance(mower, type(None)) and not isinstance(api, type(None)):
71 | self.__mapinfo(api, mower)
72 |
73 | @property
74 | def raw_data(self) -> str:
75 | """Returns current raw dataset."""
76 | return self.__raw_data
77 |
78 | @property
79 | def json_data(self) -> str:
80 | """Returns current dataset as JSON."""
81 | return self.__json_data
82 |
83 | @raw_data.setter
84 | def raw_data(self, value: str) -> None:
85 | """Set new MQTT data."""
86 | self.__is_decoded = False
87 | self.__raw_data = value
88 | try:
89 | self.__json_data = json.loads(value)
90 | except: # pylint: disable=bare-except
91 | pass # Just continue if we couldn't decode the data
92 |
93 | self.decode_data()
94 |
95 | @property
96 | def is_decoded(self) -> bool:
97 | """Returns true if latest dataset was decoded and handled."""
98 | return self.__is_decoded
99 |
100 | @is_decoded.setter
101 | def is_decoded(self, value: bool) -> None:
102 | """Set decoded flag when dataset was decoded and handled."""
103 | self.__is_decoded = value
104 |
105 | def __mapinfo(self, api: Any, data: Any) -> None:
106 | """Map information from API."""
107 |
108 | if isinstance(data, type(None)) or isinstance(api, type(None)):
109 | raise APIException(
110 | "Either 'data' or 'api' object was missing, no data was mapped!"
111 | )
112 |
113 | for attr, val in data.items():
114 | setattr(self, str(attr), val)
115 |
116 | if not "time_zone" in data:
117 | data["time_zone"] = "UTC"
118 |
119 | self.battery = Battery(data)
120 | self.blades = Blades(data)
121 | self.error = States(StateType.ERROR)
122 | self.orientation = Orientation([0, 0, 0])
123 | self.capabilities = Capability(data)
124 | self.rainsensor = Rainsensor()
125 | self.status = States()
126 | self.zone = Zone(data)
127 | self.warranty = Warranty(data)
128 | self.firmware = Firmware(data)
129 | self.schedules = Schedule(data)
130 | self.statistics = Statistic([])
131 | self.in_topic = data["mqtt_topics"]["command_in"]
132 | self.out_topic = data["mqtt_topics"]["command_out"]
133 |
134 | if data in ["lawn_perimeter", "lawn_size"]:
135 | self.lawn = Lawn(data["lawn_perimeter"], data["lawn_size"])
136 |
137 | self.name = data["name"]
138 | self.model = str.format(
139 | "{} ({})", data["model"]["friendly_name"], data["model"]["code"]
140 | )
141 |
142 | self.mac_address = None
143 | self.protocol = 0
144 | self.time_zone = None
145 |
146 | for attr in UNWANTED_ATTRIBS:
147 | if hasattr(self, attr):
148 | delattr(self, attr)
149 |
150 | if self._decode:
151 | self.decode_data()
152 | self.is_decoded = True
153 |
154 | def decode_data(self) -> None:
155 | """Decode incoming JSON data."""
156 | invalid_data = False
157 | self.is_decoded = False
158 |
159 | logger = LOGGER.getChild("decode_data")
160 | logger.debug("Data decoding for %s started", self.name)
161 |
162 | if self.json_data:
163 | logger.debug("Found JSON decoded data: %s", self.json_data)
164 | data = self.json_data
165 | elif self.raw_data:
166 | logger.debug("Found raw data: %s", self.raw_data)
167 | data = self.raw_data
168 | elif (
169 | not isinstance(self.last_status, type(None))
170 | and "payload" in self.last_status
171 | ):
172 | data = self.last_status["payload"]
173 | else:
174 | self.is_decoded = True
175 | logger.debug("No valid data was found, skipping update for %s", self.name)
176 | return
177 |
178 | if isinstance(self.capabilities, list):
179 | setattr(self, "api_capabilities", getattr(self, "capabilities"))
180 | self.capabilities = Capability(data)
181 |
182 | mower = self.mower
183 | self.protocol = mower["protocol"]
184 |
185 | if "dat" in data:
186 | mower["last_status"]["payload"]["dat"] = data["dat"]
187 | if "uuid" in data["dat"]:
188 | self.uuid = data["dat"]["uuid"]
189 |
190 | if isinstance(self.mac_address, type(None)):
191 | self.mac_address = (
192 | data["dat"]["mac"] if "mac" in data["dat"] else "__UUID__"
193 | )
194 |
195 | try:
196 | # Get wifi signal strength
197 | if "rsi" in data["dat"]:
198 | self.rssi = data["dat"]["rsi"]
199 |
200 | # Get status code
201 | if "ls" in data["dat"]:
202 | self.status.update(data["dat"]["ls"])
203 |
204 | # Get error code
205 | if "le" in data["dat"]:
206 | self.error.update(data["dat"]["le"])
207 |
208 | # Get zone index
209 | self.zone.index = data["dat"]["lz"] if "lz" in data["dat"] else 0
210 |
211 | # Get device lock state
212 | self.locked = bool(data["dat"]["lk"]) if "lk" in data["dat"] else None
213 | mower["locked"] = self.locked
214 |
215 | # Get battery info if available
216 | if "bt" in data["dat"]:
217 | if len(self.battery) == 0:
218 | self.battery = Battery(data["dat"]["bt"])
219 | else:
220 | self.battery.set_data(data["dat"]["bt"])
221 | # Get device statistics if available
222 | if "st" in data["dat"]:
223 | self.statistics = Statistic(data["dat"]["st"])
224 |
225 | if len(self.blades) != 0:
226 | self.blades.set_data(data["dat"]["st"])
227 |
228 | # Get orientation if available.
229 | if "dmp" in data["dat"]:
230 | self.orientation = Orientation(data["dat"]["dmp"])
231 |
232 | # Check for extra module availability
233 | if "modules" in data["dat"]:
234 | if "4G" in data["dat"]["modules"]:
235 | if "gps" in data["dat"]["modules"]["4G"]:
236 | self.gps = Location(
237 | data["dat"]["modules"]["4G"]["gps"]["coo"][0],
238 | data["dat"]["modules"]["4G"]["gps"]["coo"][1],
239 | )
240 |
241 | # Get remaining rain delay if available
242 | if "rain" in data["dat"]:
243 | self.rainsensor.triggered = bool(
244 | str(data["dat"]["rain"]["s"]) == "1"
245 | )
246 | self.rainsensor.remaining = int(data["dat"]["rain"]["cnt"])
247 |
248 | except TypeError: # pylint: disable=bare-except
249 | invalid_data = True
250 |
251 | if "cfg" in data:
252 | mower["last_status"]["payload"]["cfg"] = data["cfg"]
253 | # try:
254 | if "dt" in data["cfg"]:
255 | dt_split = data["cfg"]["dt"].split("/")
256 | date = (
257 | f"{dt_split[2]}-{dt_split[1]}-{dt_split[0]}"
258 | + " "
259 | + data["cfg"]["tm"]
260 | )
261 | elif "tm" in data["dat"]:
262 | date = datetime.fromisoformat(data["dat"]["tm"])
263 | else:
264 | date = datetime.now()
265 |
266 | self.updated = date
267 | self.rainsensor.delay = int(data["cfg"]["rd"])
268 |
269 | # Fetch wheel torque
270 | if "tq" in data["cfg"]:
271 | self.capabilities.add(DeviceCapability.TORQUE)
272 | self.torque = data["cfg"]["tq"]
273 |
274 | # Fetch zone information
275 | if "mz" in data["cfg"] and "mzv" in data["cfg"]:
276 | self.zone.starting_point = data["cfg"]["mz"]
277 | self.zone.indicies = data["cfg"]["mzv"]
278 |
279 | # Map current zone to zone index
280 | self.zone.current = self.zone.indicies[self.zone.index]
281 |
282 | # Fetch main schedule
283 | if "sc" in data["cfg"]:
284 | if "ots" in data["cfg"]["sc"]:
285 | self.capabilities.add(DeviceCapability.ONE_TIME_SCHEDULE)
286 | self.capabilities.add(DeviceCapability.EDGE_CUT)
287 | if "m" in data["cfg"]["sc"] or "enabled" in data["cfg"]["sc"]:
288 | self.capabilities.add(DeviceCapability.PARTY_MODE)
289 | self.partymode_enabled = (
290 | bool(str(data["cfg"]["sc"]["m"]) == "2")
291 | if self.protocol == 0
292 | else bool(str(data["cfg"]["sc"]["enabled"]) == "0")
293 | )
294 | self.schedules["active"] = (
295 | bool(str(data["cfg"]["sc"]["m"]) in ["1", "2"])
296 | if self.protocol == 0
297 | else bool(str(data["cfg"]["sc"]["enabled"]) == "0")
298 | )
299 |
300 | self.schedules["time_extension"] = (
301 | data["cfg"]["sc"]["p"] if self.protocol == 0 else "0"
302 | )
303 |
304 | sch_type = ScheduleType.PRIMARY
305 | self.schedules.update({TYPE_TO_STRING[sch_type]: Weekdays()})
306 |
307 | try:
308 | for day in range(
309 | 0,
310 | (
311 | len(data["cfg"]["sc"]["d"])
312 | if self.protocol == 0
313 | else len(data["cfg"]["sc"]["slots"])
314 | ),
315 | ):
316 | dayOfWeek = ( # pylint: disable=invalid-name
317 | day
318 | if self.protocol == 0
319 | else data["cfg"]["sc"]["slots"][day]["d"]
320 | )
321 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[dayOfWeek]][
322 | "start"
323 | ] = (
324 | data["cfg"]["sc"]["d"][day][0]
325 | if self.protocol == 0
326 | else (
327 | datetime.strptime("00:00", "%H:%M")
328 | + timedelta(
329 | minutes=data["cfg"]["sc"]["slots"][day]["s"]
330 | )
331 | ).strftime("%H:%M")
332 | )
333 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[dayOfWeek]][
334 | "duration"
335 | ] = (
336 | data["cfg"]["sc"]["d"][day][1]
337 | if self.protocol == 0
338 | else data["cfg"]["sc"]["slots"][day]["t"]
339 | )
340 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[dayOfWeek]][
341 | "boundary"
342 | ] = (
343 | bool(data["cfg"]["sc"]["d"][day][2])
344 | if self.protocol == 0
345 | else (
346 | bool(data["cfg"]["sc"]["slots"][day]["cfg"]["cut"]["b"])
347 | if "b" in data["cfg"]["sc"]["slots"][day]["cfg"]["cut"]
348 | else None
349 | )
350 | )
351 |
352 | time_start = datetime.strptime(
353 | self.schedules[TYPE_TO_STRING[sch_type]][
354 | DAY_MAP[dayOfWeek]
355 | ]["start"],
356 | "%H:%M",
357 | )
358 |
359 | if isinstance(
360 | self.schedules[TYPE_TO_STRING[sch_type]][
361 | DAY_MAP[dayOfWeek]
362 | ]["duration"],
363 | type(None),
364 | ):
365 | self.schedules[TYPE_TO_STRING[sch_type]][
366 | DAY_MAP[dayOfWeek]
367 | ]["duration"] = "0"
368 |
369 | duration = int(
370 | self.schedules[TYPE_TO_STRING[sch_type]][
371 | DAY_MAP[dayOfWeek]
372 | ]["duration"]
373 | )
374 |
375 | duration = duration * (
376 | 1 + (int(self.schedules["time_extension"]) / 100)
377 | )
378 | end_time = time_start + timedelta(minutes=duration)
379 |
380 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[dayOfWeek]][
381 | "end"
382 | ] = end_time.time().strftime("%H:%M")
383 | except KeyError:
384 | pass
385 |
386 | # Fetch secondary schedule
387 | try:
388 | if "dd" in data["cfg"]["sc"]:
389 | sch_type = ScheduleType.SECONDARY
390 | self.schedules.update({TYPE_TO_STRING[sch_type]: Weekdays()})
391 |
392 | for day in range(0, len(data["cfg"]["sc"]["dd"])):
393 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
394 | "start"
395 | ] = data["cfg"]["sc"]["dd"][day][0]
396 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
397 | "duration"
398 | ] = data["cfg"]["sc"]["dd"][day][1]
399 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
400 | "boundary"
401 | ] = bool(data["cfg"]["sc"]["dd"][day][2])
402 |
403 | time_start = datetime.strptime(
404 | data["cfg"]["sc"]["dd"][day][0],
405 | "%H:%M",
406 | )
407 |
408 | if isinstance(
409 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
410 | "duration"
411 | ],
412 | type(None),
413 | ):
414 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
415 | "duration"
416 | ] = "0"
417 |
418 | duration = int(
419 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
420 | "duration"
421 | ]
422 | )
423 |
424 | duration = duration * (
425 | 1 + (int(self.schedules["time_extension"]) / 100)
426 | )
427 | end_time = time_start + timedelta(minutes=duration)
428 |
429 | self.schedules[TYPE_TO_STRING[sch_type]][DAY_MAP[day]][
430 | "end"
431 | ] = end_time.time().strftime("%H:%M")
432 | except KeyError:
433 | pass
434 |
435 | # Check for addon modules
436 | if "modules" in data["cfg"]:
437 | if "DF" in data["cfg"]["modules"]:
438 | self.capabilities.add(DeviceCapability.OFF_LIMITS)
439 | self.offlimit = bool(
440 | str(data["cfg"]["modules"]["DF"]["cut"]) == "1"
441 | )
442 | self.offlimit_shortcut = bool(
443 | str(data["cfg"]["modules"]["DF"]["fh"]) == "1"
444 | )
445 |
446 | if "US" in data["cfg"]["modules"]:
447 | self.capabilities.add(DeviceCapability.ACS)
448 | self.acs_enabled = bool(
449 | str(data["cfg"]["modules"]["US"]["enabled"]) == "1"
450 | )
451 |
452 | self.schedules.update_progress_and_next(
453 | tz=(
454 | self._tz if not isinstance(self._tz, type(None)) else self.time_zone
455 | )
456 | )
457 | # except TypeError:
458 | # invalid_data = True
459 | # except KeyError:
460 | # invalid_data = True
461 |
462 | convert_to_time(self.name, self, self._tz, callback=self.update_attribute)
463 |
464 | mower["last_status"]["timestamp"] = self.updated
465 |
466 | self.is_decoded = True
467 | logger.debug("Data for %s was decoded", self.name)
468 | logger.debug("Device object:\n%s", vars(self))
469 |
470 | if invalid_data:
471 | raise InvalidDataDecodeException()
472 |
473 | def update_attribute(self, device: str, attr: str, key: str, value: Any) -> None:
474 | """Used as callback to update value."""
475 | chattr = self
476 | if not isinstance(attr, type(None)):
477 | for level in attr.split(";;"):
478 | if hasattr(chattr, level):
479 | chattr = getattr(chattr, level)
480 | else:
481 | chattr = chattr[level]
482 |
483 | if hasattr(chattr, key):
484 | setattr(chattr, key, value)
485 | elif isinstance(chattr, dict):
486 | chattr.update({key: value})
487 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/firmware.py:
--------------------------------------------------------------------------------
1 | """Firmware information handler."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 |
7 | from ..const import CONST_UNKNOWN
8 | from .landroid_class import LDict
9 |
10 |
11 | class Firmware(LDict):
12 | """Firmware information handler class."""
13 |
14 | def __init__(self, data: Any | None = None) -> None:
15 | if isinstance(data, type(None)):
16 | return
17 |
18 | super().__init__()
19 |
20 | self["auto_upgrade"] = (
21 | data["firmware_auto_upgrade"]
22 | if "firmware_auto_upgrade" in data
23 | else CONST_UNKNOWN
24 | )
25 | self["version"] = (
26 | data["firmware_version"] if "firmware_version" in data else CONST_UNKNOWN
27 | )
28 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/landroid_class.py:
--------------------------------------------------------------------------------
1 | """A class set that helps representing data as wanted."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 |
7 |
8 | class LDict(dict):
9 | """A Landroid custom dict."""
10 |
11 | def __init__(self, default: Any | None = None):
12 | """Init dict."""
13 | super().__init__(self)
14 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/lawn.py:
--------------------------------------------------------------------------------
1 | """Handling lawn parameters."""
2 |
3 | from __future__ import annotations
4 |
5 | from .landroid_class import LDict
6 |
7 |
8 | class Lawn(LDict):
9 | """Handler for lawn parameters."""
10 |
11 | def __init__(self, perimeter, size) -> None:
12 | super().__init__()
13 |
14 | self["perimeter"] = perimeter
15 | self["size"] = size
16 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/location.py:
--------------------------------------------------------------------------------
1 | """Location information."""
2 |
3 | from __future__ import annotations
4 |
5 | from .landroid_class import LDict
6 |
7 |
8 | class Location(LDict):
9 | """GPS location."""
10 |
11 | def __init__(self, latitude: float = None, longitude: float = None):
12 | """Initialize location object."""
13 | super().__init__()
14 |
15 | if not latitude or not longitude:
16 | return
17 |
18 | self["latitude"] = latitude
19 | self["longitude"] = longitude
20 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/mqtt.py:
--------------------------------------------------------------------------------
1 | """MQTT information class."""
2 |
3 | from __future__ import annotations
4 |
5 | import asyncio
6 | import json
7 | import logging
8 | import random
9 | import ssl
10 | import time
11 | import urllib.parse
12 | from datetime import datetime
13 | from logging import Logger
14 | from typing import Any
15 | from uuid import uuid4
16 |
17 | import paho.mqtt.client as mqtt
18 | from paho.mqtt.client import connack_string
19 |
20 | from ..events import EventHandler, LandroidEvent
21 | from ..exceptions import NoConnectionError
22 | from .landroid_class import LDict
23 |
24 | QOS_FLAG = 1
25 |
26 |
27 | class MQTTMsgType(LDict):
28 | """Define specific message type data."""
29 |
30 | def __init__(self) -> dict:
31 | super().__init__()
32 |
33 | self["in"] = 0
34 | self["out"] = 0
35 |
36 |
37 | class MQTTMessageItem(LDict):
38 | """Defines a MQTT message for Landroid Cloud."""
39 |
40 | def __init__(
41 | self, device: str, data: str = "{}", qos: int = 0, retain: bool = False
42 | ) -> dict:
43 | super().__init__()
44 |
45 | self["device"] = device
46 | self["data"] = data
47 | self["qos"] = qos
48 | self["retain"] = retain
49 |
50 |
51 | class MQTTMessages(LDict):
52 | """Messages class."""
53 |
54 | def __init__(self) -> dict:
55 | super().__init__()
56 |
57 | self["raw"] = MQTTMsgType()
58 | self["filtered"] = MQTTMsgType()
59 |
60 |
61 | class MQTTTopics(LDict):
62 | """Topics class."""
63 |
64 | def __init__(
65 | self, topic_in: str | None = None, topic_out: str | None = None
66 | ) -> dict:
67 | super().__init__()
68 |
69 | self["in"] = topic_in
70 | self["out"] = topic_out
71 |
72 |
73 | class Command:
74 | """Landroid Cloud commands."""
75 |
76 | FORCE_REFRESH = 0
77 | START = 1
78 | PAUSE = 2
79 | HOME = 3
80 | ZONETRAINING = 4
81 | LOCK = 5
82 | UNLOCK = 6
83 | RESTART = 7
84 | PAUSE_OVER_WIRE = 8
85 | SAFEHOME = 9
86 |
87 |
88 | class MQTT(LDict):
89 | """Full MQTT handler class."""
90 |
91 | def __init__(
92 | self,
93 | api: Any,
94 | brandprefix: str,
95 | endpoint: str,
96 | user_id: int,
97 | logger: Logger,
98 | callback: Any,
99 | ) -> dict:
100 | """Initialize AWSIoT MQTT handler."""
101 |
102 | super().__init__()
103 | # self.client = None
104 | self._events = EventHandler()
105 | self._on_update = callback
106 | self._endpoint = endpoint
107 | self._log = logger.getChild("MQTT")
108 | self._disconnected: bool = False
109 | self._reconnected: bool = False
110 | self._topic: list = []
111 | self._api = api
112 | self._await_publish: bool = False
113 | self._await_timestamp: time = None
114 | self._uuid = uuid4()
115 | self._is_connected: bool = False
116 | self._brandprefix = brandprefix
117 | self._user_id = user_id
118 |
119 | self.client = mqtt.Client(
120 | client_id=f"{self._brandprefix}/USER/{self._user_id}/homeassistant/{self._uuid}",
121 | clean_session=False,
122 | userdata=None,
123 | reconnect_on_failure=True,
124 | )
125 |
126 | accesstokenparts = (
127 | api.access_token.replace("_", "/").replace("-", "+").split(".")
128 | )
129 | self.client.username_pw_set(
130 | username=f"bot?jwt={urllib.parse.quote(accesstokenparts[0])}.{urllib.parse.quote(accesstokenparts[1])}&x-amz-customauthorizer-name=''&x-amz-customauthorizer-signature={urllib.parse.quote(accesstokenparts[2])}", # pylint: disable= line-too-long
131 | password=None,
132 | )
133 |
134 | ssl_context = ssl.create_default_context()
135 | ssl_context.set_alpn_protocols(["mqtt"])
136 | self.client.tls_set_context(context=ssl_context)
137 |
138 | self.client.reconnect_delay_set(min_delay=30, max_delay=300)
139 |
140 | self.client.on_connect = self._on_connect
141 | self.client.on_message = self._forward_on_message
142 | self.client.on_disconnect = self._on_disconnect
143 |
144 | @property
145 | def connected(self) -> bool:
146 | """Returns the MQTT connection state."""
147 | return self._is_connected
148 | # return self.client.is_connected()
149 |
150 | def _forward_on_message(
151 | self,
152 | client: mqtt.Client | None, # pylint: disable=unused-argument
153 | userdata: Any | None, # pylint: disable=unused-argument
154 | message: Any | None,
155 | properties: Any | None = None, # pylint: disable=unused-argument
156 | ) -> None:
157 | """MQTT callback method definition."""
158 | msg = message.payload.decode("utf-8")
159 | self._log.debug("Received MQTT message:\n%s", msg)
160 | self._await_publish = False
161 | self._on_update(msg)
162 |
163 | def subscribe(self, topic: str, append: bool = True) -> None:
164 | """Subscribe to MQTT updates."""
165 | if append and topic not in self._topic:
166 | self._topic.append(topic)
167 | self.client.subscribe(topic=topic, qos=QOS_FLAG)
168 |
169 | def connect(self) -> None:
170 | """Connect to the MQTT service."""
171 | try:
172 | self.client.connect(
173 | self._endpoint,
174 | 443,
175 | )
176 | self.client.loop_start()
177 | while not self.connected:
178 | try:
179 | loop = asyncio.get_running_loop()
180 | except (
181 | RuntimeError
182 | ): # 'RuntimeError: There is no current event loop...'
183 | loop = None
184 |
185 | if loop and loop.is_running():
186 | loop.create_task(asyncio.sleep(0.5))
187 | else:
188 | asyncio.run(asyncio.sleep(0.5))
189 | except NoConnectionError as exc:
190 | raise NoConnectionError() from exc
191 |
192 | def _on_connect(
193 | self,
194 | client: mqtt.Client | None, # pylint: disable=unused-argument
195 | userdata: Any | None, # pylint: disable=unused-argument
196 | flags: Any | None, # pylint: disable=unused-argument
197 | rc: int | None,
198 | properties: Any | None = None, # pylint: disable=unused-argument,invalid-name
199 | ) -> None:
200 | """MQTT callback method."""
201 | logger = self._log.getChild("Conn_State")
202 | logger.debug(connack_string(rc))
203 | if rc == 0:
204 | self._disconnected = False
205 | self._is_connected = True
206 | self._reconnected = False
207 | logger.debug("MQTT connected")
208 | self._events.call(
209 | LandroidEvent.MQTT_CONNECTION, state=self.client.is_connected()
210 | )
211 | for topic in self._topic:
212 | logger.debug("Subscribing '%s'", topic)
213 | self.subscribe(topic, False)
214 | self._await_publish = False
215 | else:
216 | self._is_connected = False
217 | logger.debug("MQTT connection failed")
218 | self._events.call(
219 | LandroidEvent.MQTT_CONNECTION, state=self.client.is_connected()
220 | )
221 |
222 | def _on_disconnect(
223 | self,
224 | client: mqtt.Client | None, # pylint: disable=unused-argument
225 | userdata: Any | None, # pylint: disable=unused-argument
226 | rc: int | None,
227 | properties: Any | None = None, # pylint: disable=unused-argument,invalid-name
228 | ) -> None:
229 | """MQTT callback method."""
230 | logger = self._log.getChild("Conn_State")
231 | self._is_connected = False
232 | if rc > 0:
233 | if rc == 7:
234 | if not self._reconnected:
235 | self._reconnected = True
236 | logger.debug("Reconnecting MQTT")
237 | self._api.check_token()
238 | accesstokenparts = (
239 | self._api.access_token.replace("_", "/")
240 | .replace("-", "+")
241 | .split(".")
242 | )
243 | self.client.username_pw_set(
244 | username=f"bot?jwt={urllib.parse.quote(accesstokenparts[0])}.{urllib.parse.quote(accesstokenparts[1])}&x-amz-customauthorizer-name=''&x-amz-customauthorizer-signature={urllib.parse.quote(accesstokenparts[2])}", # pylint: disable= line-too-long
245 | password=None,
246 | )
247 | else:
248 | self.disconnect()
249 | raise NoConnectionError("Error connecting to AwSIoT MQTT")
250 | else:
251 | logger.debug(
252 | "Unexpected MQTT disconnect (%s: %s) - retrying",
253 | rc,
254 | connack_string(rc),
255 | )
256 | try:
257 | self.client.reconnect()
258 | except: # pylint: disable=bare-except
259 | pass
260 |
261 | def update_token(self) -> None:
262 | """Update the token."""
263 | self._log.debug("Updating token")
264 | accesstokenparts = (
265 | self._api.access_token.replace("_", "/").replace("-", "+").split(".")
266 | )
267 | self.client.username_pw_set(
268 | username=f"bot?jwt={urllib.parse.quote(accesstokenparts[0])}.{urllib.parse.quote(accesstokenparts[1])}&x-amz-customauthorizer-name=''&x-amz-customauthorizer-signature={urllib.parse.quote(accesstokenparts[2])}", # pylint: disable= line-too-long
269 | password=None,
270 | )
271 | self.client.reconnect()
272 | self._log.debug("Token updated")
273 |
274 | def disconnect(
275 | self, reasoncode=None, properties=None # pylint: disable=unused-argument
276 | ):
277 | """Disconnect from AWSIoT MQTT server."""
278 | logger = self._log.getChild("MQTT_Disconnect")
279 | for topic in self._topic:
280 | logger.debug("Unsubscribing '%s'", topic)
281 | self.client.unsubscribe(topic)
282 | self._topic = []
283 | self._disconnected = True
284 | self.client.loop_stop()
285 | self.client.disconnect()
286 | logger.debug("MQTT disconnected")
287 |
288 | def ping(self, serial_number: str, topic: str, protocol: int = 0) -> None:
289 | """Ping (update) the mower."""
290 | cmd = {"cmd": Command.FORCE_REFRESH}
291 | try:
292 | self._log.debug("Sending '%s' on topic '%s'", cmd, topic)
293 | self.publish(serial_number, topic, cmd, protocol)
294 | except NoConnectionError:
295 | pass
296 |
297 | def command(
298 | self, serial_number: str, topic: str, action: Command, protocol: int = 0
299 | ) -> None:
300 | """Send a specific command to the mower."""
301 | cmd = self.format_message(serial_number, {"cmd": action}, protocol)
302 | self._log.debug("Sending '%s' on topic '%s'", cmd, topic)
303 | self.client.publish(topic, cmd, QOS_FLAG)
304 |
305 | def publish(
306 | self, serial_number: str, topic: str, message: dict, protocol: int = 0
307 | ) -> None:
308 | """Publish message to the mower."""
309 | if not self.connected:
310 | raise NoConnectionError("No connection to AwSIoT MQTT")
311 |
312 | while self._await_publish:
313 | if self._await_timestamp + 30 >= time.time():
314 | self._await_publish = False
315 | break
316 | asyncio.run(asyncio.sleep(1))
317 |
318 | self._await_publish = True
319 | self._await_timestamp = time.time()
320 | self._log.debug("Publishing message '%s'", message)
321 | self.client.publish(
322 | topic, self.format_message(serial_number, message, protocol), QOS_FLAG
323 | )
324 |
325 | def format_message(self, serial_number: str, message: dict, protocol: int) -> str:
326 | """
327 | Format a message.
328 | Message is expected to be a dict like this: {"cmd": 1}
329 | """
330 | now = datetime.now()
331 | msg = {}
332 | if protocol == 0:
333 | msg = {
334 | "id": random.randint(1024, 65535),
335 | "sn": serial_number,
336 | "tm": now.strftime("%H:%M:%S"),
337 | "dt": now.strftime("%d/%m/%Y"),
338 | }
339 | elif protocol == 1:
340 | msg = {
341 | "id": random.randint(1024, 65535),
342 | "uuid": serial_number,
343 | "tm": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
344 | }
345 |
346 | msg.update(message)
347 | self._log.debug("Formatting message '%s' to '%s'", message, msg)
348 |
349 | return json.dumps(msg)
350 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/orientation.py:
--------------------------------------------------------------------------------
1 | """Device orientation."""
2 |
3 | from __future__ import annotations
4 |
5 | from .landroid_class import LDict
6 |
7 |
8 | class Orientation(LDict):
9 | """Device orientation class."""
10 |
11 | def __init__(self, data: list) -> None:
12 | """Initialize orientation object."""
13 | super().__init__()
14 | if not data:
15 | return
16 |
17 | self["pitch"] = data[0]
18 | self["roll"] = data[1]
19 | self["yaw"] = data[2]
20 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/product.py:
--------------------------------------------------------------------------------
1 | """Handler for the physical device information."""
2 |
3 | from __future__ import annotations
4 |
5 | from enum import IntEnum
6 | from typing import Any
7 |
8 | from .landroid_class import LDict
9 |
10 |
11 | class SetupLocation(LDict):
12 | """Handling setup location."""
13 |
14 | def __init__(self, latitude: float = 0.0, longitude: float = 0.0) -> None:
15 | """Initialize setup location object."""
16 | super().__init__()
17 |
18 | self["latitude"] = latitude
19 | self["longitude"] = longitude
20 |
21 |
22 | class WarrantyInfo(LDict):
23 | """Handling warranty information."""
24 |
25 |
26 | class InfoType(IntEnum):
27 | MOWER = 0
28 |
29 |
30 | class ProductInfo(LDict):
31 | """Handling mainboard information."""
32 |
33 | def __init__(
34 | self,
35 | info_type: InfoType,
36 | api: Any | None = None,
37 | product_id: int | None = None,
38 | ) -> dict:
39 | """Initialize mower infor object."""
40 | super().__init__()
41 |
42 | if product_id and api:
43 | self.get_information_from_id(info_type, api, product_id)
44 |
45 | def get_information_from_id(
46 | self, info_type: InfoType, api: Any, product_id: int
47 | ) -> None:
48 | """Get the device information based on ID."""
49 |
50 | # api_prod = None
51 | # if info_type == InfoType.MOWER:
52 | # api_prod = api.get_product_info(product_id)
53 |
54 | # for attr, val in api_prod.items():
55 | # setattr(self, str(attr), val)
56 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/rainsensor.py:
--------------------------------------------------------------------------------
1 | """Rain sensor information."""
2 |
3 | from __future__ import annotations
4 |
5 | from .landroid_class import LDict
6 |
7 |
8 | class Rainsensor(LDict):
9 | """Rain sensor definition."""
10 |
11 | def __init__(self) -> dict:
12 | """Initialize rain sensor object."""
13 | super().__init__()
14 |
15 | self["delay"] = 0
16 |
17 | @property
18 | def delay(self) -> int:
19 | """Return rain delay."""
20 | return self["delay"]
21 |
22 | @delay.setter
23 | def delay(self, raindelay: int) -> None:
24 | """Set rain delay information."""
25 | self["delay"] = raindelay
26 |
27 | @property
28 | def triggered(self) -> bool:
29 | """Return rain sensor trigger state."""
30 | return self["triggered"]
31 |
32 | @triggered.setter
33 | def triggered(self, triggered: bool) -> None:
34 | """Set rain sensor trigger state."""
35 | self["triggered"] = triggered
36 |
37 | @property
38 | def remaining(self) -> int:
39 | """Return remaining rain delay."""
40 | return self["remaining"]
41 |
42 | @remaining.setter
43 | def remaining(self, remaining: int) -> None:
44 | """Set remaining rain delay information."""
45 | self["remaining"] = remaining
46 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/requests.py:
--------------------------------------------------------------------------------
1 | """For handling HTTP/HTTPS requests."""
2 |
3 | from __future__ import annotations
4 |
5 | from time import sleep
6 |
7 | import requests
8 |
9 | from ..exceptions import (
10 | APIError,
11 | AuthorizationError,
12 | ForbiddenError,
13 | InternalServerError,
14 | NoConnectionError,
15 | NotFoundError,
16 | RequestError,
17 | ServiceUnavailableError,
18 | TooManyRequestsError,
19 | )
20 |
21 | # pylint: disable=invalid-name
22 |
23 | NUM_RETRIES = 5
24 | MAX_BACKOFF = 120
25 | BACKOFF_FACTOR = 3
26 |
27 |
28 | def backoff(retry: int) -> float:
29 | """Calculate backoff time."""
30 | val: float = BACKOFF_FACTOR * (2 ** (retry - 1))
31 |
32 | return val if val <= MAX_BACKOFF else MAX_BACKOFF
33 |
34 |
35 | def HEADERS(access_token: str | None = None) -> dict:
36 | """Generate headers dictionary."""
37 | head = {
38 | "Accept": "application/json",
39 | }
40 |
41 | if isinstance(access_token, type(None)):
42 | head.update({"Content-Type": "application/x-www-form-urlencoded"})
43 | else:
44 | head.update({"Authorization": f"Bearer {access_token}"})
45 |
46 | return head
47 |
48 |
49 | def POST(URL: str, REQUEST_BODY: str, HEADER: dict | None = None) -> str:
50 | """A request POST"""
51 |
52 | if isinstance(HEADER, type(None)):
53 | HEADER = HEADERS()
54 |
55 | for retry in range(NUM_RETRIES):
56 | try:
57 | req = requests.post(
58 | URL, REQUEST_BODY, headers=HEADER, timeout=60, cookies=None
59 | ) # 60 seconds timeout
60 |
61 | req.raise_for_status()
62 |
63 | return req.json()
64 | except requests.exceptions.HTTPError as err:
65 | code = err.response.status_code
66 | if code == 400:
67 | raise RequestError()
68 | elif code == 401:
69 | raise AuthorizationError()
70 | elif code == 403:
71 | raise ForbiddenError()
72 | elif code == 404:
73 | raise NotFoundError()
74 | elif code == 429:
75 | raise TooManyRequestsError()
76 | elif code == 500:
77 | raise InternalServerError()
78 | elif code == 503:
79 | raise ServiceUnavailableError()
80 | elif code == 504:
81 | sleep(backoff(retry))
82 | pass
83 | else:
84 | raise APIError(err)
85 |
86 | raise NoConnectionError()
87 |
88 |
89 | def GET(URL: str, HEADER: dict | None = None) -> str:
90 | """A request GET"""
91 | if isinstance(HEADER, type(None)):
92 | HEADER = HEADERS()
93 |
94 | for retry in range(NUM_RETRIES):
95 | try:
96 | req = requests.get(
97 | URL, headers=HEADER, timeout=60, cookies=None
98 | ) # 60 seconds timeout
99 |
100 | req.raise_for_status()
101 |
102 | return req.json()
103 | except requests.exceptions.HTTPError as err:
104 | code = err.response.status_code
105 | if code == 400:
106 | raise RequestError()
107 | elif code == 401:
108 | raise AuthorizationError()
109 | elif code == 403:
110 | raise ForbiddenError()
111 | elif code == 404:
112 | raise NotFoundError()
113 | elif code == 429:
114 | raise TooManyRequestsError()
115 | elif code == 500:
116 | raise InternalServerError()
117 | elif code == 503:
118 | raise ServiceUnavailableError()
119 | elif code == 504:
120 | sleep(backoff(retry))
121 | pass
122 | else:
123 | raise APIError(err)
124 |
125 | raise NoConnectionError()
126 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/schedules.py:
--------------------------------------------------------------------------------
1 | """Defines schedule classes."""
2 |
3 | from __future__ import annotations
4 |
5 | import calendar
6 | from datetime import datetime, timedelta
7 | from enum import IntEnum
8 | from typing import Any
9 | from zoneinfo import ZoneInfo
10 |
11 | from ..day_map import DAY_MAP
12 | from .landroid_class import LDict
13 |
14 |
15 | class ScheduleType(IntEnum):
16 | """Schedule types."""
17 |
18 | PRIMARY = 0
19 | SECONDARY = 1
20 |
21 |
22 | TYPE_TO_STRING = {ScheduleType.PRIMARY: "primary", ScheduleType.SECONDARY: "secondary"}
23 |
24 |
25 | class WeekdaySettings(LDict):
26 | """Class representing a weekday setting."""
27 |
28 | def __init__(
29 | self,
30 | start: str = "00:00",
31 | end: str = "00:00",
32 | duration: int = 0,
33 | boundary: bool = False,
34 | ) -> None:
35 | """Initialize the settings."""
36 | super().__init__()
37 | self["start"] = start
38 | self["end"] = end
39 | self["duration"] = duration
40 | self["boundary"] = boundary
41 |
42 |
43 | class Weekdays(LDict):
44 | """Represents all weekdays."""
45 |
46 | def __init__(self) -> dict:
47 | super().__init__()
48 |
49 | for day in list(calendar.day_name):
50 | self.update({day.lower(): WeekdaySettings()})
51 |
52 |
53 | class ScheduleInfo:
54 | """Used for calculate the current schedule progress and show next schedule start."""
55 |
56 | def __init__(self, schedule: Schedule, tz: str | None = None) -> None:
57 | """Initialize the ScheduleInfo object and set values."""
58 | self.__schedule = schedule
59 | now = datetime.now()
60 | timezone = ZoneInfo(tz) if not isinstance(tz, type(None)) else ZoneInfo("UTC")
61 | self._tz = tz
62 | self.__now = now.astimezone(timezone)
63 | self.__today = self.__now.strftime("%d/%m/%Y")
64 |
65 | def _get_schedules(
66 | self, date: datetime, next: bool = False, add_offset: bool = False
67 | ) -> WeekdaySettings | None:
68 | """Get primary and secondary schedule for today or tomorrow."""
69 | day = DAY_MAP[int(date.strftime("%w"))]
70 |
71 | primary = self.__schedule[TYPE_TO_STRING[ScheduleType.PRIMARY]][day]
72 | if primary["duration"] == 0:
73 | return None, None, date
74 |
75 | secondary = (
76 | self.__schedule[TYPE_TO_STRING[ScheduleType.SECONDARY]][day]
77 | if TYPE_TO_STRING[ScheduleType.SECONDARY] in self.__schedule
78 | else None
79 | )
80 |
81 | if (not isinstance(secondary, type(None))) and secondary["duration"] == 0:
82 | secondary = None
83 |
84 | return primary, secondary, date
85 |
86 | def calculate_progress(self) -> int:
87 | """Calculate and return current progress in percent."""
88 | from ..helpers.time_format import string_to_time
89 |
90 | primary, secondary, date = self._get_schedules(self.__now)
91 |
92 | if isinstance(primary, type(None)) and isinstance(secondary, type(None)):
93 | return 100
94 |
95 | start = string_to_time(f"{self.__today} {primary['start']}:00", self._tz)
96 | end = string_to_time(f"{self.__today} {primary['end']}:00", self._tz)
97 |
98 | total_run = primary["duration"]
99 | has_run = 0
100 |
101 | if self.__now >= start and self.__now < end:
102 | has_run = (self.__now - start).total_seconds() / 60
103 | elif self.__now < start:
104 | has_run = 0
105 | else:
106 | has_run = primary["duration"]
107 |
108 | if (not isinstance(secondary, type(None))) and secondary["duration"] > 0:
109 | start = string_to_time(f"{self.__today} {secondary['start']}:00", self._tz)
110 | end = string_to_time(f"{self.__today} {secondary['end']}:00", self._tz)
111 | total_run += secondary["duration"]
112 | if self.__now >= start and self.__now < end:
113 | has_run += (self.__now - start).total_seconds() / 60
114 | elif self.__now < start:
115 | has_run += 0
116 | else:
117 | has_run += primary["duration"]
118 |
119 | pct = (has_run / total_run) * 100
120 | return int(round(pct))
121 |
122 | def next_schedule(self) -> str:
123 | """Find next schedule starting point."""
124 | from ..helpers.time_format import string_to_time
125 |
126 | primary, secondary, date = self._get_schedules(self.__now)
127 | next = None
128 | cnt = 0
129 | while isinstance(primary, type(None)):
130 | if cnt == 7:
131 | # No schedule active for any weekday, return None
132 | return None
133 |
134 | primary, secondary, date = self._get_schedules(date + timedelta(days=1))
135 | cnt += 1
136 |
137 | start = string_to_time(
138 | f"{date.strftime('%d/%m/%Y')} {primary['start']}:00", self._tz
139 | )
140 |
141 | if self.__now < start:
142 | next = start
143 | elif (
144 | (not isinstance(secondary, type(None)))
145 | and start
146 | < string_to_time(
147 | f"{date.strftime('%d/%m/%Y')} {secondary['start']}:00", self._tz
148 | )
149 | and secondary["duration"] > 0
150 | ):
151 | next = string_to_time(
152 | f"{date.strftime('%d/%m/%Y')} {secondary['start']}:00", self._tz
153 | )
154 |
155 | if isinstance(next, type(None)):
156 | primary, secondary, date = self._get_schedules(date + timedelta(days=1))
157 | while isinstance(primary, type(None)):
158 | primary, secondary, date = self._get_schedules(date + timedelta(days=1))
159 | next = string_to_time(
160 | f"{date.strftime('%d/%m/%Y')} {primary['start']}:00", self._tz
161 | )
162 |
163 | return next.strftime("%Y-%m-%d %H:%M:%S")
164 |
165 |
166 | class Schedule(LDict):
167 | """Represents a schedule."""
168 |
169 | def __init__(self, data: Any | None = None) -> None:
170 | """Initialize a schedule."""
171 | if isinstance(data, type(None)):
172 | return
173 |
174 | super().__init__()
175 |
176 | self["daily_progress"] = None
177 | self["next_schedule_start"] = None
178 | self["time_extension"] = 0
179 | self["active"] = True
180 | self["auto_schedule"] = {
181 | "settings": (
182 | data["auto_schedule_settings"]
183 | if "auto_schedule_settings" in data
184 | else {}
185 | ),
186 | "enabled": data["auto_schedule"] if "auto_schedule" in data else False,
187 | }
188 |
189 | def update_progress_and_next(self, tz: str | None = None) -> None:
190 | """Update progress and next scheduled start properties."""
191 |
192 | info = ScheduleInfo(self, tz)
193 | self["daily_progress"] = info.calculate_progress()
194 | self["next_schedule_start"] = info.next_schedule()
195 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/state.py:
--------------------------------------------------------------------------------
1 | """States handler."""
2 |
3 | from __future__ import annotations
4 |
5 | from enum import IntEnum
6 |
7 | from .landroid_class import LDict
8 |
9 | # Valid states - some are missing as these haven't been identified yet
10 | STATE_TO_DESCRIPTION = {
11 | -1: "unknown",
12 | 0: "idle",
13 | 1: "home",
14 | 2: "start sequence",
15 | 3: "leaving home",
16 | 4: "follow wire",
17 | 5: "searching home",
18 | 6: "searching wire",
19 | 7: "mowing",
20 | 8: "lifted",
21 | 9: "trapped",
22 | 10: "blade blocked",
23 | 11: "debug",
24 | 12: "remote control",
25 | 13: "digital fence escape",
26 | 30: "going home",
27 | 31: "zoning",
28 | 32: "cutting edge",
29 | 33: "searching area",
30 | 34: "pause",
31 | 103: "searching zone",
32 | 104: "searching home",
33 | 110: "border crossing",
34 | 111: "exploring lawn",
35 | }
36 |
37 | # Valid error states
38 | ERROR_TO_DESCRIPTION = {
39 | -1: "unknown",
40 | 0: "no error",
41 | 1: "trapped",
42 | 2: "lifted",
43 | 3: "wire missing",
44 | 4: "outside wire",
45 | 5: "rain delay",
46 | 6: "close door to mow",
47 | 7: "close door to go home",
48 | 8: "blade motor blocked",
49 | 9: "wheel motor blocked",
50 | 10: "trapped timeout",
51 | 11: "upside down",
52 | 12: "battery low",
53 | 13: "reverse wire",
54 | 14: "charge error",
55 | 15: "timeout finding home",
56 | 16: "locked",
57 | 17: "battery temperature error",
58 | 18: "dummy model",
59 | 19: "battery trunk open timeout",
60 | 20: "wire sync",
61 | 21: "msg num",
62 | 100: "charging station docking error",
63 | 101: "hbi error",
64 | 102: "ota error",
65 | 103: "map error",
66 | 104: "excessive slope",
67 | 105: "unreachable zone",
68 | 106: "unreachable charging station",
69 | 108: "insufficient sensor data",
70 | 109: "training start disallowed",
71 | 110: "camera error",
72 | 111: "mapping exploration required",
73 | 112: "mapping exploration failed",
74 | 113: "rfid reader error",
75 | 114: "headlight error",
76 | 115: "missing charging station",
77 | 116: "blade height adjustment blocked",
78 | }
79 |
80 |
81 | class StateType(IntEnum):
82 | """State types."""
83 |
84 | STATUS = 0
85 | ERROR = 1
86 |
87 |
88 | class States(LDict):
89 | """States class handler."""
90 |
91 | def update(self, new_id: int) -> None:
92 | """Update the dataset."""
93 | try:
94 | self["id"] = new_id
95 | self["description"] = self.__descriptor[self["id"]]
96 | except KeyError:
97 | self["description"] = self.__descriptor[-1]
98 |
99 | def __init__(self, statetype: StateType = StateType.STATUS) -> dict:
100 | """Initialize the dataset."""
101 | super().__init__()
102 |
103 | self.__descriptor = STATE_TO_DESCRIPTION
104 | if statetype == StateType.ERROR:
105 | self.__descriptor = ERROR_TO_DESCRIPTION
106 |
107 | self["id"] = -1
108 | self["description"] = self.__descriptor[self["id"]]
109 |
110 | @property
111 | def id(self) -> int:
112 | """Return state ID."""
113 | return self["id"]
114 |
115 | @property
116 | def description(self) -> str:
117 | """Return state description."""
118 | return self["description"]
119 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/statistics.py:
--------------------------------------------------------------------------------
1 | """Landroid Cloud statistics class."""
2 |
3 | from __future__ import annotations
4 |
5 | from .landroid_class import LDict
6 |
7 |
8 | class Statistic(LDict):
9 | """Statistics."""
10 |
11 | def __init__(self, data: list | None = None) -> dict:
12 | """Initialize a statistics class."""
13 | super().__init__()
14 |
15 | if isinstance(data, type(list)):
16 | return
17 |
18 | # Total runtime with blades on in minutes
19 | self["worktime_blades_on"] = data["b"] if "b" in data else 0
20 |
21 | # Total distance in meters
22 | self["distance"] = data["d"] if "d" in data else 0
23 |
24 | # Total worktime in minutes
25 | self["worktime_total"] = data["wt"] if "wt" in data else 0
26 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/warranty.py:
--------------------------------------------------------------------------------
1 | """Warranty information handler."""
2 |
3 | from __future__ import annotations
4 |
5 | from datetime import datetime
6 | from typing import Any
7 |
8 | from .landroid_class import LDict
9 |
10 |
11 | class Warranty(LDict):
12 | """Class for handling warranty information."""
13 |
14 | def __init__(self, data: Any | None = None) -> None:
15 | if isinstance(data, type(None)):
16 | return
17 |
18 | from ..helpers.time_format import string_to_time
19 |
20 | super().__init__()
21 |
22 | self["expires_at"] = string_to_time(
23 | data["warranty_expires_at"], data["time_zone"]
24 | )
25 | self["registered"] = data["warranty_registered"]
26 | self["expired"] = (
27 | bool(self["expires_at"] < datetime.now().astimezone())
28 | if not isinstance(self["expires_at"], type(None))
29 | else None
30 | )
31 |
--------------------------------------------------------------------------------
/pyworxcloud/utils/zone.py:
--------------------------------------------------------------------------------
1 | """Zone representation."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any
6 |
7 | from .landroid_class import LDict
8 |
9 |
10 | class Zone(LDict):
11 | """Class for handling zone data."""
12 |
13 | def __init__(self, data: Any | None = None) -> dict:
14 | """Initialize zone object."""
15 | if isinstance(data, type(None)):
16 | return
17 |
18 | super().__init__()
19 |
20 | self["current"] = 0
21 | self["index"] = 0
22 | self["indicies"] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
23 | self["starting_point"] = [0, 0, 0, 0]
24 |
25 | try:
26 | if not "last_status" in data:
27 | return
28 |
29 | if not "payload" in data["last_status"]:
30 | return
31 |
32 | if (
33 | not "dat" in data["last_status"]["payload"]
34 | or not "cfg" in data["last_status"]["payload"]
35 | ):
36 | return
37 |
38 | self["index"] = (
39 | data["last_status"]["payload"]["dat"]["lz"]
40 | if "lz" in data["last_status"]["payload"]["dat"]
41 | else 0
42 | )
43 | self["indicies"] = (
44 | data["last_status"]["payload"]["cfg"]["mzv"]
45 | if "mzv" in data["last_status"]["payload"]["cfg"]
46 | else [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
47 | )
48 | self["starting_point"] = (
49 | data["last_status"]["payload"]["cfg"]["mz"]
50 | if "mz" in data["last_status"]["payload"]["cfg"]
51 | else [0, 0, 0, 0]
52 | )
53 | except TypeError: # pylint: disable=bare-except
54 | pass
55 |
56 | self["current"] = self["indicies"][self["index"]]
57 |
58 | @property
59 | def current(self) -> int:
60 | """Get current zone."""
61 | return self["current"]
62 |
63 | @current.setter
64 | def current(self, value: int) -> None:
65 | """Set current zone property."""
66 | self["current"] = value
67 |
68 | @property
69 | def index(self) -> int:
70 | """Get current index."""
71 | return self["index"]
72 |
73 | @index.setter
74 | def index(self, value: int) -> None:
75 | """Set current index property."""
76 | self["index"] = value
77 |
78 | @property
79 | def indicies(self) -> int:
80 | """Get indicies."""
81 | return self["indicies"]
82 |
83 | @indicies.setter
84 | def indicies(self, value: list) -> None:
85 | """Set indicies property."""
86 | self["indicies"] = value
87 |
88 | @property
89 | def starting_point(self) -> int:
90 | """Get starting points."""
91 | return self["starting_point"]
92 |
93 | @starting_point.setter
94 | def starting_point(self, value: int) -> None:
95 | """Set starting points."""
96 | self["starting_point"] = value
97 |
--------------------------------------------------------------------------------
/test.py:
--------------------------------------------------------------------------------
1 | """Basic test file."""
2 |
3 | import logging
4 | from os import environ
5 | from pprint import pprint
6 | from time import sleep
7 |
8 | from pyworxcloud import WorxCloud
9 |
10 | # logging.basicConfig(level=logging.DEBUG)
11 |
12 | EMAIL = environ["EMAIL"]
13 | PASS = environ["PASSWORD"]
14 | TYPE = environ["TYPE"]
15 |
16 | # Clear the screen for better visibility when debugging
17 | print("\033c", end="")
18 |
19 | iter = 1
20 | max = 1
21 |
22 | while iter <= max:
23 | # Initialize the class
24 | cloud = WorxCloud(EMAIL, PASS, TYPE, tz="Europe/Copenhagen")
25 | cloud.authenticate()
26 | cloud.connect()
27 |
28 | # for _, device in cloud.devices.items():
29 | # cloud.update(device.serial_number)
30 | # pprint(vars(device))
31 | # print(f"{device.name} online: {device.online}")
32 |
33 | # cloud.set_offlimits(device.serial_number, False)
34 | # cloud.set_offlimits_shortcut(device.serial_number, True)
35 | # cloud.set_cutting_height(device.serial_number, 45)
36 | # print(f"Cutting height: {cloud.get_cutting_height(device.serial_number)}")
37 | # cloud.set_acs(device.serial_number, False)
38 |
39 | cloud.disconnect()
40 | iter += 1
41 |
--------------------------------------------------------------------------------
/test_async.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import datetime
3 | import json
4 | import time
5 | from os import environ
6 |
7 | from pyworxcloud import WorxCloud
8 | from pyworxcloud.events import LandroidEvent
9 | from pyworxcloud.utils import DeviceHandler
10 |
11 | EMAIL = environ["EMAIL"]
12 | PASS = environ["PASSWORD"]
13 | TYPE = environ["TYPE"]
14 |
15 | tz = datetime.datetime.now().astimezone().tzinfo.tzname(None)
16 |
17 |
18 | async def main():
19 | loop = asyncio.get_running_loop()
20 | await async_worx()
21 |
22 |
23 | async def async_worx():
24 | # Clear the screen for better visibility when debugging
25 |
26 | # Initialize the class and connect
27 | cloud = WorxCloud(EMAIL, PASS, TYPE, tz="Europe/Copenhagen")
28 | cloud.authenticate()
29 | cloud.connect()
30 | cloud.set_callback(LandroidEvent.DATA_RECEIVED, receive_data)
31 | cloud.set_callback(LandroidEvent.API, receive_api_data)
32 |
33 | print("Listening for new data")
34 | while 1:
35 | pass
36 |
37 | # Self explanatory - disconnect from the cloud
38 | cloud.disconnect()
39 |
40 |
41 | def receive_data(
42 | name: str, device: DeviceHandler # pylint: disable=unused-argument
43 | ) -> None:
44 | """Callback function when the MQTT broker sends new data."""
45 | print("Got data on MQTT from " + name)
46 | print(name + " last status: " + str(device.last_status))
47 |
48 |
49 | def receive_api_data(
50 | name: str, device: DeviceHandler # pylint: disable=unused-argument
51 | ) -> None:
52 | """Callback function when the API data was updated."""
53 | print("API data was refreshed")
54 |
55 |
56 | asyncio.run(main())
57 |
--------------------------------------------------------------------------------
/test_with.py:
--------------------------------------------------------------------------------
1 | """Testfile demonstrating the "with method" of calling the module."""
2 |
3 | from os import environ
4 | from pprint import pprint
5 |
6 | from pyworxcloud import WorxCloud
7 | from pyworxcloud.events import LandroidEvent
8 | from pyworxcloud.utils.devices import DeviceHandler
9 |
10 | EMAIL = environ["EMAIL"]
11 | PASS = environ["PASSWORD"]
12 | TYPE = environ["TYPE"]
13 |
14 |
15 | def receive_data(
16 | self, name: str, device: DeviceHandler # pylint: disable=unused-argument
17 | ) -> None:
18 | """Callback function when the MQTT broker sends new data."""
19 | for _, device in cloud.devices.items():
20 | pprint(vars(device))
21 |
22 |
23 | with WorxCloud(EMAIL, PASS, TYPE) as cloud:
24 | for _, device in cloud.devices.items():
25 | cloud.update(device.serial_number)
26 | pprint(vars(device))
27 |
28 | cloud.set_callback(LandroidEvent.DATA_RECEIVED, receive_data)
29 |
--------------------------------------------------------------------------------