├── .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 | Buy Me A Coffee 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 | Buy Me A Coffee 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 | --------------------------------------------------------------------------------