diff --git a/.github/workflows/builddevdoc.yml b/.github/workflows/builddevdoc.yml index b2e931ce8..664bf8b71 100755 --- a/.github/workflows/builddevdoc.yml +++ b/.github/workflows/builddevdoc.yml @@ -20,6 +20,8 @@ jobs: - run: sudo apt-get install libudev-dev - run: sudo apt-get install librrd-dev libpython3-dev - run: sudo apt-get install gcc --only-upgrade + - run: sudo locale-gen de_DE.UTF-8 + - run: locale -a - uses: actions/checkout@v2 - name: Checkout SmartHomeNG DEVELOP Branch uses: actions/checkout@v2 diff --git a/.github/workflows/buildreleasedoc.yml b/.github/workflows/buildreleasedoc.yml index e0efe9ad1..36923f336 100755 --- a/.github/workflows/buildreleasedoc.yml +++ b/.github/workflows/buildreleasedoc.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.8' ] + python-version: [ '3.9' ] name: Python ${{ matrix.python-version }} steps: - name: update OS (Ubuntu) @@ -21,6 +21,8 @@ jobs: - run: sudo apt-get install libudev-dev - run: sudo apt-get install librrd-dev libpython3-dev - run: sudo apt-get install gcc --only-upgrade + - run: sudo locale-gen de_DE.UTF-8 + - run: locale -a - uses: actions/checkout@v2 - name: Checkout SmartHomeNG DEVELOP Branch uses: actions/checkout@v2 diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 2b2406663..f9525bcd7 100755 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12' ] name: Python ${{ matrix.python-version }} steps: - name: Setup OS (Ubuntu) @@ -42,15 +42,19 @@ jobs: echo github.pull_request.base.ref '${{ github.pull_request.base.ref }}' echo steps.extract_branch.outputs.branch '${{ steps.extract_branch.outputs.branch }}' - - name: Checkout core from branch '${{ steps.extract_branch.outputs.branch }}' (for push) - if: github.event_name != 'pull_request' + - name: Check if branch '${{ steps.extract_branch.outputs.branch }}' exists in smarthomeNG/smarthome + run: echo "code=$(git ls-remote --exit-code --heads https://github.com/smarthomeNG/smarthome ${{ steps.extract_branch.outputs.branch }} > /dev/null; echo $? )" >>$GITHUB_OUTPUT + id: shng_branch_check + + - name: Checkout core from branch '${{ steps.extract_branch.outputs.branch }}' (for push on known smarthomeNG/smarthome branch) + if: github.event_name != 'pull_request' && steps.shng_branch_check.outputs.code == '0' uses: actions/checkout@v3 with: repository: smarthomeNG/smarthome ref: ${{ steps.extract_branch.outputs.branch }} - - name: Checkout core from branch 'develop' (for pull request) - if: github.event_name == 'pull_request' + - name: Checkout core from branch 'develop' (for pull request or push on unknown smarthomeNG/smarthome branch) + if: github.event_name == 'pull_request' || steps.shng_branch_check.outputs.code == '2' uses: actions/checkout@v3 with: repository: smarthomeNG/smarthome @@ -59,7 +63,7 @@ jobs: - name: Checkout plugins from branch '${{steps.extract_branch.outputs.branch}}' uses: actions/checkout@v3 with: - repository: smarthomeNG/plugins + repository: ${{ github.repository_owner }}/plugins ref: ${{steps.extract_branch.outputs.branch}} path: plugins @@ -70,6 +74,8 @@ jobs: architecture: x64 - run: python3 -m pip install --upgrade pip + - name: Install setuptools (needed for Python 3.12) + run: pip install setuptools - name: Install requirements for unit testing run: pip install -r tests/requirements.txt - name: Build Requirements for SmartHomeNG diff --git a/.gitignore b/.gitignore index bc08e4ed4..6a46b1f3c 100755 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,10 @@ ehthumbs.db Thumbs.db # don't upload private plugins -/priv_* +/priv_*/ + +# don't upload plugins loaded from develop to a release installation +/*_dev/ # Pycharm settings /.idea diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..95f8695b9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "roombapysh/roombapy"] + path = roombapysh/roombapy + url = https://github.com/Pacifica15/roombapy.git diff --git a/README.md b/README.md index ca9d05965..a5c05bb3c 100755 --- a/README.md +++ b/README.md @@ -3,20 +3,18 @@ ![Github Tag](https://img.shields.io/github/tag/smarthomeNG/smarthome.svg) ![Made with Python](https://img.shields.io/badge/made%20with-python-blue.svg) [![Join the chat at https://gitter.im/smarthomeNG/smarthome](https://badges.gitter.im/smarthomeNG/smarthome.svg)](https://gitter.im/smarthomeNG/smarthome?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![Build Status on TravisCI](https://travis-ci.com/smarthomeNG/plugins.svg?branch=master)](https://travis-ci.com/smarthomeNG/plugins) + Die Plugins für SmartHomeNG erweitern die Möglichkeiten des Gesamtsystems in dem sie Zugriff auf verschiedene Schnittstellen bereitstellen. -Auf der ([Webseite des Projektes](https://www.smarthomeNG.de)) kann eine [Benutzerdokumentation](https://www.smarthomeNG.de) eingesehen werden. +Es existiert eine [Benutzerdokumentation](https://smarthomeng.github.io/smarthome/) in der Kern und Plugins dokumentiert sind. -Ein [Wiki](https://github.com/smarthomeNG/smarthome/wiki) existiert zumeist in deutscher Sprache. +Ein [Wiki](https://github.com/smarthomeNG/smarthome/wiki) existiert zumeist in deutscher Sprache, wird aber sehr selten erweitert oder aktualisiert. ## Aktueller Status der Entwicklung -[![Aktuelle Entwicklung](https://travis-ci.com/smarthomeNG/plugins.svg?branch=develop)](https://travis-ci.com/smarthomeNG/plugins) - - +Es gibt eine stetig aktualisierte [Dokumentation für Entwickler](https://smarthomeng.github.io/dev_doc/). ## Other languages -It is possible to read the documentation with [Google's translation service](https://translate.google.com/translate?hl=&sl=de&tl=en&u=https://www.smarthomeng.de/dev/user/) in other languages as well. \ No newline at end of file +It is possible to read the documentation with [Google's translation service](https://translate.google.com/translate?hl=&sl=de&tl=en&u=https://smarthomeng.github.io/smarthome/) in other languages as well. diff --git a/__init__.py b/__init__.py index 389159c96..332c84254 100755 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,5 @@ def plugin_release(): - return '1.9.5' + return '1.10.0' def plugin_branch(): return 'master' diff --git a/alexa4p3/README.md b/alexa4p3/README.md index 942fb6c02..c60f19b05 100755 --- a/alexa4p3/README.md +++ b/alexa4p3/README.md @@ -4,34 +4,34 @@ Alexa4PayloadV3 ## Table of Content -1. [Generell](#generell) -2. [Change Log](#changelog) -3. [Requrirements](#requirements)
- [Einrichtung Amazon-Skill / Lambda-Funktion](#Skill) **Neu** -4. [Icon / Display Categories](#Icons) **Update** -5. [Entwicklung / Einbau von neuen Skills](#Entwicklung) -6. [Alexa-ThermostatController](#ThermostatController) + [Thermosensor](#Thermostatsensor) -7. [Alexa-PowerController](#PowerController) -8. [Alexa-BrightnessController](#BrightnessController) -9. [Alexa-PowerLevelController](#PowerLevelController) -10. [Alexa-PercentageController](#PercentageController) -11. [Alexa-LockController](#LockController) -12. [Alexa-CameraStreamController](#CameraStreamController) **Update** -13. [Alexa-SceneController](#SceneController) -14. [Alexa-ContactSensor](#ContactSensor) -15. [Alexa-ColorController](#ColorController) -16. [Alexa-RangeController](#RangeController) **Neu** -17. [Alexa-ColorTemperaturController](#ColorTemperaturController) **Neu** -18. [Alexa-PlaybackController](#PlaybackController) **Neu** -19. [Web-Interface](#webinterface) **Neu** - - - -## [Beispiel-Konfigurationen](#Beispiel) **Neu** - - -- [der fast perfekte Rolladen](#perfect_blind) **Neu** -- [Items in Abhängikeit des letzten benutzten Echos-Devices schalten](#get_last_alexa) **Neu** +1. Generell +2. Change Log +3. Requrirements
+ Einrichtung Amazon-Skill / Lambda-Funktion **Neu** +4. Icon / Display Categories **Update** +5. Entwicklung / Einbau von neuen Skills +6. Alexa-ThermostatController + Thermosensor +7. Alexa-PowerController +8. Alexa-BrightnessController +9. Alexa-PowerLevelController +10. Alexa-PercentageController +11. Alexa-LockController +12. Alexa-CameraStreamController **Update** +13. Alexa-SceneController +14. Alexa-ContactSensor +15. Alexa-ColorController +16. Alexa-RangeController **Neu** +17. Alexa-ColorTemperaturController **Neu** +18. Alexa-PlaybackController **Neu** +19. Web-Interface **Neu** + + + +## Beispiel-Konfigurationen **Neu** + + +- der fast perfekte Rolladen **Neu** +- Items in Abhängikeit des letzten benutzten Echos-Devices schalten **Neu** # -------------------------------------- ## Generell @@ -1030,7 +1030,7 @@ Default-Wert ist : RGB Die Helligkeit wird bei Farbwechsel unverändert beibehalten. Bei HSB-Werten wird der ursprüngliche Wert gepuffert und als aktueller Wert wieder an das Item übergeben. Zum Wechseln der Helligkeit einen BrightnessController hinzufügen -Details siehe [BrightnessController](#BrightnessController) +Details siehe BrightnessController Beispiel Konfiguration (wobei R_Wert, G_Wert, B_Wert für die Gruppenadressen stehen :

 %YAML 1.1
diff --git a/alexa4p3/README.not_convertable.md.off b/alexa4p3/README.not_convertable.md.off
new file mode 100755
index 000000000..942fb6c02
--- /dev/null
+++ b/alexa4p3/README.not_convertable.md.off
@@ -0,0 +1,1275 @@
+# alexa4p3
+
+Alexa4PayloadV3
+
+## Table of Content
+
+1. [Generell](#generell)
+2. [Change Log](#changelog)
+3. [Requrirements](#requirements)
+ [Einrichtung Amazon-Skill / Lambda-Funktion](#Skill) **Neu** +4. [Icon / Display Categories](#Icons) **Update** +5. [Entwicklung / Einbau von neuen Skills](#Entwicklung) +6. [Alexa-ThermostatController](#ThermostatController) + [Thermosensor](#Thermostatsensor) +7. [Alexa-PowerController](#PowerController) +8. [Alexa-BrightnessController](#BrightnessController) +9. [Alexa-PowerLevelController](#PowerLevelController) +10. [Alexa-PercentageController](#PercentageController) +11. [Alexa-LockController](#LockController) +12. [Alexa-CameraStreamController](#CameraStreamController) **Update** +13. [Alexa-SceneController](#SceneController) +14. [Alexa-ContactSensor](#ContactSensor) +15. [Alexa-ColorController](#ColorController) +16. [Alexa-RangeController](#RangeController) **Neu** +17. [Alexa-ColorTemperaturController](#ColorTemperaturController) **Neu** +18. [Alexa-PlaybackController](#PlaybackController) **Neu** +19. [Web-Interface](#webinterface) **Neu** + + + +## [Beispiel-Konfigurationen](#Beispiel) **Neu** + + +- [der fast perfekte Rolladen](#perfect_blind) **Neu** +- [Items in Abhängikeit des letzten benutzten Echos-Devices schalten](#get_last_alexa) **Neu** +# -------------------------------------- + +## Generell + +Die Daten des Plugin müssen in den Ordner /usr/local/smarthome/plugins/alexa4p3/ (wie gewohnt) +Die Rechte entsprechend setzen. + +Das Plugin sollte ohne Änderungen die ursprünglichen Funktionen von Payload V2 +weiterverarbeiten können. + +Um die neuen Payload-Features nutzen zu können muss lediglich die Skill-Version in der Amazon Hölle auf PayLoad Version 3 umgestellt werden. Alles andere kann unverändert weiterverwendet werden. + +Das Plugin muss in der plugin.yaml eingefügt werden : + +

+Alexa4P3:
+    plugin_name: Alexa4P3
+    service_port: 9000
+
+ +Das ursprünglich Plugin kann deaktiviertwerden : + +

+#alexa:
+#    plugin_name: alexa4p3
+#    service_port: 9000
+
+ +Idealerweise kopiert man sich seine ganzen conf/yaml Files aus dem Items-Verzeichnis. +und ersetzt dann die "alten" Actions durch die "Neuen". Nachdem der Skill auf Payload V3 umgestellt wurde +muss ein Discover durchgeführt werden. Im besten Fall funktioniert dann alles wie gewohnt. + +In den Items sind die "neuen" V3 Actions zu definieren : + +Zum Beispiel : + +PayloadV2 : turnon + +PayloadV3 : TurnOn + +Die Actions unterscheiden sich zwischen Payload V2 und V3 oft nur durch Gross/Klein-Schreibung + +## Change Log + +### 20.10.2020 +- Doku von Schuma für die Einrichtung des Skills bei Amazon ergänzt - eingefügt bei Requirements + +### 11.04.2020 +- Version auf 1.0.2 für shNG Release 1.7 erhöht + +### 12.03.2020 +- Ergänzung bei Wertänderung durch das Plugin wid der "Plugin Identifier" "alexa4p3" an die Change Item-Methode übegeben (PR #332) + +### 07.12.2019 +- Web-Interface um Protokoll-Log ergänzt +- PlaybackController realisiert +- bux-fix for alias-Devices, es wurden nicht alle Eigenschaften an das Alias-Device übergeben. Voice-Steuerung funktionierte, Darstellung in der App war nicht korrekt. + +### 06.12.2019 - zum Nikolaus :-) +- RangeController mit global "utterances" für Rolladen realisiert - endlich "Alexa, mach den Rolladen zu/auf - hoch/runter" + +### 01.12.2019 +- Web-Interface ergänzt +- Prüfung auf Verwendung von gemischtem Payload V2/V3 im Web-Interface +- Bug-Fix bei falsch definierten Devices (alexa_name fehlt) - Issue #300 - diese werden entfernt und ein Log-Eintrag erfolgt +- Bug-Fix alexa-description (PR #292) - die Beschreibung in der App lautet nun "device.name" + "by smarthomeNG" +- alexa_description beim Geräte Discovery ergänzt + +### 20.04.2019 +- Authentifizierungsdaten (Credentials) für AlexaCamProxy eingebaut +- Umbennung des Plugin-Pfades auf "alexa4p3" !! Hier die Einträge in der plugin.yaml anpassen. + +### 17.02.2019 +- Version erhöht aktuell 1.0.1 +- CameraStreamController Integration für Beta-Tests fertiggestellt + +### 26.01.2019 +- ColorController eingebaut +- Doku für ColorController erstellt +- Neues Attribut für CameraStreamController (**alexa_csc_proxy_uri**) zum streamen von Kameras in lokalen Netzwerken in Verbindung mit CamProxy4AlexaP3 + +### 19.01.2019 +- Version auf 1.0.0.2 erhöht +- ContactSensor Interface eingebaut +- Doku für ContactSensor Interface ergänzt +- DoorLockController fertiggestellt +- DoorLockController Doku ergänzt +- ReportLockState eingebaut +- Doku für die Erstellung des Alexa-Skill´s auf Amazon als PDF erstellt + +### 31.12.2018 +- Version auf 1.0.0.1 erhöht +- CameraStreamController eingebaut +- Dokumentation für CameraStreamController ergänzt +- PowerLevelController eingebaut +- Dokumentation für PowerLevelController ergänzt +- Debugs und Testfunktionen kontrolliert und für Upload entfernt + +### 24.12.2018 +- Doku für PercentageController erstellt +- Bug Fix für fehlerhafte Testfunktionen aus der Lambda + +### 12.12.2018 +- Scene Controller eingebaut +- Doku für Scene Controller erstellt +- PercentageController eingebaut + + +## Requrirements + +Das Plugin benötigt Modul Python-Requests. Dies sollte mit dem Core immer auf dem aktuellen Stand mitkommen. + + +## Amazon Skill / Lambda + +Es muss ein funktionierender Skill in der Amazon Developer Konsole / AWS Lambda erstellt werden. +Eine ausführliche Dokumentation unter ./assets/Alexa_V3_plugin.pdf zu finden. +Vielen Dank @schuma für die ausführliche Dokumentation + +Ansonsten keine Requirements. + +## Icons / Catagories +Optional kann im Item angegeben werden welches Icon in der Alexa-App verwendet werden soll : +

+alexa_icon = "LIGHT"
+
+
+
++    +    +
+
+    
+        
+        
+    
+
+
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+    
+        
+        
+    
+
+
ValueDescription
ACTIVITY_TRIGGERA combination of devices set to a specific state. Use activity triggers for scenes when the state changes must occur in a specific order. For example, for a scene named "watch Netflix" you might power on the TV first, and then set the input to HDMI1.
CAMERAA media device with video or photo functionality.
COMPUTERA non-mobile computer, such as a desktop computer.
CONTACT_SENSORAn endpoint that detects and reports changes in contact between two surfaces.
DOORA door.
DOORBELLA doorbell.
EXTERIOR_BLINDA window covering on the outside of a structure.
FANA fan.
GAME_CONSOLEA game console, such as Microsoft Xbox or Nintendo Switch
GARAGE_DOORA garage door. Garage doors must implement the ModeController interface to open and close the door.
INTERIOR_BLINDA window covering on the inside of a structure.
LAPTOPA laptop or other mobile computer.
LIGHTA light source or fixture.
MICROWAVEA microwave oven.
MOBILE_PHONEA mobile phone.
MOTION_SENSORAn endpoint that detects and reports movement in an area.
MUSIC_SYSTEMA network-connected music system.
NETWORK_HARDWAREA network router.
OTHERAn endpoint that doesn't belong to one of the other categories.
OVENAn oven cooking appliance.
PHONEA non-mobile phone, such as landline or an IP phone.
SCENE_TRIGGERA combination of devices set to a specific state. Use scene triggers for scenes when the order of the state change is not important. For example, for a scene named "bedtime" you might turn off the lights and lower the thermostat, in any order.
SCREENA projector screen.
SECURITY_PANELA security panel.
SMARTLOCKAn endpoint that locks.
SMARTPLUGA module that is plugged into an existing electrical outlet, and then has a device plugged into it. For example, a user can plug a smart plug into an outlet, and then plug a lamp into the smart plug. A smart plug can control a variety of devices.
SPEAKERA speaker or speaker system.
STREAMING_DEVICEA streaming device such as Apple TV, Chromecast, or Roku.
SWITCHA switch wired directly to the electrical system. A switch can control a variety of devices.
TABLETA tablet computer.
TEMPERATURE_SENSORAn endpoint that reports temperature, but does not control it. The temperature data of the endpoint is not shown in the Alexa app.
THERMOSTATAn endpoint that controls temperature, stand-alone air conditioners, or heaters with direct temperature control.
TVA television.
WEARABLEA network-connected wearable device, such as an Apple Watch, Fitbit, or Samsung Gear.
+
+default = "Switch" (vergleiche : https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories ) + +Optional kann im Item angegeben werden ob es durch Amazon abgefragt werden kann : +

+	alexa_retrievable = true
+
+ +default = false +**==!! Achtung das sorgt für Traffic auf der Lambda bei Benutzung der Alexa-App !!==** + + +Die sonstigen Parameter aus dem ursprüngliche Alexa-Plugin bleiben erhalten und werden weiterhin genutzt. +(alexa_name / alexa_device / alexa_description / alexa_actions /alexa_item_range) + +Beispiel für Item im .conf-Format: + +

+[OG]
+    [[Flur]]
+        name = Flur_Obeschoss
+        [[[Spots]]]
+        alexa_name = "Licht Flur OG"
+        alexa_device = Licht_Flur_OG
+	alexa_actions = "TurnOn TurnOff"
+        alexa_icon = "LIGHT"
+        type = bool
+        visu_acl = rw
+        knx_dpt = 1
+        knx_listen = 1/1/107
+        knx_send = 1/1/107
+        enforce_updates = true
+            [[[[dimmen]]]]
+                type = num
+                alexa_device = Licht_Flur_OG
+                alexa_actions = "AdjustBrightness SetBrightness"
+                alexa_retrievable= True
+                alexa_item_range = 0-255
+                visu_acl = rw
+                knx_dpt = 5
+                knx_listen = 1/4/100
+                knx_send = 1/3/100
+                knx_init = 1/4/100
+                enforce_updates = true
+        [[[Treppe]]]
+        type = bool
+        visu_acl = rw
+        knx_dpt = 1
+        knx_listen = 1/1/133
+        knx_send = 1/1/133
+        enforce_updates = true
+
+
+ +im .yaml-Format : + +

+%YAML 1.1
+---
+
+OG:
+
+    Flur:
+        name: Flur_Obeschoss
+        Spots:
+            alexa_name: Licht Flur OG
+            alexa_device: Licht_Flur_OG
+            alexa_actions: TurnOn TurnOff
+            alexa_icon: LIGHT
+            type: bool
+            visu_acl: rw
+            knx_dpt: 1
+            knx_listen: 1/1/107
+            knx_send: 1/1/107
+            enforce_updates: 'true'
+            dimmen:
+                type: num
+                alexa_device: Licht_Flur_OG
+                alexa_actions: AdjustBrightness SetBrightness
+                alexa_retrievable: 'True'
+                alexa_item_range: 0-255
+                visu_acl: rw
+                knx_dpt: 5
+                knx_listen: 1/4/100
+                knx_send: 1/3/100
+                knx_init: 1/4/100
+                enforce_updates: 'true'
+        Treppe:
+            type: bool
+            visu_acl: rw
+            knx_dpt: 1
+            knx_listen: 1/1/133
+            knx_send: 1/1/133
+            enforce_updates: 'true'
+
+ +## Entwicklung / Einbau von neuen Fähigkeiten +Um weitere Actions hinzuzufügen muss die Datei p3_actions.py mit den entsprechenden Actions ergänzt werden. +(wie ursprünglich als selbstregistrierende Funktion) + +

+
+@alexa('action_name', 'directive_type', 'response_type','namespace',[]) // in der Datei p3_actions.py
+@alexa('TurnOn', 'TurnOn', 'powerState','Alexa.PowerController',[]) // in der Datei p3_actions.py
+
+
+ +Hierbei ist zu beachten, das für die jeweilige Action die folgenden Paramter übergeben werden : + +action_name = neuer Action-Name z.B.: TurnOn (gleich geschrieben wie in der Amazon-Beschreibung - auch Gross/Klein) + +directive_type = gleich wie action_name (nur notwendig wegen Kompatibilität V2 und V3) + +response_type = Property des Alexa Interfaces +siehe Amazon z.B. : https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html#properties + +namespace = NameSpace des Alexa Interfaces +siehe Amazon z.B.: https://developer.amazon.com/docs/device-apis/list-of-interfaces.html + +[] = Array für Abhängigkeiten von anderen Capabilties (z.B. beim Theromcontroller ThermostatMode und TargetTemperatur) + +In der "service.py" muss für den ReportState der Rückgabewert für die neue Action hinzugefügt werden. +(siehe Quellcode) + +## Alexa-ThermostatController + Thermosensor + +Es kann nun via Alexa die Solltemperatur verändert werden und der Modus des Thermostaten kann umgestellt werden. +Die Konfiguration der YAML-Datei sieht wie folgt aus + +Es müssen beim Thermostaten in YAML die Einträge für : +alexa_thermo_config, alexa_icon, alexa_actions vorgenommen werden. + +alexa_thermo_config = "0:AUTO 1:HEAT 2:COOL 3:ECO 4:ECO" +Hierbei stehen die Werte für für die KNX-Werte von DPT 20 + +

+$00 Auto
+$01 Comfort
+$02 Standby
+$03 Economy
+$04 Building Protection
+
+ +Die Modi AUTO / HEAT / COOL / ECO / OFF entsprechen den Alexa-Befehlen aus dem Theromstatconroller +siehe Amazon : https://developer.amazon.com/docs/device-apis/alexa-property-schemas.html#thermostatmode + +

+alexa_icon = "THERMOSTAT" = Thermostatcontroller
+
+alexa_icon = "TEMPERATURE_SENSOR" = Temperatursensor
+
+ +### Thermostatsensor + +Der Temperartursensor wird beim Item der Ist-Temperatur hinterlegt. +Der Thermostatconroller wird beim Thermostat-Item hinterlegt. An Amazon werden die Icons als Array übertragen. +Die Abfrage der Ist-Temperatur muss mit der Action "ReportTemperature" beim Item der Ist-Temperatur hinterlegt werden. + +

+alexa_actions : "ReportTemperature"
+
+ +Alexa wie ist die Temperatur in der Küche ? + +### Verändern der Temperatur (SetTargetTemperature AdjustTargetTemperature) + +

+alexa_actions = "SetTargetTemperature AdjustTargetTemperature"
+
+ +Hiermit werden die Solltemperatur auf einen Wert gesetzt oder die Temperatur erhöht. +Diese Actions müssen beim Item des Soll-Wertes des Thermostaten eingetragen werden + +Alexa erhöhe die Temperatur in der Küche um zwei Grad + +Alexa stelle die Temperatur in der Küche auf zweiundzwanzig Grad + +Alexa wie ist die Temperatur in der Küche eingestellt ? + +### Thermostatmode + +alexa_actions = "SetThermostatMode" + +Hier wird das Item des Modus angesteuert. Diese Action muss beim Item des Thermostat-Modes eingetragen werden. +Falls keine Modes angegeben wurden wird "0:AUTO" als default gesetzt + +Alexa stelle den Thermostaten Küche auf Heizen + + +

+%YAML 1.1
+---
+EG:
+    name: EG
+    sv_page: cat_seperator
+    Kueche:
+        temperature:
+            name: Raumtemperatur
+            alexa_description : "Küche Thermostat"
+            alexa_name : "Küche Thermostat"
+            alexa_device : thermo_Kueche 
+            alexa_thermo_config : "0:AUTO 1:HEAT 2:OFF 3:ECO 4:ECO"
+            alexa_icon : "THERMOSTAT"
+            actual:
+                type: num
+                sqlite: 'yes'
+                visu: 'yes'
+                knx_dpt: 9
+                initial_value: 21.8
+                alexa_device : thermo_Kueche 
+                alexa_retrievable : True
+                alexa_actions : "ReportTemperature"
+                alexa_icon : "TEMPERATURE_SENSOR"
+            SollBasis:
+                type: num
+                visu_acl: rw
+                knx_dpt: 9
+                initial_value: 21.0
+                alexa_device : thermo_Kueche 
+                alexa_actions : "SetTargetTemperature AdjustTargetTemperature"
+            Soll:
+                type: num
+                sqlite: 'yes'
+                visu: 'yes'
+                visu_acl: rw
+                knx_dpt: 9
+                initial_value: 21.0
+                alexa_device : thermo_Kueche 
+            mode:
+                type: num
+                visu_acl: rw
+                knx_dpt: 20
+                initial_value: 1.0
+                alexa_device : thermo_Kueche 
+                alexa_actions : "SetThermostatMode"
+            state:
+                type: bool
+                visu_acl: r
+                sqlite: 'yes'
+                visu: 'yes'
+                knx_dpt: 1
+                cache: true
+                alexa_device : thermo_Kueche 
+
+ +Beispiel für einen MDT-Glastron, der Modus wird auf Objekt 12 in der ETS-Parametrierung gesendet (Hierzu eine entsprechende +Gruppenadresse anlegen) + +

+ temperature:
+            name: Raumtemperatur
+            alexa_description : "Küche Thermostat"
+            alexa_name : "Küche Thermostat"
+            alexa_device : thermo_Kueche 
+            alexa_thermo_config : "0:AUTO 1:HEAT 2:OFF 3:ECO 4:ECO"
+            alexa_icon : "THERMOSTAT"
+        plan:
+            type: num
+            visu_acl: rw
+            database@mysqldb: init
+            knx_dpt: 9
+            knx_send: 2/1/2
+            knx_listen: 2/1/2
+            knx_cache: 2/1/2
+            alexa_device : thermo_Kueche 
+            alexa_actions : "SetTargetTemperature AdjustTargetTemperature"
+        state:
+            type: num
+            visu_acl: r
+            database@mysqldb: init
+            knx_dpt: 9
+            knx_listen: 2/1/1
+            knx_cache: 2/1/1
+            alexa_device : thermo_Kueche 
+            alexa_retrievable : True
+            alexa_actions : "ReportTemperature"
+            alexa_icon : "TEMPERATURE_SENSOR"
+        mode:
+            type: num
+            visu_acl: rw
+            knx_dpt: 20
+            initial_value: 1.0
+            alexa_device : thermo_Kueche 
+            alexa_actions : "SetThermostatMode"
+
+        humidity:
+            type: num
+            visu_acl: r
+            database@mysqldb: init
+            knx_dpt: 9
+            knx_listen: 2/1/5
+            knx_cache: 2/1/5
+
+        actor_state:
+            type: num
+            visu_acl: r
+            database@mysqldb: init
+            knx_dpt: '5.001'
+            knx_listen: 2/1/3
+            knx_cache: 2/1/3
+
+
+ +## Alexa-PowerController + +Alexa schalte das Licht im Büro ein + +Mit dem PowerController können beliebige Geräte ein und ausgeschalten werden. +Folgende Paramter sind anzugeben : + +

+	    alexa_actions = "TurnOn TurnOff"
+
+ +Beispiel + +

+        [[[Licht]]]
+        type = bool
+        alexa_name = "Licht Büro"
+        alexa_description = "Licht Büro"
+	    alexa_actions = "TurnOn TurnOff"
+        alexa_retrievable = true
+        alexa_icon = "LIGHT"
+        visu_acl = rw
+        knx_dpt = 1
+        knx_listen = 1/1/105
+        knx_send = 1/1/105
+        enforce_updates = true
+
+ +## Alexa-BrightnessController +Alexa stelle das Licht am Esstisch auf fünfzig Prozent +Alexa dimme das Licht am Esstisch um zehn Prozent +Folgende Parameter sind anzugeben : + +

+	    alexa_actions = "AdjustBrightness SetBrightness"
+        alexa_item_range = 0-255
+
+ +Es kann der BrightnessController mit dem PowerController kombiniert werden + +Beispiel : +

+    [[[Licht_Esstisch]]]
+    type = bool
+    alexa_name = "Licht Esstisch"
+    alexa_actions = "TurnOn TurnOff"
+    alexa_device = licht_esstisch
+    alexa_description = "Licht Esstisch"
+    alexa_icon = "SWITCH"
+    alexa_retrievable= True
+    visu_acl = rw
+	knx_dpt = 1
+	knx_listen = 1/1/9
+	knx_send = 1/1/9
+    enforce_updates = true
+        [[[[dimmen]]]]
+        type = num
+        alexa_device = licht_esstisch
+        alexa_actions = "AdjustBrightness SetBrightness"
+        alexa_retrievable= True
+        alexa_item_range = 0-255
+        visu_acl = rw
+        knx_dpt = 5
+        knx_listen = 1/4/9
+        knx_send = 1/3/9
+        knx_init = 1/4/9
+        enforce_updates = true
+
+ +## Alexa-PowerLevelController +## !!!! erst ab Plugin-Version 1.0.1 oder höher !!!! + +Alexa stelle Energie Licht Küche auf achtzig +Alexa erhöhe Energie Licht Küche um zehn + +Es können Werte von 0-100 angesagt werden. + +Der PowerLevelController kann in Verbindung mit dem PowerController verwendet werden. Funktionsweise entspricht der von PercentageController und BrightnessController + +Folgende Parameter sind anzugeben : + +

+    alexa_actions = "SetPowerLevel AdjustPowerLevel"
+    alexa_item_range = 0-255
+
+ +## Alexa-PercentageController + +Alexa stelle Rolladen Essen West auf achtzig Prozent + +Mit dem PercentageController können Geräte auf einen bestimmten Prozentwert gestellt werden. Der PercentageController eignet sich für die Umsetzung von +Rolladen/Jalousien. + +Folgende Parameter sind anzugeben : + +

+    alexa_actions = "SetPercentage AdjustPercentage"
+    alexa_item_range = 0-255
+
+ +In Verbindung mit dem PowerController (TurnOn / TurnOff) kann der Rolladen +dann mit "Schalte Rolladen Büro EIN" zugefahren werden und mit "Schalte Rolladen Büro AUS" aufgefahren werden. +(Zwar nicht wirklich schön aber funktioniert) + +'enforce_updates' sollte auf true gesetzt sein damit auch auf den Bus gesendet wird wenn keine Änderung des Wertes erfolgt. + +Beispiel Konfiguration im yaml-Format: +

+        Rolladen:
+            alexa_name: Rollladen Büro
+            alexa_device: rolladen_buero
+            alexa_description: Rollladen Büro
+            alexa_icon: SWITCH
+
+            move:
+                type: num
+                alexa_device: rolladen_buero
+                alexa_actions: TurnOn TurnOff
+                alexa_retrievable: 'True'
+                visu_acl: rw
+                knx_dpt: 1
+                knx_send: 3/2/23
+                enforce_updates: 'true'
+
+            stop:
+                type: num
+                visu_acl: rw
+                enforce_updates: 'true'
+                knx_dpt: 1
+                knx_send: 3/1/23
+
+            pos:
+                type: num
+                visu_acl: rw
+                alexa_device: rolladen_buero
+                alexa_actions: SetPercentage AdjustPercentage
+                alexa_item_range: 0-255
+                knx_dpt: 5
+                knx_listen: 3/3/23
+                knx_send: 3/4/23
+                knx_init: 3/3/23
+                enforce_updates: 'true'
+
+
+ +## Alexa-LockController +## !!!! erst ab Plugin-Version 1.0.1 oder höher !!!! +Die Probleme in der Amazon-Cloud mit dem LockController sind behoben. + +Die Funktion ist im Moment so realisiert, das bei "Unlock" ein "ON" (=1) auf +das Item geschrieben wird. Bei "Lock" wird ebenfalls ein "ON" (=1) auf die Gruppenadresse geschrieben. Eventuell die Werte mittels "eval"-Funktion direkt +in der Item Config anpassen. +Für den Zustand Smartlock geschlossen oder offen ist +"OFF" (=0) Tür offen +"ON" (=1) Tür geschlossen + +Wenn keine Rückmeldewert angegeben ist **(ReportLockState)** wird als default Wert "Locked" gemeldet. +Es wird beim Öffnen oder Schliessen immer der +ausgeführte Befehl als Rückmeldng gegeben.(Locked/Unlocked) + +Directive "Alexa schliesse die Haustür auf", Rückgabewert "Unlocked" +Directive "Alexa schliesse die Haustür ab", Rückgabewert "Locked" + +Es muss nach dem das Smartlock gefunden wurden die Sprachsteuerung über die Alexa-App freigegeben werden. Es muss für die Sprachsteuerung ein 4-stelliger PIN eingegeben werden welcher immer bei öffnen abgefragt wird. (Vorgabe Amazon, kann nicht umgangen werden) + +Folgende Befehle sind möglich : + +Alexa, entsperre die Haustür + +Alexa, schliesse die Haustür auf + +Alexa, sperre die Haustür + +Alexa, schliesse die Haustür ab + +Folgende Parameter sind anzugeben : + +

+    alexa_actions : Lock Unlock ReportLockState
+    alexa_icon: SMARTLOCK
+
+ +Beispiel mit einem Aktor-Kanal für öffnen, ein Aktor-Kanal für schliessen mit virtueller Rückmeldung, rücksetzen des Aktorkanals nach 5 Sekunden via autotimer + +

+        haustuer:
+            name: haustuer
+            alexa_description: Haustür
+            alexa_name: Haustuer
+            alexa_device: haustuer
+            alexa_icon: SMARTLOCK
+            unlock:
+                knx_send: 9/9/1
+                type: bool
+                visu_acl: rw
+                knx_dpt: 1
+                alexa_device: haustuer
+                alexa_actions: Unlock
+                autotimer: 5 = 0
+                on_change: 
+                - test.testzimmer.haustuer.state = 0 if sh.test.testzimmer.haustuer.unlock() == True else None
+            lock:
+                knx_send: 9/9/2
+                type: bool
+                visu_acl: rw
+                knx_dpt: 1
+                alexa_device: haustuer
+                alexa_actions: Lock
+                autotimer: 5 = 0
+                on_change: 
+                - test.testzimmer.haustuer.state = 1 if sh.test.testzimmer.haustuer.lock() == True else None
+            state:
+                type: num
+                visu_acl: rw
+                alexa_device: haustuer
+                alexa_actions: ReportLockStatelexa_actions: ReportLockState
+
+ +Beispiel mit einem Aktor-Kanal für öffnen, ein Aktor-Kanal für schliessen mit KNX-Eingang für die Rückmeldung.Der jeweilige Aktor-Kanel ist als Treppenlicht-Automat konfiguriert und stellt selbstständig zurück. + +

+        haustuer:
+            name: haustuer
+            alexa_description: Haustür
+            alexa_name: Haustuer
+            alexa_device: haustuer
+            alexa_icon: SMARTLOCK
+            unlock:
+                knx_send: 9/9/1
+                type: bool
+                visu_acl: rw
+                knx_dpt: 1
+                alexa_device: haustuer
+                alexa_actions: Unlock
+            lock:
+                knx_send: 9/9/2
+                type: bool
+                visu_acl: rw
+                knx_dpt: 1
+                alexa_device: haustuer
+                alexa_actions: Lock
+            state:
+				knx_listen: 9/9/3
+                knx_init: 9/9/3
+                type: num
+                visu_acl: rw
+                knx_dpt: 20
+                alexa_device: haustuer
+                alexa_actions: ReportLockState
+
+ +## Alexa-CameraStreamContoller +## !!!! erst ab Plugin-Version 1.0.1 oder höher !!!! + +Alexa zeige die Haustür Kamera. + +Der CameraController funktioniert mit Cameras die den Anforderungen von Amazon entsprechen. +d.h. : +- TLSv1.2 Verschlüsselung +- Kamera auf Port 443 erreichbar + +##!! für Kameras im lokalen Netzwerk wird gerade noch ein Camera Proxy entwickelt - dieser gibt dann die Möglichkeit auch private Kameras einzubinden !! +#Look out for : AlexaCamProxy4P3 + + +Aus den bereitgestellten Streams wird +immer der mit der höchsten Auflösung an Alexa übermittelt. + +Folgende Parameter sind anzugeben : + +##### alexa_csc_proxy_uri **Update**: URL über DynDNS vergeben um die Kamera mittels CamProxy4AlexaP3 zu streamen + +##### alexa_proxy_credentials **Update**: Zugangsdaten für den AlexaCamProxy falls dieser mit Authentication "Basic" oder "Digest" parametriert wird. Angabe in der Form "USER":"PWD" + + +##### alexa_camera_imageUri: die URL des Vorschau-Pictures der Kamera + +##### alexa_stream_1: Definition für den ersten Stream der Kamara, es werden bis zu 3 Streams unterstützt. Hier müssen die Details zum Stream definiert werden (protocol = rtsp, resolutions = Array mit der Auflösung, authorizationTypes = Autorisierung, videoCodecs = Array der VideoCodes, autoCodecs = Array der Audiocodes) + +##### alexa_csc_uri: Auflistung der Stream-URL´s für Stream1: / Stream2: / Stream3 +siehe Tabelle unten für mögliche Werte + +(Beispiel im YAML-Format): + +

+        doorcam:
+            name: doorcam
+            alexa_description: Haustürkamera
+            alexa_name: Doorcam
+            alexa_device: doorcam
+            alexa_icon: CAMERA
+            alexa_actions: InitializeCameraStreams
+            alexa_camera_imageUri: 'http://192.168.178.9/snapshot/view0.jpg'
+            alexa_csc_uri: '{"Stream1":"192.168.178.9","Stream2":"192.168.178./2","Stream3:...."}'
+            alexa_auth_cred: 'USER:PWD'
+            alexa_stream_1: '{
+            "protocols":["RTSP"],
+            "resolutions":[{"width":1920,"height":1080}],
+            "authorizationTypes":["BASIC"],
+            "videoCodecs":["H264"],
+            "audioCodecs":["G711"]
+            }'
+            alexa_stream_2: '{
+            "protocols":["RTSP"],
+            "resolutions":[{"width":1920,"height":1080}],
+            "authorizationTypes":["NONE"],
+            "videoCodecs":["H264"],
+            "audioCodecs":["AAC"]
+            }'
+            alexa_stream_3: '{.......
+            }'
+            alexa_csc_proxy_uri: alexatestcam.ddns.de:443
+            alexa_proxy_credentials: user:pwd
+
+ +Als Action ist fix "alexa_actions: InitializeCameraStreams" anzugeben. +Als Icon empfiehlt sich "alexa_icon: CAMERA". + +Es können aktuell bis zu drei Streams pro Kamera definiert werden. In "alexa_csc_uri" werden die URL´s der Streams definiert. Die Items "alexa_csc_uri" und "alexa_stream_X" werden beim Laden der Items als Json geladen. + + +!! Unbedingt auf korrekte Struktur im Json-Format achten !! + + +Die Kamera URL´s müssen in der gleichen Reihenfolge zu den Streams (alexa_stream_X) sein. + + +Mit dem Eintrag "alexa_auth_cred" werden User und Passwort für den Zugriff auf die Kamera hinterlegt. + +Mit dem Eintrag "alexa_camera_imageUri" wird die URL für den eventuell Snapshot der Kamera definiert. + +Für die Streams werden folgende Einstellungen untersützt: + +
+protocols    		  : RTSP
+resolutions  		  : alle die von der Kamera unterstützt werden
+authorizationTypes	 : "BASIC", "DIGEST" or "NONE"
+videoCodecs			: "H264", "MPEG2", "MJPEG", oder "JPG"
+audioCodecs			: "G711", "AAC", or "NONE"
+
+ +!! alle Einstellungen sind als Array definiert [] !! +## Alexa-SceneController + +Alexa aktiviere Szene kommen + +Mit dem Scene-Controller können Szenen aufgerufen werden. +Folgende Parameter sind anzugeben: +

+alexa_actions = "Activate"
+alexa_item_turn_on = 3
+alexa_icon = "SCENE_TRIGGER"
+
+ +Das "alexa_item_turn_on" ist die Nummer der Szene die aufgerufen werden soll. + + +Beispiel Konfiguration : +

+scene:
+    type: num
+    name: scene_kommen
+    alexa_description : "Szene Kommen"
+    alexa_name : "Szene Kommen"
+    alexa_device : Szene_Kommen
+    alexa_icon : "SCENE_TRIGGER"
+    alexa_item_turn_on : 3
+    alexa_actions : "Activate"
+    alexa_retrievable : false
+
+ +## ContactSensor Interface + +Alexa ist das Küchenfenster geschlossen ? +Alexa ist das Küchenfenster geöffnet ? + +Folgende Parameter sind anzugeben: +

+alexa_actions = "ReportContactState"
+alexa_icon = "CONTACT_SENSOR"
+
+ +Beispiel Konfiguration : +

+fensterkontakt:
+    type: bool
+    name: kuechenfenster
+    alexa_description: Küchenfenster
+    alexa_name: kuechenfenster
+    alexa_device: kuechenfenster
+    alexa_icon: CONTACT_SENSOR
+    alexa_actions: ReportContactState
+    alexa_retrievable: 'True'
+
+ + +## ColorController + +Alexa, setze Licht Speicher auf rot + +Folgende Paramter sind anzugeben : + +

+alexa_actions = "SetColor"
+alexa_color_value_type = RGB
+alexa_icon = "LIGHT"
+
+ + +**"alexa_color_value_type" = RGB oder HSB** + +Der Parameter "alexa_color_value_type" gibt an ob die Werte von Alexa +als RGB-Werte [120, 40, 65] oder als HSB-Werte[350.5, 0.7138, 0.6524] im list-Objekt an das Item übergeben werden. +Default-Wert ist : RGB + +Die Helligkeit wird bei Farbwechsel unverändert beibehalten. Bei HSB-Werten wird der ursprüngliche Wert gepuffert und als aktueller Wert wieder an das Item übergeben. + +Zum Wechseln der Helligkeit einen BrightnessController hinzufügen +Details siehe [BrightnessController](#BrightnessController) +Beispiel Konfiguration (wobei R_Wert, G_Wert, B_Wert für die Gruppenadressen stehen : +

+%YAML 1.1
+---
+Speicher:
+    Lampe_Speicher:
+        alexa_description: Licht Speicher
+        alexa_device: DALI_RGB_Speicher
+        alexa_name: Licht Speicher
+        alexa_icon: LIGHT
+        Dimmwert:
+            type: num
+            alexa_device: DALI_RGB_Speicher
+            alexa_actions: AdjustBrightness SetBrightness
+            alexa_retrievable: True
+            alexa_item_range: 0-255
+        Farbwert_RGB:
+            type: list
+            alexa_device: DALI_RGB_Speicher
+            alexa_color_value_type: RGB
+            alexa_actions: SetColor
+            alexa_retrievable: True
+            alexa_color_value_type: RGB
+            on_change:
+              - R_WERT = list[0]
+              - G_WERT = list[1]
+              - B_WERT = list[2]
+
+ + +## RangeController + + +Folgende Paramter sind anzugeben : + +

+alexa_actions: SetRangeValue AdjustRangeValue 
+alexa_range_delta: 20
+alexa_item_range: 0-255
+
+ +ergänzt um das entsprechende Categorie-Icon + +

+alexa_icon: EXTERIOR_BLIND
+
+ +oder + +

+alexa_icon: INTERIOR_BLIND
+
+ +Der RangeController kann mit dem Percentage-Controller kombiniert werden + +

+alexa_actions: SetRangeValue AdjustRangeValue SetPercentage 
+alexa_range_delta: 20
+alexa_item_range: 0-255
+
+ + +## ColorTemperaturController + +Es müssen die Parameter für den einstellbaren Weiss-Bereich unter "alexa_item_range" in Kelvin von/bis angegeben werden. +Da die Geräte der verschiedenen Hersteller unterschiedliche Weißbereiche abdecken ist wird dieser Wert benötigt. +Falls ein Weißwert angefordert wird den das jeweilige Gerät nicht darstellen kann wird auf den Minimum bzw. den Maximumwert gestellt. + +Als Alexa-Actions müssen SetColorTemperature/IncreaseColorTemperature/DecreaseColorTemperature angegeben werden. +Als Rückgabewert wird das entsprechende Item vom plugin auf den Wert von 0 (warmweiss) bis 255 (kaltweiss) gesetzt. + +Hinweis : Alexa unterstützt 1.000 Kelvin - 10.000 Kelvin + +

+alexa_item_range: 3000-6500
+alexa_actions: SetColorTemperature IncreaseColorTemperature DecreaseColorTemperature
+
+ +## PlaybackController + +Eingebaut um fahrende Rolladen zu stoppen. + +#### Alexa, stoppe den Rolladen Büro + +Das funktioniert nur, wenn beim Rolladen/Jalousie kein TurnOn/TurnOff definiert sind. Die Rolladen müssen mittels "AdjustPercentage" und "SetPercentage" angesteuert werden. Dann kann mit dem "Stop" Befehl der Rolladen angehalten werden. + +Die Action lautet "Stop". Es wird an dieser Stelle der Alexa.PlaybackController zweck entfremded. Dieser Controller hat eine "Stop" Funktion implementiert welche hier genutzt wird. +Beim ausführen des Befehls wird eine "1" an das Item übergeben. Das Item muss der Stopbefehl für den Rolladen sein. enforce_update muss auf True stehen. + +Alle Actions senden jeweils ein "True" bzw. "EIN" bzw. "1" + +implementierte Funktionen: + +alexa_actions: Stop / Play / Pause / FastForward / Next / Previous / Rewind / StartOver + + +# Web-Interface + +Das Plugin bietet ein Web-Interface an. + +Auf der ersten Seite werden alle Alexa-Geräte, die definierten Actions sowie die jeweiligen Aliase angezeigt. Actions in Payload-Version 3 werden grün angezeigt. Actions in Payload-Version 2 werden in rot angezeigt. +Eine Zusammenfassung wird oben rechts dargestellt. Durch anklicken eine Zeile kann ein Alexa-Geräte für die Testfunktionen auf Seite 3 des Web-Interfaces auswewählt werden +![webif_Seite1](./assets/Alexa4P3_Seite1.jpg) + +Auf der Zweiten Seite wird ein Kommunikationsprotokoll zur Alexa-Cloud angezeigt. +![webif_Seite2](./assets/Alexa4P3_Seite2.jpg) + +Auf Seite drei können "Directiven" ähnlich wie in der Lambda-Test-Funktion der Amazon-Cloud ausgeführt werden. Der jeweilige Endpunkt ist auf Seite 1 duch anklicken zu wählen. Die Kommunikation wird auf Seite 2 protokolliert. +So könnne einzelne Geräte und "Actions" getestet werden. + +![webif_Seite3](./assets/Alexa4P3_Seite3.jpg) + +Auf Seite 4 kann interaktiv ein YAML-Eintrag für einen Alexa-Kamera erzeugt werden. Der fertige YAML-Eintrag wird unten erzeugt und kann via Cut & Paste in die Item-Definition von shNG übernommen werden. + +![webif_Seite4](./assets/Alexa4P3_Seite4.jpg) + + +# Beispiele + +## Der fast perfekte Rolladen + +Mit diesen Einstellungen kann ein Rolladen wie folgt gesteuert werden : + +Alexa, + +mache den Rolladen hoch + +mache den Rolladen runter + +öffne den Rolladen im Büro + +mache den Rolladen im Büro auf + +schliesse den Rolladen im Büro + +mache den Rolladen im Büro zu + +fahre Rolladen Büro auf siebzig Prozent + +stoppe Rolladen Büro + + + +Es wird zum einen der RangeController mit erweiterten Ausdrücken verwendet zum anderen wird +der PlaybackController zweckentfremdet für das Stop-Signal verwendet. + +### !! Wichtig !! + + +Die erweiterten Ausdrücke (öffnen/schliessen - hoch/runter) werden durch das Plugin automatisch +beim RangeController eingebunden wenn als Alexa-Icon "EXTERIOR_BLIND" oder "INTERIOR_BLIND" parametriert werden. + +Beim Stop des Rolladen-Items muss "alexa_actions: Stop" angegeben werden +Um das Item automatisch zurückzusetzen empfiehlt sich der autotimer-Eintrag. + + +Bei der Positionierung des Rolladen muss "alexa_range_delta: xx" angegeben werden. +"xx" ist hier der Wert der beim Kommando hoch/runter gesendet wird. +Bei xx=20 und "Rolladen runter" würde der Rolladen 20 Prozent nach unten fahren. +Bei xx=20 und "Rolladen hoch" würde der Rolladen 20 Prozent nach oben fahren. +Wenn der Rolladen bei "hoch/runter" komplett fahren soll kann hier auch 100 angegeben werden. + +Für die Positionierung ist "alexa_item_range: 0-255" anzugeben. + +

+        Rolladen:
+            alexa_name: Rollladen Büro
+            alexa_device: rolladen_buero
+            alexa_description: Rollladen Büro
+            alexa_icon: EXTERIOR_BLIND
+            alexa_proactivelyReported: 'False'
+            alexa_retrievable: 'True'
+
+            move:
+                type: num
+                visu_acl: rw
+                knx_dpt: 1
+                knx_send: 3/2/23
+                enforce_updates: 'true'
+
+            stop:
+                type: num
+                visu_acl: rw
+                enforce_updates: 'true'
+                knx_dpt: 1
+                knx_send: 3/1/23
+                alexa_device: rolladen_buero
+                alexa_actions: Stop 
+                alexa_retrievable: 'False'
+                alexa_proactivelyReported: 'False'
+                autotimer: 1 = 0
+
+            pos:
+                type: num
+                visu_acl: rw
+                knx_dpt: 5
+                knx_listen: 3/3/23
+                knx_send: 3/4/23
+                knx_init: 3/3/23
+                enforce_updates: 'true'
+                alexa_actions: SetRangeValue AdjustRangeValue 
+                alexa_retrievable: 'True'
+                alexa_range_delta: 20
+                alexa_item_range: 0-255
+
+ + +## Items in Abhängikeit des letzten benutzten Echos-Devices schalten + +Wenn das AlexaRc4shNG-Plugin aktiviert ist kann über eine Logik das letzte Echo-Gerät welches einen Sprachbefehl bekommen hat ermittelt werden und abhängig davon können Items geschalten werden. So kann z.b. eine raumabhängige Steuerung für das Licht und Rolladen erstellt werden. + +Es wird ein Item für Licht pauschal erstellt : +``` + Licht_pauschal: + alexa_name: Licht + alexa_device: Licht_pauschal + alexa_description: Licht Pauschal + alexa_icon: OTHER + alexa_actions: TurnOn TurnOff + alexa_proactivelyReported: 'False' + type: num + visu_acl: rw + enforce_updates: 'true' +``` + +eine entsprechende Logik welche durch das item "Licht_pauschal" getriggert wird schaltet dann die entsprechenden Items. +``` +#!/usr/bin/env python3 +#last_alexa.py + +myAlexa = sh.alexarc4shng.get_last_alexa() +if myAlexa != None: + triggeredItem=trigger['source'] + triggerValue = trigger['value'] + if triggeredItem == "test.testzimmer.Licht_pauschal": + if myAlexa == "ShowKueche": + sh.EG.Kueche.Spots_Sued(triggerValue) + if myAlexa == "Wohnzimmer": + sh.OG.Wohnzimmer.Spots_Nord(triggerValue) + sh.OG.Wohnzimmer.Spots_Sued(triggerValue) + +``` diff --git a/alexarc4shng/README.md b/alexarc4shng/README.not_convertable.md.off similarity index 100% rename from alexarc4shng/README.md rename to alexarc4shng/README.not_convertable.md.off diff --git a/alexarc4shng/__init__.py b/alexarc4shng/__init__.py index 4d686018f..c42111181 100755 --- a/alexarc4shng/__init__.py +++ b/alexarc4shng/__init__.py @@ -117,7 +117,7 @@ def __init__(self, id): ############################################################################## class AlexaRc4shNG(SmartPlugin): - PLUGIN_VERSION = '1.0.3' + PLUGIN_VERSION = '1.0.4' ALLOW_MULTIINSTANCE = False """ Main class of the Plugin. Does all plugin specific stuff and provides @@ -1122,7 +1122,7 @@ def auto_login_by_request(self): "Referer": myLocation } newUrl = "https://www.amazon.de"+"/ap/signin/"+actSessionID - postfields = urllib3.request.urlencode(PostData) + postfields = urlencode(PostData) myStatus,myRespHeader, myRespCookie, myContent = self.send_post_request(newUrl,myHeaders,myCollectionCookie,PostData) myCollectionTxtCookie = self.parse_response_cookie_2_txt(myRespCookie,myCollectionTxtCookie) @@ -1165,7 +1165,7 @@ def auto_login_by_request(self): myResults.append('MFA : ' + 'use MFA/OTP - Login OTP : {}'.format(mfaCode)) - postfields = urllib3.request.urlencode(PostData) + postfields = urlencode(PostData) myStatus,myRespHeader, myRespCookie, myContent = self.send_post_request(newUrl,myHeaders,myCollectionCookie,PostData) myCollectionTxtCookie = self.parse_response_cookie_2_txt(myRespCookie,myCollectionTxtCookie) myCollectionCookie = self.parse_response_cookie(myRespCookie,myCollectionCookie) diff --git a/alexarc4shng/plugin.yaml b/alexarc4shng/plugin.yaml index 447b7ac04..ec99a371b 100755 --- a/alexarc4shng/plugin.yaml +++ b/alexarc4shng/plugin.yaml @@ -8,7 +8,7 @@ plugin: maintainer: AndreK tester: henfri, juergen, psilo #documentation: https://www.smarthomeng.de/user/plugins/alexarc4shng/user_doc.html # url of documentation - version: 1.0.3 # Plugin version + version: 1.0.4 # Plugin version sh_minversion: 1.5.2 # minimum shNG version to use this plugin multi_instance: False # plugin supports multi instance classname: AlexaRc4shNG # class containing the plugin @@ -36,8 +36,18 @@ parameters: type: str default: '' description: - de: 'Ein Item welches verwendet wird um die Freigabe für die Kommunikation zu erteilen (USZU)' - en: 'An Item to give the plugin permission to remote control the echo-devices (USZU)' + de: 'Ein Item welches verwendet wird um die Freigabe für die Kommunikation zu erteilen (z.B. via UZSU)' + en: 'An Item to give the plugin permission to remote control the echo-devices (e.g. via UZSU)' + description_long: + de: 'Item, das beispielsweise durch eine Zeitschaltuhr oder etwas anderem geschaltet wird, + um die Kommunikation mit Alexa-Amazon-Geräten zu ermöglichen.\n + Ist der Wert leer oder nicht angegeben, ist die Kommunikation jederzeit rund um die Uhr aktiviert.\n + Dieses Item wird nur während update_item in smarthomeNG überprüft. Wenn die API direkt von einer Logik oder + über die Benutzeroberfläche verwendet wird, wird das Item nicht überprüft.' + en: 'Item controlled by UZSU or something else which enables the communication to Alexa-Amazon-devices.\n + If left blank/not configured the communication is enabled all the time 24/7.\n + This item is only checked during update_item in smarthomeNG. + If you use the API directly from a logic or from the Webinterface the item will not be checked.' alexa_credentials: type: str @@ -45,6 +55,13 @@ parameters: description: de: 'Zugangsdaten für das Amazon-Alexa-Web-Site :, base64 encodiert' en: 'credentials for the amazon-alexa-website :, base64 encoded' + description_long: + de: 'Die Zugangsdaten können entweder über der Web Interface kodiert werden oder direkt über eine Python-Konsole mit den zwei Zeilen\n + import base64\n + base64.b64encode("user.test@gmail.com:your_pwd".encode("utf-8"))' + en: 'The access credentials can be encoded either through the web interface or directly via a Python console using the following two lines.\n + import base64\n + base64.b64encode("user.test@gmail.com:your_pwd".encode("utf-8"))' login_update_cycle: type: num @@ -80,8 +97,8 @@ plugin_functions: send_cmd: type: str description: - de: "Sendet einen Befehl an Alexa." - en: "Sends a command to Alexa." + de: "Sendet einen Befehl an Alexa. Es können auch Platzhalter genutzt werden. Das Resultat wird der HTTP Status des Requests als String sein." + en: "Sends a command to Alexa. Placeholders can be used. The result will be the HTTP-Status of the request as string (str)" parameters: dvName: type: str @@ -115,10 +132,9 @@ plugin_functions: - 'TO_DO' - + get_last_alexa: type: str description: de: "Liefert die Geräte-ID des zuletzt verwendeten Alexa-Gerätes zurück" en: "delivers the Device-ID of the last used Alexa-Device" - diff --git a/alexarc4shng/user_doc.rst b/alexarc4shng/user_doc.rst index 73f3f6089..482055d9a 100755 --- a/alexarc4shng/user_doc.rst +++ b/alexarc4shng/user_doc.rst @@ -9,13 +9,50 @@ alexarc4shng .. image:: webif/static/img/plugin_logo.png :alt: plugin logo - :width: 300px - :height: 300px + :width: 650px + :height: 350px :scale: 50 % :align: left -Plugin zur Steuerung von Amazon Echo Geräten Zugriff via Web-Browser API und Cookie. +Das Plugin bietet die Möglichkeit, ein Alexa-Echo-Gerät über smartHomeNG fernzusteuern. +So ist es möglich, einen TuneIn-Radio-Kanal einzuschalten, Nachrichten über Text2Speech zu senden, +wenn ein Ereignis auf dem knx-Bus oder auf der Visu eintritt, etc. + +Voraussetzungen +=============== + +* Python requests +* ein gültiges Cookie aus einer Sitzung auf einer alexa.amazon-Webseite +* "base64"-codierte Anmeldedaten in der etc/plugin.yaml Datei + +Cookie +------ + +Erste Möglichkeit - ohne Anmeldedaten: + +Es gibt Plugins für die meisten gängigen Browser. Nach der Installation des Plugins müssen Sie sich in Ihrer alexa.amazon-Webkonsole anmelden. +Exportieren Sie nun das Cookie mithilfe des Plugins. Öffnen Sie die Cookie-Datei mit einem Texteditor, +markieren Sie alles und kopieren Sie es in die Zwischenablage. Gehen Sie zur Web-Benutzeroberfläche des Plugins +und fügen Sie den Inhalt der Cookie-Datei in das Textfeld auf dem Tab "Cookie-Handling" ein. Speichern Sie das Cookie. +Wenn das Cookie erfolgreich gespeichert wurde, finden Sie Ihre Echo-Geräte im Tab mit den Alexa-Geräten. + +Zweite Möglichkeit - mit Anmeldedaten: + +Wenn das Plugin gestartet wird und Anmeldedaten in der plugin.yaml gefunden werden, überprüft das Plugin, +ob die Informationen in der Cookie-Datei noch gültig sind. Falls nicht, versucht das Plugin, sich selbst mit den +Anmeldedaten anzumelden und speichert die Informationen in der Cookie-Datei. Das Cookie wird im unter "login_update_cycle" +in der plugin.yaml angegebenen Zyklus aktualisiert. + +Anmeldedaten +------------ + +Nutzername und Passwort können im Web Interface oder via Python entsprechend kodiert werden + +.. code-block:: python + + import base64 + base64.b64encode("user.test@gmail.com:your_pwd".encode("utf-8")) Konfiguration @@ -23,49 +60,299 @@ Konfiguration Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/alexarc4shng` beschrieben. +plugin.yaml +----------- -Web Interface -============= +.. code-block:: yaml -Das AlexaRc4shNG Plugin verfügt über ein Webinterface.Hier werden die Zugangsdaten zur Amazon-Web-Api (Cookie) gepflegt. -Es können neue Kommandos erstellt werden + AlexaRc4shNG: + plugin_name: alexarc4shng + cookiefile: /usr/local/smarthome/plugins/alexarc4shng/cookies.txt + host: alexa.amazon.de + item_2_enable_alexa_rc: + alexa_credentials: : (kodiert!) + login_update_cycle: 432000 + mfa_secret: -.. important:: +items.yaml +---------- - Das Webinterface des Plugins kann mit SmartHomeNG v1.5.2 und davor **nicht** genutzt werden. - Es wird dann nicht geladen. Diese Einschränkung gilt nur für das Webinterface. Ansonsten gilt - für das Plugin die in den Metadaten angegebene minimale SmartHomeNG Version. +Sie können bis zu 99 Befehle pro shng-Element angeben. +Das Plugin scannt die item.yaml während der Initialisierung nach Befehlen von 01 bis 99. +.. important:: + Bitte starten Sie jedes Mal mit 01 pro Item, also alexa_cmd_01. Die Befehlsnummern müssen fortlaufend sein, vergessen Sie keine. + Der Scan der Befehle endet, wenn kein Befehl mit der nächsten Nummer gefunden wird.** + +Ein Command ist wie folgt aufgebaut: -Aufruf des Webinterfaces ------------------------- +.. code-block:: yaml -Das Plugin kann aus dem backend aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden -Zeile das Icon in der Spalte **Web Interface** anklicken. + alexa_cmd_01: comparison:EchoDevice:Commandlet:Value_to_Send -Außerdem kann das Webinterface direkt über ``http://smarthome.local:8383/plugins/alexarc4shng`` aufgerufen werden. +Unterstützte Vergleiche (comparison): +- "True" oder "False" für boolsche Werte +- für numerische Werte "<=", ">=", "=", "<", ">" Beispiele ---------- +========= + +Radiostation +------------ + +.. code-block:: yaml + + alexa_cmd_01: True:EchoDotKueche:StartTuneInStation:s96141 + +- Value = True bedeutet, dass das item() "ON" wird +- EchodotKueche = Gerätename, an das der Befehl gesendet werden soll +- StartTuneInStation = Name des Befehls +- s96141 = Wert der Radiostation als guideID, Stationsnamen werden nicht unterstützt (hier S96141 = baden.fm) + +Um die Stations-ID zu finden, suchen Sie nach Ihrer Station auf TuneIn.com. Greifen Sie auf Ihre Seite zu und +verwenden Sie die letzten Ziffern der resultierenden URL für die ID. Zum Beispiel: +Wenn Ihre TuneIn.com-URL `http://tunein.com/radio/tuneinstation-s######/` ist, dann wäre Ihre Stations-ID `"s######"`. + +Text senden +----------- + +Beispiel zum Senden von Text mit dem im Wert enthaltenen Element basierend auf einem Wert unter 20 Grad: + +.. code-block:: yaml + + alexa_cmd_01: <20.0:EchoDotKueche:Text2Speech:Die Temperatur in der Küche ist niedriger als 20 Grad. Die Temperatur ist jetzt #test.testzimmer.temperature.actual/# + +- Value = <20.0 - Befehl senden, wenn der Wert des Elements kleiner als 20.0 wird +- EchodotKueche = Gerätename, an das der Befehl gesendet werden soll +- Text2Speech = Name des Befehls +- Value_to_Send = Die Temperatur in der Küche ist niedriger als 20 Grad. Die Temperatur ist jetzt #test.testzimmer.temperature.actual/# +- #test.testzimmer.temperature.actual/# = Elementpfad des einzufügenden Werts + +Beispiel Itemdefinition +----------------------- + +.. code-block:: yaml + + OG: + Buero: + name: Buero + Licht: + type: bool + alexa_name: Licht Büro + alexa_description: Licht Büro + alexa_actions: Einschalten Ausschalten + alexa_icon: LICHT + alexa_cmd_01: True:EchoDotKueche:StartTuneInStation:s96141 + alexa_cmd_02: True:EchoDotKueche:Text2Speech:Hallo das Licht im Büro ist eingeschaltet + alexa_cmd_03: False:EchoDotKueche:Text2Speech:Hallo das Licht im Büro ist aus + alexa_cmd_04: 'False:EchoDotKueche:Pause: ' + visu_acl: rw + knx_dpt: 1 + knx_listen: 1/1/105 + knx_send: 1/1/105 + enforce_updates: 'true' + +Logiken +======= + +Beispiellogik, um Items mit Listeninformationen (Todo, Shopping) zu füllen + +.. code-block:: Python + + from datetime import datetime + + # get the Todo-List + myList=sh.AlexaRc4shNG.get_list('TO_DO') + for entry in myList: + if entry['completed'] == True: + entry['icon'] = 'control_clear' + else: + entry['icon'] = 'control_home' + entry['date'] = datetime.fromtimestamp((entry['updatedDateTime']/1000)).strftime("%d.%m.%Y, %H:%M:%S") + + # Write list to Item - type should be list + sh.Alexa_Lists.list.todo(myList) + + # get the shopping-List + myList=sh.AlexaRc4shNG.get_list('SHOPPING_LIST') + for entry in myList: + if entry['completed'] == True: + entry['icon'] = 'control_clear' + else: + entry['icon'] = 'jquery_shop' + entry['date'] = datetime.fromtimestamp((entry['updatedDateTime']/1000)).strftime("%d.%m.%Y, %H:%M:%S") + + # Write list to Item - type should be list + sh.Alexa_Lists.list.shopping(myList) + +Einbinden in der smartVISU + +.. code-block:: HTML + + status.activelist('','Alexa_Lists.list.todo','value','date','value','info') + status.activelist('','Alexa_Lists.list.shopping','value','date','value','info') + +.. image:: assets/Alexa_lists.jpg + :class: screenshot + + +Platzhalter +=========== -Folgende Informationen können im Webinterface angezeigt werden: +- = Value to send as alpha +- = Value to send as numeric +- "#item.path/#" = item-path of the value that should be inserted into text or ssml +- = SerialNo. of the device where the command should go to +- = device family +- = deviceType +- = OwnerID of the device -Oben rechts werden allgemeine Parameter zum Plugin angezeigt. +.. important:: + + Platzhalter sind mit "<", ">", "#" und "/#" kennzuzeichnen! + +Kommandos erstellen +=================== + +Öffnen Sie das Web-Interface für Alexa auf Amazon. Wählen Sie die Seite aus, die Sie überwachen möchten. +Bevor Sie auf den Befehl klicken, öffnen Sie den Debugger des Browsers (F12). Wählen Sie den Netzwerk-Tab aus. +Wenn Sie auf den Befehl klicken, den Sie überwachen möchten, wird der Netzwerkverkehr im Debugger angezeigt. +Hier erhalten Sie alle Informationen, die Sie benötigen. +Normalerweise werden Informationen an Amazon gesendet. Konzentrieren Sie sich also auf die Post-Methoden. + +.. image:: assets/pic1.jpg + :alt: Browser Debugger + :class: screenshot + +Als Beispiel zum Überwachen der Station-ID einer TuneIn Radio-Station sehen Sie dies direkt im Kontext, wenn Sie Ihre Maus auf den Post-Befehl bewegen. +Sie können die URL in die Zwischenablage kopieren und sie im Plugin verwenden. + +Sie können sie auch als cUrl kopieren, in einen Editor einfügen und die Payload im --data-Abschnitt des cUrl finden. + +.. image:: assets/pic2.jpg + :alt: Post Befehl + :class: screenshot + +Für einige Befehle müssen Sie die Payload kennen. Dies können Sie durch Überwachung der Daten herausfinden. +Wählen Sie den Netzwerkbefehl aus. Wählen Sie dann den Tab mit den Headern aus. Unten finden Sie die Formulardaten. +Sie können die Payload in die Zwischenablage kopieren und sie im Web Interface einfügen. + +.. image:: assets/pic3.jpg + :alt: Header + :class: screenshot + +Vergessen Sie nicht, die Werte für deviceOwnerCustomerId, customerID, serialNumber, family und Werte durch die Platzhalter zu ersetzen + +.. code-block:: text + + + + + + (für Alpha-Werte) + (für numerische Werte) + +Web Interface +============= + +Funktionen +---------- + +Auf dem Web-Interface können eigene Commandlets (Funktionen) definiert werden. Die folgenden Funktionen sind auf dem Web-Interface verfügbar: + +- Speichern einer Cookie-Datei, um Zugang zum Alexa-Web-Interface zu erhalten +- Manuelles Login mit Ihren Zugangsdaten (gespeichert in der /etc/plugin.yaml) +- Sehen Sie alle verfügbaren Geräte, wählen Sie eines aus um Test-Funktionen zu senden +- Commandlets definieren - Sie können Commandlets laden, speichern, löschen, prüfen und testen +- die Commandlets können mit einem Klick auf die Liste in das Webinterface geladen werden +- die Json-Struktur kann auf dem WebInterface geprüft werden -Im ersten Tab kann das Cookie File gespeichert werden - in die Textarea via Cut & Paste einfügen und speichern: +In der API-URL und im JSON-Payload müssen die echten Werte aus dem Alexa-Webinterface durch Platzhalter ersetzt werden, siehe oben. +Für Testfunktionen ist die Verwendung der Platzhalter nicht unbedingt notwendig. + +Cookies +------- + +Im ersten Tab kann das Cookie File gespeichert werden. .. image:: assets/webif1.jpg :class: screenshot +Exportieren Sie es mit einem Cookie.txt-Add-On Ihres Browsers. Kopieren Sie es in die Zwischenablage. +Fügen Sie es in das Textfeld in der Web-Benutzeroberfläche ein und speichern Sie es ab. +Nun werden die verfügbaren Geräte aus Ihrem Alexa-Konto erkannt und auf dem zweiten Tab angezeigt. + +Geräte +------ + Im zweiten Tab werden die verfügbaren Geräte angezeigt - Durch click auf ein Gerät wird dieses selektiert und steht für Tests zur Verfügung: .. image:: assets/webif2.jpg :class: screenshot -Im dritten Tab werden die Commandlets verwaltet - mit Click auf die Liste der Commandlets wird dieses ins WebIF geladen: +Kommandos verwalten +------------------- + +Im dritten Tab werden die Commandlets verwaltet - mit Klick auf die Liste der Commandlets wird dieses ins WebIF geladen: .. image:: assets/webif3.jpg :class: screenshot +Bestehende Commandlets +^^^^^^^^^^^^^^^^^^^^^^ + +- Play (Spielt das zuletzt pausierte Medium ab) +- Pause (Pausiert das aktuelle Medium) +- Text2Speech (Sendet einen Text an das Echo, das Echo spricht ihn) +- StartTuneInStation (Startet eine TuneIn-Radiostation mit der angegebenen GuideID, siehe auch Beispiele weiter oben) +- SSML (Sprachausgabe von Text mit Speech Synthesis Markup Language) +- VolumeAdj (Regelt die Lautstärke während der Wiedergabe einiger Medien; funktioniert nicht über die Testfunktionen der Web-Benutzeroberfläche) +- VolumeSet (Setzt die Lautstärke auf einen Wert zwischen 0 und 100 Prozent) + +Sie können Testwerte im Feld für die Werte eingeben. Drücken Sie "Test", und der Befehl wird an das Gerät gesendet. +Sie erhalten den HTTP-Status der Anfrage zurück. + +.. important:: + + Für Tests sollten Sie die Payload nicht ändern, sondern einfach das Testwert-Feld verwenden. + +SSML Hinweise +^^^^^^^^^^^^^ + +Auszugebender Text ist in Tags einzubetten. + +Beispiel + +.. code-block:: + + + I want to tell you a secret.I am not a real human.. + Can you believe it? + + +Außerdem können SpeechCons wie folgt genutzt werden. + +.. code-block:: + + + Here is an example of a speechcon. + ach du liebe zeit.. + + +Weitere Infos: `SSML `_ und `SpeechCons `_ + +Danksagung +========== + +- `Alex von Loetzimmer `_ +- `Ingo `_ +- `Michael, OpenHAB2 `_ +- Jonofe vom Edomi-Forum + +Disclaimer +========== +TuneIn, Amazon Echo, Amazon Echo Spot, Amazon Echo Show, Amazon Music, Amazon Prime, Alexa und alle anderen Produkte und Unternehmen von Amazon, +TuneIn und anderen sind Marken™ oder eingetragene® Marken ihrer jeweiligen Inhaber. +Die Verwendung bedeutet nicht, dass eine Verbindung zu ihnen besteht oder dass sie sie unterstützen. diff --git a/alexarc4shng/webif/templates/index.html b/alexarc4shng/webif/templates/index.html index e892bd4cf..142760033 100755 --- a/alexarc4shng/webif/templates/index.html +++ b/alexarc4shng/webif/templates/index.html @@ -143,7 +143,7 @@

-
+
@@ -322,7 +322,7 @@ {% block bodytab3 %} -
+
diff --git a/apcups/README.md b/apcups/README.md deleted file mode 100755 index 4be911631..000000000 --- a/apcups/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# APC UPS - -## Requirements - -A running **apcupsd** with configured netserver (NIS). The plugin retrieves the information via network from the netserver. No local apcupsd is required. -The apcupsd package must be installed also on the local system (running daemon is not required). The plugin uses the **apcaccess** helper tool from the package. - -If running the daemon locally and using the netserver, then the ``/etc/apcupsd/apcupsd.conf`` should contain additionally the following entries: - -``` -NETSERVER on -NISPORT 3551 -NISIP 127.0.0.1 -``` - -## Supported Hardware - -Should work on all APC UPS devices. Tested only on a "smartUPS". - -## Configuration - -### plugin.yaml - -Add the following lines to activate the plugin: - -```yaml -ApcUps: - plugin_name: apcups - host: localhost - port: 3551 -``` - -Description of the attributes: - -* __host__: ip address of the NIS (optional, default: localhost) -* __port__: port of the NIS (optional, default: 3551) -* __cycle__: time to update the items with values from apcaccess - -### items.yaml - -There is only one attribute: **apcups** - -For a list of values for this attribute call "apcaccess" on the command line. This command will give back a text block containing a list of ``statusname : value`` entries like the following: - -``` -APC : 001,050,1127 -DATE : 2017-11-02 07:59:15 +0100 -HOSTNAME : sh11 -VERSION : 3.14.12 (29 March 2014) debian -UPSNAME : UPS_IDEN -CABLE : Ethernet Link -DRIVER : PCNET UPS Driver -UPSMODE : Stand Alone -STARTTIME: 2017-11-02 07:59:11 +0100 -MODEL : Smart-UPS 1400 RM -STATUS : ONLINE -LINEV : 227.5 Volts -LOADPCT : 31.2 Percent -BCHARGE : 100.0 Percent -TIMELEFT : 30.0 Minutes -MBATTCHG : 10 Percent -MINTIMEL : 5 Minutes -MAXTIME : 0 Seconds -MAXLINEV : 227.5 Volts -MINLINEV : 226.0 Volts -OUTPUTV : 227.5 Volts -SENSE : High -DWAKE : 0 Seconds -DSHUTD : 120 Seconds -DLOWBATT : 2 Minutes -LOTRANS : 208.0 Volts -HITRANS : 253.0 Volts -RETPCT : 0.0 Percent -ITEMP : 25.6 C -ALARMDEL : Low Battery -BATTV : 27.7 Volts -LINEFREQ : 49.8 Hz -LASTXFER : Line voltage notch or spike -NUMXFERS : 0 -TONBATT : 0 Seconds -CUMONBATT: 0 Seconds -XOFFBATT : N/A -SELFTEST : NO -STESTI : 336 -STATFLAG : 0x05000008 -REG1 : 0x00 -REG2 : 0x00 -REG3 : 0x00 -MANDATE : 08/16/00 -SERIALNO : GS0034003173 -BATTDATE : 06/20/15 -NOMOUTV : 230 Volts -NOMBATTV : 24.0 Volts -EXTBATTS : 0 -FIRMWARE : 162.3.I -END APC : 2017-11-02 08:00:39 +0100 -``` - -The plugin will check the items type. If the type is a string, then the value will we returned as a string (e.g. status "ONLINE"). -If it is of type num, then the item will be set to a float. To convert to a float, the returned string will be cut after first space and the converted (e.g. 235 Volt = 235). - -### Example - -The example below will read the keys **LINEV**, **STATUS** and **TIMELEFT** and returns their values. - -```yaml -# items/apcups.yaml -serverroom: - - apcups: - - linev: - visu_acl: ro - type: num - apcups: linev - - status: - # will be 'ONLINE', 'ONBATT', or in case of a problem simply empty - visu_acl: ro - type: str - apcups: status - - timeleft: - visu_acl: ro - type: num - apcups: timeleft -``` - -**type** depends on the values. - -### Status Report Fields - -Due to a look into this http://apcupsd.org/manual/manual.html#configuration-examples file the meaning of the above variables are as follows - -``` -APC - Header record indicating the STATUS format revision level, the number of records that follow the APC statement, and the number of bytes that follow the record. -DATE - The date and time that the information was last obtained from the UPS. -HOSTNAME - The name of the machine that collected the UPS data. -UPSNAME - The name of the UPS as stored in the EEPROM or in the UPSNAME directive in the configuration file. -VERSION - The apcupsd release number, build date, and platform. -CABLE - The cable as specified in the configuration file (UPSCABLE). -MODEL - The UPS model as derived from information from the UPS. -UPSMODE - The mode in which apcupsd is operating as specified in the configuration file (UPSMODE) -STARTTIME - The time/date that apcupsd was started. -STATUS - The current status of the UPS (ONLINE, ONBATT, etc.) -LINEV - The current line voltage as returned by the UPS. -LOADPCT - The percentage of load capacity as estimated by the UPS. -BCHARGE - The percentage charge on the batteries. -TIMELEFT - The remaining runtime left on batteries as estimated by the UPS. -MBATTCHG - If the battery charge percentage (BCHARGE) drops below this value, apcupsd will shutdown your system. Value is set in the configuration file (BATTERYLEVEL) -MINTIMEL - apcupsd will shutdown your system if the remaining runtime equals or is below this point. Value is set in the configuration file (MINUTES) -MAXTIME - apcupsd will shutdown your system if the time on batteries exceeds this value. A value of zero disables the feature. Value is set in the configuration file (TIMEOUT) -MAXLINEV - The maximum line voltage since the UPS was started, as reported by the UPS -MINLINEV - The minimum line voltage since the UPS was started, as returned by the UPS -OUTPUTV - The voltage the UPS is supplying to your equipment -SENSE - The sensitivity level of the UPS to line voltage fluctuations. -DWAKE - The amount of time the UPS will wait before restoring power to your equipment after a power off condition when the power is restored. -DSHUTD - The grace delay that the UPS gives after receiving a power down command from apcupsd before it powers off your equipment. -DLOWBATT - The remaining runtime below which the UPS sends the low battery signal. At this point apcupsd will force an immediate emergency shutdown. -LOTRANS - The line voltage below which the UPS will switch to batteries. -HITRANS - The line voltage above which the UPS will switch to batteries. -RETPCT - The percentage charge that the batteries must have after a power off condition before the UPS will restore power to your equipment. -ITEMP - Internal UPS temperature as supplied by the UPS. -ALARMDEL - The delay period for the UPS alarm. -BATTV - Battery voltage as supplied by the UPS. -LINEFREQ - Line frequency in hertz as given by the UPS. -LASTXFER - The reason for the last transfer to batteries. -NUMXFERS - The number of transfers to batteries since apcupsd startup. -XONBATT - Time and date of last transfer to batteries, or N/A. -TONBATT - Time in seconds currently on batteries, or 0. -CUMONBATT - Total (cumulative) time on batteries in seconds since apcupsd startup. -XOFFBATT - Time and date of last transfer from batteries, or N/A. -SELFTEST - - The results of the last self test, and may have the following values: - - OK: self test indicates good battery - BT: self test failed due to insufficient battery capacity - NG: self test failed due to overload - NO: No results (i.e. no self test performed in the last 5 minutes) - -STESTI - The interval in hours between automatic self tests. -STATFLAG - Status flag. English version is given by STATUS. -DIPSW - The current dip switch settings on UPSes that have them. -REG1 - The value from the UPS fault register 1. -REG2 - The value from the UPS fault register 2. -REG3 - The value from the UPS fault register 3. -MANDATE - The date the UPS was manufactured. -SERIALNO - The UPS serial number. -BATTDATE - The date that batteries were last replaced. -NOMOUTV - The output voltage that the UPS will attempt to supply when on battery power. -NOMINV - The input voltage that the UPS is configured to expect. -NOMBATTV - The nominal battery voltage. -NOMPOWER - The maximum power in Watts that the UPS is designed to supply. -HUMIDITY - The humidity as measured by the UPS. -AMBTEMP - The ambient temperature as measured by the UPS. -EXTBATTS - The number of external batteries as defined by the user. A correct number here helps the UPS compute the remaining runtime more accurately. -BADBATTS - The number of bad battery packs. -FIRMWARE - The firmware revision number as reported by the UPS. -APCMODEL - The old APC model identification code. -END APC - The time and date that the STATUS record was written. -``` diff --git a/apcups/__init__.py b/apcups/__init__.py index 8328fa6e2..6fc987aef 100755 --- a/apcups/__init__.py +++ b/apcups/__init__.py @@ -20,6 +20,7 @@ ######################################################################### import logging +import threading import subprocess # we scrape apcaccess output from lib.model.smartplugin import SmartPlugin @@ -28,7 +29,7 @@ ITEM_TAG = ['apcups'] class APCUPS(SmartPlugin): - PLUGIN_VERSION = "1.3.3" + PLUGIN_VERSION = "1.4.0" def __init__(self, sh): # Call init code of parent class (SmartPlugin) @@ -42,49 +43,63 @@ def __init__(self, sh): self._port = self.get_parameter_value('port') self._cycle = self.get_parameter_value('cycle') self._items = {} + self._lock = threading.Lock() + # the command goes here + self._command = f"/sbin/apcaccess status {self._host}:{ self._port}" + self._last_readout = "" def run(self): self.alive = True - self.scheduler_add('APCups', self.update_status, cycle=self._cycle) + self.scheduler_add(self.get_shortname(), self.update_status, cycle=self._cycle) def stop(self): self.alive = False + self.scheduler_remove(self.get_shortname()) + def parse_item(self, item): if self.has_iattr(item.conf, ITEM_TAG[0]): apcups_key = (self.get_iattr_value(item.conf, ITEM_TAG[0])).lower() self._items[apcups_key]=item - self.logger.debug("item {0} added with apcupd_key {1}".format(item,apcups_key)) - return self.update_item - else: - return None + self.logger.debug(f"item {item} added with apcupd_key {apcups_key}") + # no callback for any item needed as this plugin is readonly + return None def update_item(self, item, caller=None, source=None, dest=None): - if caller != 'plugin': - self.logger.debug("update item: {0}".format(item.id())) + pass def update_status(self): """ Start **apcaccess** on a shell, capture the output and parse it. The items attribut parameter will be matched against the shell output """ - command = '/sbin/apcaccess status {0}:{1}'.format(self._host, self._port) # the command goes here - output = subprocess.check_output(command.split(), shell=False) - # decode byte string to string - output = output.decode() - for line in output.split('\n'): - (key,spl,val) = line.partition(': ') - key = key.rstrip().lower() - val = val.strip() + + if not self._lock.acquire(timeout=1): + return + try: + self._command = f"/sbin/apcaccess status {self._host}:{self._port}" # the command goes here + output = subprocess.check_output(self._command.split(), shell=False) + # decode byte string to string + output = output.decode() + # save for webinterface + self._last_readout = output + for line in output.split('\n'): + (key,spl,val) = line.partition(': ') + key = key.rstrip().lower() + val = val.strip() - if key in self._items: - self.logger.debug("update item {0} with {1}".format(self._items[key],val)) - item = self._items[key] - self.logger.debug("Item type {0}".format(item.type())) - if item.type() == 'str': - item (val, 'apcups') - else: - val = val.split(' ', 1)[0] # ignore anything after 1st space - item (float(val), 'apcups') - return + if key in self._items: + self.logger.debug(f"update item {self._items[key]} with {val}") + item = self._items[key] + self.logger.debug(f"Item type {item.type()}") + if item.type() == 'str': + item (val, self.get_shortname()) + else: + val = val.split(' ', 1)[0] # ignore anything after 1st space + item (float(val), self.get_shortname()) + return + except Exception as e: + self.logger.error(f"Problem {e} reading output from call to {self._command}") + finally: + self._lock.release() diff --git a/apcups/plugin.yaml b/apcups/plugin.yaml index b31891b4c..e8ba61906 100755 --- a/apcups/plugin.yaml +++ b/apcups/plugin.yaml @@ -5,15 +5,15 @@ plugin: description: de: 'Unterstützung für smartUPS Geräte der Firma APC' en: 'Support for smartUPS devices sold by APC' - maintainer: cmalo + maintainer: bmx tester: Sandman60 state: ready - #keywords: iot xyz + keywords: ups uninterruptible power supply #documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page #support: https://knx-user-forum.de/forum/supportforen/smarthome-py - version: 1.3.3 # Plugin version - sh_minversion: 1.3 # minimum shNG version to use this plugin + version: 1.4.0 # Plugin version + sh_minversion: 1.9 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance restartable: unknown @@ -38,7 +38,8 @@ parameters: cycle: type: int - valid_min: 0 + default: 60 + valid_min: 1 description: de: 'Zyklus-Zeit zum Update der Items mit Werten von APCaccess' en: time to update the items with values from apcaccess diff --git a/apcups/user_doc.rst b/apcups/user_doc.rst new file mode 100644 index 000000000..ca87121ee --- /dev/null +++ b/apcups/user_doc.rst @@ -0,0 +1,278 @@ +.. index:: Plugins; apcups +.. index:: apcups + + +====== +apcups +====== + + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Anforderungen +============= + +Ein laufender ``apcupsd`` mit einem konfigurierten Netserver (NIS) ist notwendig. Dieser kann lokal oder remote laufen. +Das Plugin fragt Laufzeitdaten vom apcupsd über das Hilfsprogramm ``apcaccess`` ab. Aus diesem Grund muss das ``apcups`` Package auch lokal installiert sein. +Wenn der Daemon lokal installiert ist, sollte die Datei ``/etc/apcupsd/apcupsd.conf`` noch folgende Informationen beinhalten: + +:: + + NETSERVER on + NISPORT 3551 + NISIP 127.0.0.1 + +Unterstützte Geräte +=================== + +Sollte mit allen APC UPS Geräten funktionieren, die den apcupsd unterstützen. Getestet wurde nur mit einer **smartUPS**. + + +Konfiguration +============= + +Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/apcups` beschrieben. + +items.yaml +---------- + +Es gibt nur ein einziges Attribut ``apcups``. Die Namen für den Statusabruf können über den Befehl ``apcaccess`` auf der Kommandozeile +abgerufen werden. Damit wird eine Liste der Art ``Statusname : Wert`` angezeigt. + +Der Wert der dem ``apcups`` Item Attribut als Parameter übergeben wird ist dieser Statusname. Dem Item wird dann dieser Wert zugewiesen. + +:: + + APC : 001,050,1127 + DATE : 2017-11-02 07:59:15 +0100 + HOSTNAME : sh11 + VERSION : 3.14.12 (29 March 2014) debian + UPSNAME : UPS_IDEN + CABLE : Ethernet Link + DRIVER : PCNET UPS Driver + UPSMODE : Stand Alone + STARTTIME: 2017-11-02 07:59:11 +0100 + MODEL : Smart-UPS 1400 RM + STATUS : ONLINE + LINEV : 227.5 Volts + LOADPCT : 31.2 Percent + BCHARGE : 100.0 Percent + TIMELEFT : 30.0 Minutes + MBATTCHG : 10 Percent + MINTIMEL : 5 Minutes + MAXTIME : 0 Seconds + MAXLINEV : 227.5 Volts + MINLINEV : 226.0 Volts + OUTPUTV : 227.5 Volts + SENSE : High + DWAKE : 0 Seconds + DSHUTD : 120 Seconds + DLOWBATT : 2 Minutes + LOTRANS : 208.0 Volts + HITRANS : 253.0 Volts + RETPCT : 0.0 Percent + ITEMP : 25.6 C + ALARMDEL : Low Battery + BATTV : 27.7 Volts + LINEFREQ : 49.8 Hz + LASTXFER : Line voltage notch or spike + NUMXFERS : 0 + TONBATT : 0 Seconds + CUMONBATT: 0 Seconds + XOFFBATT : N/A + SELFTEST : NO + STESTI : 336 + STATFLAG : 0x05000008 + REG1 : 0x00 + REG2 : 0x00 + REG3 : 0x00 + MANDATE : 08/16/00 + SERIALNO : GS0034003173 + BATTDATE : 06/20/15 + NOMOUTV : 230 Volts + NOMBATTV : 24.0 Volts + EXTBATTS : 0 + FIRMWARE : 162.3.I + END APC : 2017-11-02 08:00:39 +0100 + +Das Plugin führt eine automatische Typumwandlung durch entsprechend dem verwendeten Item Typ. +Bei der Umwandlung in einen numerischen Wert wird nach dem ersten Leerzeichen abgeschnitten und dann konvertiert. Aus ``235 Volt`` wird also ``235``. + +Beispiele +========= + +Schlüssel auslesen +------------------ + +Das folgende Beispiel liest die Schlüssel **LINEV**, **STATUS** und +**TIMELEFT** und gibt deren Werte zurück. + +.. code-block:: yaml + + # items/apcups.yaml + serverroom: + + apcups: + + linev: + visu_acl: ro + type: num + apcups: linev + + status: + # will be 'ONLINE', 'ONBATT', or in case of a problem simply empty + visu_acl: ro + type: str + apcups: status + + timeleft: + visu_acl: ro + type: num + apcups: timeleft + +**type** hängt von den Werten ab. + +Status Report Fields +-------------------- + +Laut `APC `_) +ist die Bedeutung der Variablen wie folgt: + +:: + + APC + Header-Datensatz, der den Revisionsstand des STATUS-Formats, die Anzahl der Datensätze, die auf die APC-Anweisung folgen, und die Anzahl der Bytes, die auf den Datensatz folgen, angibt. + DATE + Das Datum und die Uhrzeit, zu der die Informationen zuletzt von der USV abgerufen wurden. + HOSTNAME + Der Name des Rechners, der die USV-Daten erfasst hat. + UPSNAME + Der Name der USV, wie er im EEPROM oder in der Direktive UPSNAME in der Konfigurationsdatei gespeichert ist. + VERSION + Die apcupsd-Versionsnummer, das Erstellungsdatum und die Plattform. + KABEL + Das Kabel, wie in der Konfigurationsdatei angegeben (UPSCABLE). + MODELL + Das USV-Modell, das aus den Informationen der USV abgeleitet wurde. + UPSMODE + Der Modus, in dem apcupsd arbeitet, wie in der Konfigurationsdatei angegeben (UPSMODE) + STARTTIME + Die Uhrzeit/das Datum, zu der/dem apcupsd gestartet wurde. + STATUS + Der aktuelle Status der USV (ONLINE, ONBATT, etc.) + LINEV + Die aktuelle Netzspannung, wie sie von der USV zurückgegeben wird. + LOADPCT + Der von der USV geschätzte Prozentsatz der Lastkapazität. + BCHARGE + Die prozentuale Ladung der Batterien. + TIMELEFT + Die von der USV geschätzte Restlaufzeit der Batterien. + MBATTCHG + Wenn der Prozentsatz der Batterieladung (BCHARGE) unter diesen Wert fällt, schaltet apcupsd Ihr System ab. Der Wert wird in der Konfigurationsdatei (BATTERYLEVEL) festgelegt. + MINTIMEL + apcupsd fährt Ihr System herunter, wenn die verbleibende Laufzeit diesen Wert erreicht oder unterschreitet. Der Wert wird in der Konfigurationsdatei festgelegt (MINUTES) + MAXTIME + apcupsd schaltet Ihr System ab, wenn die Akkulaufzeit diesen Wert überschreitet. Ein Wert von Null deaktiviert die Funktion. Der Wert wird in der Konfigurationsdatei festgelegt (TIMEOUT) + MAXLINEV + Die maximale Netzspannung seit dem Start der USV, wie von der USV gemeldet + MINLINEV + Die minimale Netzspannung seit dem Start der USV, wie von der USV zurückgemeldet + OUTPUTV + Die Spannung, die die USV an Ihre Geräte liefert + SENSE + Der Empfindlichkeitsgrad der USV gegenüber Schwankungen der Netzspannung. + DWAKE + Die Zeit, die die USV wartet, bevor sie die Stromversorgung Ihrer Geräte nach einem Stromausfall wiederherstellt, wenn die Stromversorgung wiederhergestellt ist. + DSHUTD + Die Wartezeit, die die USV nach Erhalt eines Ausschaltbefehls von apcupsd einhält, bevor sie Ihre Geräte ausschaltet. + DLOWBATT + Die verbleibende Laufzeit, bei deren Unterschreitung die USV das Signal für eine schwache Batterie sendet. An diesem Punkt erzwingt apcupsd eine sofortige Notabschaltung. + LOTRANS + Die Netzspannung, unterhalb derer die USV auf Batterien umschaltet. + HITRANS + Die Netzspannung, oberhalb derer die USV auf Batterien umschaltet. + RETPCT + Die prozentuale Ladung, die die Batterien nach einem Stromausfall haben müssen, bevor die USV die Stromversorgung Ihrer Geräte wiederherstellt. + ITEMP + Interne USV-Temperatur, wie von der USV geliefert. + ALARMDEL + Die Verzögerungszeit für den USV-Alarm. + BATTV + Batteriespannung, wie sie von der USV geliefert wird. + LINEFREQ + Netzfrequenz in Hertz, wie von der USV angegeben. + LASTXFER + Der Grund für die letzte Übertragung an die Batterien. + NUMXFERS + Die Anzahl der Übertragungen an die Batterien seit dem Start von apcupsd. + XONBATT + Uhrzeit und Datum der letzten Übertragung in die Batterien oder N/A. + TONBATT + Zeit in Sekunden, die derzeit auf Batterien übertragen wird, oder 0. + CUMONBATT + Gesamte (kumulative) Zeit auf den Batterien in Sekunden seit dem Start von apcupsd. + XOFFBATT + Zeit und Datum der letzten Übertragung von den Batterien oder N/A. + SELFTEST + Die Ergebnisse des letzten Selbsttests und können die folgenden Werte haben: + + OK: Selbsttest zeigt gute Batterie an + BT: Selbsttest wegen unzureichender Batteriekapazität fehlgeschlagen + NG: Selbsttest aufgrund von Überlastung fehlgeschlagen + NO: Keine Ergebnisse (d.h. in den letzten 5 Minuten wurde kein Selbsttest durchgeführt) + + STESTI + Das Intervall in Stunden zwischen den automatischen Selbsttests. + STATFLAG + Statusflagge. Die englische Version wird durch STATUS angegeben. + DIPSW + Die aktuellen Dip-Schalter-Einstellungen bei USVs, die über solche verfügen. + REG1 + Der Wert aus dem USV-Fehlerregister 1. + REG2 + Der Wert aus dem USV-Fehlerregister 2. + REG3 + Der Wert aus dem USV-Fehlerregister 3. + MANDATE + Das Datum, an dem die USV hergestellt wurde. + SERIENNUMMER + Die Seriennummer der USV. + BATTDATUM + Das Datum, an dem die Batterien zuletzt ausgetauscht wurden. + NOMOUTV + Die Ausgangsspannung, die die USV versucht zu liefern, wenn sie mit Batterien betrieben wird. + NOMINV + Die Eingangsspannung, für die die USV konfiguriert ist. + NOMBATTV + Die Nennspannung der Batterie. + NOMPOWER + Die maximale Leistung in Watt, für die die USV ausgelegt ist. + FEUCHTIGKEIT + Die von der USV gemessene Luftfeuchtigkeit. + AMBTEMP + Die von der USV gemessene Umgebungstemperatur. + EXTBATTEN + Die Anzahl der externen Batterien, wie vom Benutzer definiert. Eine korrekte Zahl hier hilft der USV, die verbleibende Laufzeit genauer zu berechnen. + BADBATTS + Die Anzahl der defekten Akkus. + FIRMWARE + Die Firmware-Revisionsnummer, wie von der USV gemeldet. + APCMODEL + Der alte APC-Modellidentifikationscode. + END APC + Die Uhrzeit und das Datum, an dem der STATUS-Datensatz geschrieben wurde. + +| + +Web Interface +============= + +Aktuell hat das Plugin kein Webinterface. diff --git a/apcups/webif/static/img/plugin_logo.png b/apcups/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..8764febba Binary files /dev/null and b/apcups/webif/static/img/plugin_logo.png differ diff --git a/appletv/README.md b/appletv/README.md deleted file mode 100755 index 88ba3c835..000000000 --- a/appletv/README.md +++ /dev/null @@ -1,340 +0,0 @@ -# Apple TV plugin - -#### Version 1.7.1 - -With this plugin you can control one or more Apple TV's. Each Apple TV needs an own plugin instance. It uses the fantastic [pyatv library](https://github.com/postlund/pyatv/tree/v0.3.9) from [Pierre Ståhl](https://github.com/postlund). It also provides a web interface to be used with the `http` module. - - -## Requirements -This plugin is designed to work with SHNG v1.5. It runs on current develop version, but using Python >= 3.5 is mandatory. - -### Needed software - -* Python >= 3.6 -* [pyatv package](https://github.com/postlund/pyatv "pyatv page on GitHub") - -### Supported Hardware - -* [Apple TV](https://www.apple.com/tv/), all generations should work - -## Configuration - -### plugin.yaml - -```yaml -appletv: - plugin_name: appletv - #instance: wohnzimmer - #ip: 192.168.2.103 - #login_id: 00000000-0580-3568-6c73-86bd9b834320 -``` -The `instance` name is only needed if you have more than one Apple TV. If not specified it uses the default instance. - -The parameters `ip` and `login_id` are needed if you have more than one Apple TV. If you omit them the plugin tries to autodetect the Apple TV and uses the first one it finds. You can find the needed parameters for all detected devices in the plugin's web interface. - -### items.yaml - -Here comes a list of all possible items. -#### name (string) -Contains the name of the device, will be filled by autodetection on plugin startup - -#### artwork_url (string) -Contains a URL to the artwork of the currently played media file (if available). - -#### play_state (integer) -The current state of playing as integer. Currently supported play states: - -* 0: Device is in idle state -* 1: No media is currently select/playing -* 2: Media is loading/buffering -* 3: Media is paused -* 4: Media is playing -* 5: Media is being fast forwarded -* 6: Media is being rewinded - -#### play\_state\_string (string) -The current state of playing as text. - -#### playing (bool) -`True` if play\_state is 4 (Media is playing), `False` for all other play\_states. - -#### media_type (integer) -The current state of playing as integer. Currently supported play states: - -* 1: Media type is unknown -* 2: Media type is video -* 3: Media type is music -* 4: Media type is TV - -#### media\_type\_string (string) -The current media type as text. - -#### album (string) -The album name. Only relevant if content is music. - -#### artist (string) -The artist name. Only relevant if content is music. - -#### genre (string) -The genre of the music. Only relevant if content is music. - -#### title (string) -The title of the current media. - -#### position (integer) -The actual position inside the playing media in seconds. - -#### total_time (integer) -The actual playint time of the media in seconds. - -#### position_percent (integer) -The actual position inside the playing media in %. - -#### repeat (integer) -The current state of selected repeat mode. Currently supported repeat modes: - -* 0: No repeat -* 1: Repeat current track -* 2: Repeat all tracks - -#### repeat_string (string) -The actual choosen type of repeat mode as string. - -#### shuffle (bool) -`True` if shuffle is enabled, `False` if not. - -#### rc_top_menu (bool) -Set this item to `True` to return to home menu. -The plugin resets this item to `False` after command execution. - -#### rc_menu (bool) -Set this item to `True` to return to menu. -The plugin resets this item to `False` after command execution. - -#### rc_select -Set this item to `True` to press the 'select' key. -The plugin resets this item to `False` after command execution. - -#### rc_left, rc_up, rc_right, rc_down (bools) -Set one of these items to `True` to move the cursor to the respective direction. -The plugin resets these items to `False` after command execution. - -#### rc_previous -Set this item to `True` to press the 'previous' key. -The plugin resets this item to `False` after command execution. - -#### rc_play -Set this item to `True` to press the 'play' key. -The plugin resets this item to `False` after command execution. - -#### rc_pause -Set this item to `True` to press the 'pause' key. -The plugin resets this item to `False` after command execution. - -#### rc_stop -Set this item to `True` to press the 'stop' key. -The plugin resets this item to `False` after command execution. - -#### rc_next -Set this item to `True` to press the 'next' key. -The plugin resets this item to `False` after command execution. - -#### Example - -```yaml -atv: - wohnzimmer: - name: - type: str - atv@wohnzimmer: name - artwork_url: - type: str - atv@wohnzimmer: artwork_url - play_state: - type: num - atv@wohnzimmer: play_state - play_state_string: - type: str - atv@wohnzimmer: play_state_string - playing: - type: bool - atv@wohnzimmer: playing - media_type: - type: num - atv@wohnzimmer: media_type - media_type_string: - type: str - atv@wohnzimmer: media_type_string - album: - type: str - atv@wohnzimmer: album - artist: - type: str - atv@wohnzimmer: artist - genre: - type: str - atv@wohnzimmer: genre - title: - type: str - atv@wohnzimmer: title - position: - type: num - visu_acl: rw - atv@wohnzimmer: position - total_time: - type: num - atv@wohnzimmer: total_time - position_percent: - type: num - atv@wohnzimmer: position_percent - repeat: - type: num - visu_acl: rw - atv@wohnzimmer: repeat - repeat_string: - type: str - atv@wohnzimmer: repeat_string - shuffle: - type: bool - visu_acl: rw - atv@wohnzimmer: shuffle - rc_top_menu: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_top_menu - rc_menu: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_menu - rc_select: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_select - rc_left: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_left - rc_up: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_up - rc_down: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_down - rc_right: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_right - rc_previous: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_previous - rc_play: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_play - rc_pause: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_pause - rc_stop: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_stop - rc_next: - type: bool - visu_acl: rw - atv@wohnzimmer: rc_next -``` - -## Methods - -### is_playing() -Returns `true` or `false` indicating if the Apple TV is currently playing media. -Example: `playing = sh.appletv.is_playing()` - -### play() -Sends a pause command to the device. -Example: `sh.appletv.play()` - -### pause() -Sends a pause command to the device. -Example: `sh.appletv.pause()` - -### play_url(url) -Plays a media using the given URL. Thed media must of course be compatible with the Apple TV device. For this to work SHNG must be authenticated with the device first. This is done by using the "Authenticate" button in the web interface. A PIN code displayed on the TV screen must then be entered in the web interface. This should only be needed once and be valid forever. -Example: `sh.appletv.play_url('http://distribution.bbb3d.renderfarming.net/video/mp4/bbb_sunflower_1080p_60fps_normal.mp4')` - -## Visualisation with SmartVISU -If you use SmartVISU as your visualisation you can use the following html code inside one of your pages to get you started: - -``` -
-
-
-

Apple TV {{ basic.print('', 'atv.wohnzimmer.name') }} ({{ basic.print('', 'atv.wohnzimmer.media_type_string') }} {{ basic.print('', 'atv.wohnzimmer.play_state_string') }})

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ basic.stateswitch('', 'atv.wohnzimmer.rc_top_menu', '', '1', 'jquery_home.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_menu', '', '1', 'control_return.svg', '') }} - - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_up', '', '1', 'control_arrow_up.svg', '') }} -
- {{ basic.stateswitch('', 'atv.wohnzimmer.shuffle', '', '', 'audio_shuffle.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.repeat', '', [0,1,2], ['audio_repeat.svg','audio_repeat_song.svg','audio_repeat.svg'], '', ['icon0','icon1','icon1']) }} - - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_left', '', '1', 'control_arrow_left.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_select', '', '1', 'control_ok.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_right', '', '1', 'control_arrow_right.svg', '') }} -
  - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_down', '', '1', 'control_arrow_down.svg', '') }} -
 
- {{ basic.print('', 'atv.wohnzimmer.artist') }} - {{ basic.print('', 'atv.wohnzimmer.album') }} -
- {{ basic.print('', 'atv.wohnzimmer.title') }} ({{ basic.print('', 'atv.wohnzimmer.genre') }}) -
{{ basic.slider('', 'atv.wohnzimmer.position_percent', 0, 100, 1, 'horizontal', 'none') }}
-
- {{ basic.stateswitch('', 'atv.wohnzimmer.rc_previous', '', '1', 'audio_rew.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_play', '', '1', 'audio_play.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_pause', '', '1', 'audio_pause.svg', '') }} - {{ basic.stateswitch('', 'atv.wohnzimmer.rc_next', '', '1', 'audio_ff.svg', '') }} -
-
- {{ basic.print ('', 'atv.wohnzimmer.artwork_url', 'html', '\'\'') }} -
-
-
-
-``` - diff --git a/appletv/__init__.py b/appletv/__init__.py index 16e2673df..514ba1603 100755 --- a/appletv/__init__.py +++ b/appletv/__init__.py @@ -5,8 +5,7 @@ ######################################################################### # This file is part of SmartHomeNG. # -# Sample plugin for new plugins to run with SmartHomeNG version 1.4 and -# upwards. +# AppleTV plugin # # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -28,15 +27,14 @@ from lib.module import Modules from lib.model.smartplugin import * from lib.item import Items +from .webif import WebInterface import asyncio import datetime import os import threading import base64 -import json -from random import randint -from time import sleep + import pyatv from pyatv.const import Protocol @@ -50,7 +48,7 @@ class AppleTV(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '1.6.1' + PLUGIN_VERSION = '1.6.2' def __init__(self, sh): """ @@ -89,7 +87,7 @@ def __init__(self, sh): self._atv_pwc = None - self.init_webinterface() + self.init_webinterface(WebInterface) return def run(self): @@ -107,11 +105,14 @@ def stop(self): """ self.logger.debug( "Plugin '{}': stop method called".format(self.get_fullname())) - self._loop.stop() - while self._loop.is_running(): - pass - self._loop.run_until_complete(self.disconnect()) - self._loop.close() + try: + self._loop.stop() + while self._loop.is_running(): + pass + self._loop.run_until_complete(self.disconnect()) + self._loop.close() + except Exception as e: + self.logger.warning(f"Issues stopping AppleTV plugin: {e}") self.alive = False def parse_item(self, item): @@ -223,26 +224,29 @@ def load_credentials(self): async def discover(self): """ - Discovers Apple TV's on local mdns domain + Discovers Apple TV's on local mdns domain """ - self.logger.debug("Discovering Apple TV's in your network for {} seconds...".format( - int(self._atv_scan_timeout))) - self._atvs = await pyatv.scan(self._loop, timeout=self._atv_scan_timeout) + try: + self.logger.debug("Discovering Apple TV's in your network for {} seconds...".format( + int(self._atv_scan_timeout))) + self._atvs = await pyatv.scan(self._loop, timeout=self._atv_scan_timeout) - if not self._atvs: - self.logger.warning("No Apple TV found") - else: - self.logger.info("Found {} Apple TV's:".format(len(self._atvs))) - for _atv in self._atvs: - _markup = '-' - if str(_atv.address) == str(self._ip): - _markup = '*' - self._atv = _atv - self.logger.info(" {} {}, IP: {}".format(_markup, _atv.name, _atv.address)) + if not self._atvs: + self.logger.warning("No Apple TV found") + else: + self.logger.info("Found {} Apple TV's:".format(len(self._atvs))) + for _atv in self._atvs: + _markup = '-' + if str(_atv.address) == str(self._ip): + _markup = '*' + self._atv = _atv + self.logger.info(" {} {}, IP: {}".format(_markup, _atv.name, _atv.address)) + except Exception as e: + self.logger.warning("Issue while searching for Apple TV: {}".format(e)) async def connect(self): """ - Connects to this instance's Apple TV + Connects to this instance's Apple TV """ if not self._atv: if len(self._atvs) > 0: @@ -259,7 +263,7 @@ async def connect(self): self._update_items('mac', self._atv.device_info.mac) if self._atv.device_info.model: self._update_items('model', str(self._atv.device_info.model).replace('DeviceModel.','')) - if self._atv.device_info.operating_system.TvOS: + if self._atv.device_info.operating_system.TvOS and self._atv.device_info.version is not None: self._update_items('os', 'TvOS ' + self._atv.device_info.version) else: self._update_items('os', self._atv.device_info.version) @@ -268,10 +272,13 @@ async def connect(self): self._device = await pyatv.connect(self._atv, self._loop) self._atv_rc = self._device.remote_control self._atv_pwc = self._device.power - if self._atv_pwc.power_state == pyatv.const.PowerState.On: - self._update_items('power', True) - else: - self._update_items('power', False) + try: + if self._atv_pwc.power_state == pyatv.const.PowerState.On: + self._update_items('power', True) + else: + self._update_items('power', False) + except Exception as e: + self.logger.error(f"Could not query power state. Error: {e}") self._push_listener_thread = threading.Thread( target=self._push_listener_thread_worker, name='ATV listener') self._push_listener_thread.start() @@ -279,11 +286,14 @@ async def connect(self): async def disconnect(self): """ - Stop listening to push updates and logout of this istances Apple TV + Stop listening to push updates and logout of this istances Apple TV """ self.logger.info("Disconnecting from '{0}'".format(self._atv.name)) - self._device.push_updater.stop() - self._device.close() + try: + self._device.push_updater.stop() + self._device.close() + except Exception as e: + self.logger.info(f"Could not disconnect from AppleTV. Error: {e}") async def update_artwork(self): try: @@ -312,22 +322,24 @@ def _update_position(self, new_position, from_device): self._position = new_position self._position_timestamp = datetime.datetime.now() self._update_items('playing_position', new_position) - if new_position > 0 and self._state['playing_total_time'] > 0: + if new_position > 0 and self._state['playing_total_time'] > 0: self._update_items('playing_position_percent', int(round(new_position / self._state['playing_total_time'] * 100))) else: self._update_items('playing_position_percent', 0) def handle_async_exception(self, loop, context): - self.logger.error('*** ASYNC EXCEPTIONM ***') - self.logger.error('Context: {}'.format(context)) - raise + self.logger.error('ASYNC EXCEPTION. Context: {}'.format(context)) + #raise Exception() def _push_listener_thread_worker(self): """ Thread to run asyncio loop. This avoids blocking the main plugin thread """ asyncio.set_event_loop(self._loop) - self._loop.set_exception_handler(self.handle_async_exception) + try: + self._loop.set_exception_handler(self.handle_async_exception) + except Exception as e: + self.logger.error(f"Issue with exception handler: {e}") self._device.push_updater.listener = self self._device.push_updater.start() self._device.power.listener = self @@ -366,6 +378,7 @@ def playstatus_update(self, updater, playstatus): self._update_items('playing_app_identifier', _app.identifier if _app.identifier else '---') except: pass + self._update_items('playing_state', playstatus.device_state.value) self._update_items('playing_state_text', pyatv.convert.device_state_str(playstatus.device_state)) self._update_items('playing_fingerprint', playstatus.hash) @@ -381,10 +394,13 @@ def playstatus_update(self, updater, playstatus): self._update_items('playing_position_percent', round(playstatus.position / playstatus.total_time * 100)) else: self._update_items('playing_position_percent', 0) - self._update_items('playing_repeat', playstatus.repeat.value) - self._update_items('playing_repeat_text', pyatv.convert.repeat_str(playstatus.repeat)) - self._update_items('playing_shuffle', playstatus.shuffle.value) - self._update_items('playing_shuffle_text', pyatv.convert.shuffle_str(playstatus.shuffle)) + try: + self._update_items('playing_repeat', playstatus.repeat.value) + self._update_items('playing_repeat_text', pyatv.convert.repeat_str(playstatus.repeat)) + self._update_items('playing_shuffle', playstatus.shuffle.value) + self._update_items('playing_shuffle_text', pyatv.convert.shuffle_str(playstatus.shuffle)) + except Exception as e: + self.logger.warning(f"Could not query repeat and/or shuffle state. Error: {e}") def playstatus_error(self, updater, exception): """ @@ -426,155 +442,3 @@ def pause(self): def play(self): self.logger.warning('Playing, sending command !!') self._loop.create_task(self.execute_rc('rc_play')) - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - # try/except to handle running in a core version that does not support modules - self.mod_http = Modules.get_instance().get_module('http') - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error( - "Plugin '{}': Not initializing the web interface".format(self.get_shortname())) - return False - - import sys - if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): - self.logger.warning( - "Plugin '{}': Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface".format(self.get_shortname())) - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.items = Items.get_instance() - self.tplenv = self.init_template_environment() - self.pinentry = False - - @cherrypy.expose - def index(self, reload=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - # get list of items with the attribute knx_dpt - plgitems = [] - _instance = self.plugin.get_instance_name() - if _instance: - _keyword = 'appletv@{}'.format(_instance) - else: - _keyword = 'appletv' - for item in self.items.return_items(): - if _keyword in item.conf: - plgitems.append(item) - tmpl = self.tplenv.get_template('index.html') - return tmpl.render(p=self.plugin, items=sorted(plgitems, key=lambda k: str.lower(k['_path'])), pinentry=self.pinentry) - - @cherrypy.expose - def get_data_html(self, dataSet=None): - """ - Return data to update the webpage - - For the standard update mechanism of the web interface, the dataSet to return the data for is None - - :param dataSet: Dataset for which the data should be returned (standard: None) - :return: dict with the data needed to update the web page. - """ - if dataSet is None: - data = {} - data['state'] = self.plugin._state - # return it as json the the web page - try: - return json.dumps(data) - except Exception as e: - self.logger.error("get_data_html exception: {}".format(e)) - #self.logger.debug(data) - return {} - - @cherrypy.expose - def button_pressed(self, button=None, pin=None): - if button == "discover": - self.logger.debug('Discover button pressed') - self.plugin._loop.create_task(self.plugin.discover()) - elif button == "start_authorization": - self.logger.debug('Start authentication') - self.pinentry = True - - _protocol = self.plugin._atv.main_service().protocol - _task = self.plugin._loop.create_task( - pyatv.pair(self.plugin._atv, _protocol, self.plugin._loop) - ) - while not _task.done(): - sleep(0.1) - self._pairing = _task.result() - if self._pairing.device_provides_pin: - self._pin = None - self.logger.info('Device provides pin') - else: - self._pin = randint(1111,9999) - self.logger.info('SHNG must provide pin: {}'.format(self._pin)) - - self.plugin._loop.create_task(self._pairing.begin()) - - elif button == "finish_authorization": - self.logger.debug('Finish authentication') - self.pinentry = False - self._pairing.pin(pin) - _task = self.plugin._loop.create_task(self._pairing.finish()) - while not _task.done(): - sleep(0.1) - if self._pairing.has_paired: - self.logger.info('Pairing successfull !') - self.plugin._credentials = self._pairing.service.credentials - self.plugin.save_credentials() - else: - self.logger.error('Unable to pair, wrong Pin ?') - self.plugin._loop.create_task(self._pairing.close()) - else: - self.logger.warning( - "Unknown button pressed in webif: {}".format(button)) - raise cherrypy.HTTPRedirect('index') diff --git a/appletv/assets/webif_appletv1.png b/appletv/assets/webif_appletv1.png new file mode 100755 index 000000000..05961e2ca Binary files /dev/null and b/appletv/assets/webif_appletv1.png differ diff --git a/appletv/plugin.yaml b/appletv/plugin.yaml index c7d185ee5..43053f3d0 100755 --- a/appletv/plugin.yaml +++ b/appletv/plugin.yaml @@ -7,13 +7,13 @@ plugin: en: 'Controls an Apple TV' fr: 'Contrôle un Apple TV' maintainer: Foxi352 - tester: Foxi352 + tester: onkelandy state: ready keywords: appletv media # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1223483-plugin-apple-tv - version: 1.6.1 # Plugin version + version: 1.6.2 # Plugin version sh_minversion: 1.6 # minimum shNG version to use this plugin multi_instance: True # plugin supports multi instance restartable: unknown @@ -35,43 +35,208 @@ parameters: en: 'Timeout in seconds to scan the local network for AppleTV devices' fr: "Délai d'attente en secondes du scan réseau pour trouver les appareils AppleTV" -item_attributes: NONE - # Definition of item attributes defined by this plugin +item_attributes: + appletv: + type: str + description: + de: 'ATV Funktion' + en: 'ATV function' + valid_list_description: + de: + - 'AppleTV Name' + - 'AppleTV IP Adresse' + - 'AppleTV MAC' + - 'AppleTV Modell' + - 'AppleTV OS Version' + - 'Einschaltstatus' + - 'Applikationsidentifikation' + - 'Applikationsname' + - 'Abspielstatus als Zahl' + - 'Abspielstatus als Text' + - 'Fingerprint des aktuellen Mediums' + - 'Aktuelles Genre' + - 'Aktueller Künstler' + - 'Aktuelles Album' + - 'Aktueller Titel' + - 'Aktueller Medientyp als Zahl' + - 'Aktueller Medientyp als Text' + - 'Aktuelle Zeitposition' + - 'Aktuelle Gesamtspielzeit' + - 'Aktuelle Abspielposition in Prozent' + - 'Aktueller Wiederholmodus als Zahl' + - 'Aktueller Wiederholmodus als Text' + - 'Aktueller Shuffle Modus als Zahl' + - 'Aktueller Shuffle Modus als Text' + - 'Breite des aktuellen Artworks in Pixel' + - 'Höhe des aktuellen Artworks in Pixel' + - 'Mimetype des aktuellen Artorks' + - 'Tatsächliches aktuelles Artwork' + - 'Topmenü Taste' + - 'Home Taste' + - 'Hold home Taste' + - 'Menü Taste' + - 'Auswählen Taste' + - 'Nächster Titel' + - 'Vorheriger Titel' + - 'Abspielen' + - 'Pause' + - 'Stop' + - 'Umschalten zwischen Play und Pause' + - 'Hinunter Taste' + - 'Hinauf Taste' + - 'Links Taste' + - 'Rechts Taste' + - 'Abspielposition setzen' + - 'Wiederholmodus setzen' + - 'Shuffle Modus setzen' + - 'Rückwärts springen' + - 'Vorwärts springen' + - 'Lautstärke verringern' + - 'Lautstärke erhöhen' + - 'Suspend Modus' + - 'Aufwecken' + en: + - 'AppleTV name' + - 'AppleTV IP address' + - 'AppleTV MAC' + - 'AppleTV Model' + - 'AppleTV OS version' + - 'Power State' + - 'App identifier of playing application' + - 'App name of playing application' + - 'Playing State as number' + - 'Playing State as text' + - 'Fingerprint of currently played media' + - 'Currently playing genre' + - 'Currently playing artist' + - 'Currently playing album' + - 'Currently playing title' + - 'Currently playing media type as number' + - 'Currently playing media type as text' + - 'Current time position' + - 'Current total time' + - 'Current playing position in percent' + - 'Current repeat setting as number' + - 'Current repeat setting as text' + - 'Current shuffle setting as number' + - 'Current shuffle setting as text' + - 'Width in pixels of current art work' + - 'Height in pixels of current art work' + - 'Mimetype of current art work' + - 'Actual playing artwork' + - 'Top menu key' + - 'Home key' + - 'Hold home key' + - 'Menu key' + - 'Select key' + - 'Next' + - 'Previous' + - 'Play' + - 'Pause' + - 'Stop' + - 'Toggle play/pause' + - 'Down key' + - 'Up key' + - 'Left key' + - 'Right key' + - 'Set play position' + - 'Set repeat mode' + - 'Set shuffle mode' + - 'Skip backward' + - 'Skip forward' + - 'Volume down' + - 'Volume up' + - 'Suspend mode' + - 'Wake up' + valid_list: + - 'name' + - 'ip' + - 'mac' + - 'model' + - 'os' + - 'power' + - 'playing_app_identifier' + - 'playing_app_name' + - 'playing_state' + - 'playing_state_text' + - 'playing_fingerprint' + - 'playing_genre' + - 'playing_artist' + - 'playing_album' + - 'playing_title' + - 'playing_type' + - 'playing_type_text' + - 'playing_position' + - 'playing_total_time' + - 'playing_position_percent' + - 'playing_repeat' + - 'playing_repeat_text' + - 'playing_shuffle' + - 'playing_shuffle_text' + - 'artwork_width' + - 'artwork_height' + - 'artwork_mimetype' + - 'artwork_base64' + - 'rc_top_menu' + - 'rc_home' + - 'rc_home_hold' + - 'rc_menu' + - 'rc_select' + - 'rc_next' + - 'rc_previous' + - 'rc_play' + - 'rc_pause' + - 'rc_stop' + - 'rc_play_pause' + - 'rc_down' + - 'rc_up' + - 'rc_left' + - 'rc_right' + - 'rc_set_position' + - 'rc_set_repeat' + - 'rc_set_shuffle' + - 'rc_skip_backward' + - 'rc_skip_forward' + - 'rc_volume_down' + - 'rc_volume_up' + - 'rc_suspend' + - 'rc_wakeup' + item_structs: device: - name: - name: Name of device + remark: + remark: Name of device type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: name ip: - name: IP address of device + remark: IP address of device type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: ip mac: - name: MAC address of device + remark: MAC address of device type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: mac model: - name: MAC address of device + remark: MAC address of device type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: model os: - name: MAC address of device + remark: MAC address of device type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: os power: - name: Power state of the device + remark: Power state of the device type: bool visu_acl: rw appletv@instance: power @@ -80,234 +245,234 @@ item_structs: identifier: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_app_identifier - name: + remark: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_app_name state: type: num visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_state state_text: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_state_text fingerprint: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_fingerprint genre: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_genre artist: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_artist album: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_album title: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_title - type: + media_type: type: num visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_type - type_text: + media_type_text: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_type_text position: type: num visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_position totaltime: type: num visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_total_time position_percent: type: num visu_acl: rw - cache: Yes + cache: True appletv@instance: playing_position_percent repeat: type: num visu_acl: rw - cache: Yes - enfore_updates: Yes + cache: True + enforce_updates: True appletv@instance: playing_repeat repeat_text: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_repeat_text shuffle: type: num visu_acl: rw - cache: Yes - enfore_updates: Yes + cache: True + enforce_updates: True appletv@instance: playing_shuffle shuffle_text: type: str visu_acl: ro - cache: Yes + cache: True appletv@instance: playing_shuffle_text artwork: width: - name: Width of the currently playing artwork + remark: Width of the currently playing artwork type: num visu_acl: ro appletv@instance: artwork_width height: - name: Height of the currently playing artwork + remark: Height of the currently playing artwork type: num visu_acl: ro appletv@instance: artwork_height mimetype: - name: Mimetype of the currently playing artwork + remark: Mimetype of the currently playing artwork type: str visu_acl: ro appletv@instance: artwork_mimetype base64: - name: The actual playing artwork in base64 + remark: The actual playing artwork in base64 type: foo visu_acl: ro appletv@instance: artwork_base64 commands: top_menu: - name: RC top menu key + remark: RC top menu key type: bool visu_acl: rw appletv@instance: rc_top_menu home: - name: RC kome key + remark: RC kome key type: bool visu_acl: rw appletv@instance: rc_home home_hold: - name: RC hold home key + remark: RC hold home key type: bool visu_acl: rw appletv@instance: rc_home_hold menu: - name: RC menu key + remark: RC menu key type: bool visu_acl: rw appletv@instance: rc_menu select: - name: RC select key + remark: RC select key type: bool visu_acl: rw appletv@instance: rc_select next: - name: RC next key + remark: RC next key type: bool visu_acl: rw appletv@instance: rc_next previous: - name: RC previous key + remark: RC previous key type: bool visu_acl: rw appletv@instance: rc_previous pause: - name: RC pause key + remark: RC pause key type: bool visu_acl: rw appletv@instance: rc_pause play: - name: RC play key + remark: RC play key type: bool visu_acl: rw appletv@instance: rc_play play_pause: - name: RC toggle between play and pause + remark: RC toggle between play and pause type: bool visu_acl: rw appletv@instance: rc_play_pause stop: - name: RC stop key + remark: RC stop key type: bool visu_acl: rw appletv@instance: rc_stop down: - name: RC down key + remark: RC down key type: bool visu_acl: rw appletv@instance: rc_down up: - name: RC up key + remark: RC up key type: bool visu_acl: rw appletv@instance: rc_up right: - name: RC right key + remark: RC right key type: bool visu_acl: rw appletv@instance: rc_right left: - name: RC left key + remark: RC left key type: bool visu_acl: rw appletv@instance: rc_left set_position: - name: RC set position + remark: RC set position type: num visu_acl: rw appletv@instance: rc_set_position set_repeat: - name: RC set repeat mode + remark: RC set repeat mode type: num visu_acl: rw appletv@instance: rc_set_repeat set_shuffle: - name: RC set shuffle mode + remark: RC set shuffle mode type: num visu_acl: rw appletv@instance: rc_set_shuffle skip_backward: - name: RC skip backward key + remark: RC skip backward key type: bool visu_acl: rw appletv@instance: rc_skip_backward skip_forward: - name: RC skip forward key + remark: RC skip forward key type: bool visu_acl: rw appletv@instance: rc_skip_forward volume_down: - name: RC volume down key + remark: RC volume down key type: bool visu_acl: rw appletv@instance: rc_volume_down volume_up: - name: RC volume up key + remark: RC volume up key type: bool visu_acl: rw appletv@instance: rc_volume_up suspend: - name: RC suspend key + remark: RC suspend key type: bool visu_acl: rw appletv@instance: rc_suspend wakeup: - name: RC wakeup key + remark: RC wakeup key type: bool visu_acl: rw appletv@instance: rc_wakeup diff --git a/appletv/requirements.txt b/appletv/requirements.txt index 228b33db0..2dda3583f 100755 --- a/appletv/requirements.txt +++ b/appletv/requirements.txt @@ -1 +1,7 @@ -pyatv==0.7.0 +pyatv==0.7.0;python_version<'3.9' +pyatv==0.10.3;python_version>='3.9' + +# miniaudio is used by pyatv, for the actual version of miniaudio (1.58) requested my pyatv 0.10.5, the wheel +# cannot be built on Python 3.11, so the following requirement was added: +miniaudio<=1.55;python_version>='3.11' + diff --git a/appletv/user_doc.rst b/appletv/user_doc.rst new file mode 100755 index 000000000..0024c5120 --- /dev/null +++ b/appletv/user_doc.rst @@ -0,0 +1,150 @@ +.. index:: Plugins; appletv +.. index:: appletv + +======= +appletv +======= + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 400px + :height: 308px + :scale: 100 % + :align: left + +Mit diesem Plugin können Sie ein oder mehrere `Apple TVs `_ aller Generationen steuern. Jedes Apple TV benötigt eine eigene Plugin-Instanz. Es benutzt die `pyatv library `_ von Pierre Ståhl. Es bietet auch eine Web-Schnittstelle, die mit dem `http`-Modul verwendet werden kann. + + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/appletv` beschrieben. + + +plugin.yaml +----------- + +.. code-block:: yaml + + # etc/plugin.yaml + appletv: + plugin_name: appletv + #instance: wohnzimmer + #ip: 192.168.2.103 + #login_id: 00000000-0580-3568-6c73-86bd9b834320 + +Items +===== + +.. important:: + + Detaillierte Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/appletv` zu finden. + + +.. code-block:: yaml + + # etc/plugin.yaml + appletv: + plugin_name: appletv + #ip: 0.0.0.0 + #scan_timeout: 5 + + +Struct Vorlagen +=============== + +Ab smarthomeNG 1.6 können Vorlagen aus dem Plugin einfach eingebunden werden. Dabei stehen folgende Vorlagen zur Verfügung: + +- device: Informationen zur IP, MAC-Adresse, Einschaltzustand, etc. +- playing: Informationen zum aktuell gespielten Titel wie Artist, Album, etc. sowie Ansteuern des Abspielmodus und mehr +- control: verschiedene Fernbedienungsfunktionen wie Menü, Play/Pause, etc. + + +SmartVISU +========= +Wenn SmartVISU als Visualisierung verwendet wird, kann folgender HTML-Code in einer der Seiten verwendet werden: + +.. code-block:: HTML + +
+
+
+

Apple TV {{ basic.print('', 'atv.wohnzimmer.name') }} ({{ basic.print('', 'atv.wohnzimmer.media_type_text') }} {{ basic.print('', 'atv.wohnzimmer.play_state_text') }})

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ basic.stateswitch('', 'atv.wohnzimmer.rc_top_menu', '', '1', 'jquery_home.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_menu', '', '1', 'control_return.svg', '') }} + + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_up', '', '1', 'control_arrow_up.svg', '') }} +
+ {{ basic.stateswitch('', 'atv.wohnzimmer.shuffle', '', '', 'audio_shuffle.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.repeat', '', [0,1,2], ['audio_repeat.svg','audio_repeat_song.svg','audio_repeat.svg'], '', ['icon0','icon1','icon1']) }} + + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_left', '', '1', 'control_arrow_left.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_select', '', '1', 'control_ok.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_right', '', '1', 'control_arrow_right.svg', '') }} +
  + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_down', '', '1', 'control_arrow_down.svg', '') }} +
 
+ {{ basic.print('', 'atv.wohnzimmer.artist') }} - {{ basic.print('', 'atv.wohnzimmer.album') }} +
+ {{ basic.print('', 'atv.wohnzimmer.title') }} ({{ basic.print('', 'atv.wohnzimmer.genre') }}) +
{{ basic.slider('', 'atv.wohnzimmer.position_percent', 0, 100, 1, 'horizontal', 'none') }}
+
+ {{ basic.stateswitch('', 'atv.wohnzimmer.rc_previous', '', '1', 'audio_rew.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_play', '', '1', 'audio_play.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_pause', '', '1', 'audio_pause.svg', '') }} + {{ basic.stateswitch('', 'atv.wohnzimmer.rc_next', '', '1', 'audio_ff.svg', '') }} +
+
+ {{ basic.print ('', 'atv.wohnzimmer.artwork_url', 'html', '\'\'') }} +
+
+
+
+ +Web Interface +============= + +Das Webinterface kann genutzt werden, um die Items und deren Werte auf einen Blick zu sehen, +die dem Plugin zugeordnet sind. Außerdem können erkannte Geräte eingesehen und gekoppelt werden. +Für jedes erkannte Gerät gibt es zudem eine Übersicht mit den aktuellen Informationen wie Status, +Abspielposition, Künstler, etc. + +.. image:: assets/webif_appletv1.png + :height: 1612px + :width: 3312px + :scale: 25% + :alt: Web Interface + :align: center diff --git a/appletv/webif/__init__.py b/appletv/webif/__init__.py new file mode 100755 index 000000000..3210aa8cb --- /dev/null +++ b/appletv/webif/__init__.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2018- Serge Wagener serge@wagener.family +######################################################################### +# This file is part of SmartHomeNG. +# +# AppleTV plugin +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import json +import pyatv +from random import randint +from time import sleep + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + self.tplenv = self.init_template_environment() + self.pinentry = False + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + # get list of items with the attribute knx_dpt + plgitems = [] + _instance = self.plugin.get_instance_name() + if _instance: + _keyword = 'appletv@{}'.format(_instance) + else: + _keyword = 'appletv' + for item in self.items.return_items(): + if _keyword in item.conf: + plgitems.append(item) + tmpl = self.tplenv.get_template('index.html') + return tmpl.render(p=self.plugin, items=sorted(plgitems, key=lambda k: str.lower(k['_path'])), pinentry=self.pinentry) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + data = {} + data['state'] = self.plugin._state + # return it as json the the web page + try: + return json.dumps(data) + except Exception as e: + self.logger.error("get_data_html exception: {}".format(e)) + #self.logger.debug(data) + return {} + + @cherrypy.expose + def button_pressed(self, button=None, pin=None): + if button == "discover": + self.logger.debug('Discover button pressed') + self.plugin._loop.create_task(self.plugin.discover()) + elif button == "start_authorization": + self.logger.debug('Start authentication') + self.pinentry = True + + _protocol = self.plugin._atv.main_service().protocol + _task = self.plugin._loop.create_task( + pyatv.pair(self.plugin._atv, _protocol, self.plugin._loop) + ) + while not _task.done(): + sleep(0.1) + self._pairing = _task.result() + if self._pairing.device_provides_pin: + self._pin = None + self.logger.info('Device provides pin') + else: + self._pin = randint(1111,9999) + self.logger.info('SHNG must provide pin: {}'.format(self._pin)) + + self.plugin._loop.create_task(self._pairing.begin()) + + elif button == "finish_authorization": + self.logger.debug('Finish authentication') + self.pinentry = False + self._pairing.pin(pin) + _task = self.plugin._loop.create_task(self._pairing.finish()) + while not _task.done(): + sleep(0.1) + if self._pairing.has_paired: + self.logger.info('Pairing successfull !') + self.plugin._credentials = self._pairing.service.credentials + self.plugin.save_credentials() + else: + self.logger.error('Unable to pair, wrong Pin ?') + self.plugin._loop.create_task(self._pairing.close()) + else: + self.logger.warning( + "Unknown button pressed in webif: {}".format(button)) + raise cherrypy.HTTPRedirect('index') diff --git a/artnet/README.md b/artnet/README.md deleted file mode 100755 index bdf79d60d..000000000 --- a/artnet/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Artnet - -## Requirements - -You need a device understanding Artnet. -I suggest to use the software OLA http://www.opendmx.net/index.php/Open_Lighting_Architecture to translate the ArtNet packets into DMX Signals. -Alternatively you can use any Art-Net to DMX Adapter. (Tested with https://www.ulrichradig.de/home/index.php/dmx/art-net-box) -OLA supports most USB -> DMX Adapters available at the moment. -For specifications of the Art-Net look at https://art-net.org.uk/resources/art-net-specification/ - -## Supported Hardware - -* Hardware supported by OLA. See Link above. - -## Configuration - -### plugin.yaml - -```yaml -artnet1: - plugin_name: artnet - artnet_universe: 0 - artnet_net: 0 - artnet_subnet: 0 - ip: 192.168.0.99 - port: 6454 - update_cycle: 120 - instance: keller -``` - -#### Attributes - * `artnet_universe`: Art-Net Universe, default: 0 - * `artnet_net`: Art-Net Net, default: 0 - * `artnet_subnet`: Art-Net Subnet, default: 0 - * `ip`: IP-address of your Art-Net node, mandatory, no default - * `port`: Port to reach your Art-Net node, defaul 6454 - * `update_cycle`: timeperiod between two update cycles, default 0 for no update. If a cycle is provided the current channel-settings is updated to Art-Net every n-th second. - * `instance`: Name of this plugin instance (e.g. above: keller) - -### items.yaml - -#### artnet_address -This attribute assigns an item to the respective artnet-address (DMX channel) - -### Example: -```yaml - lightbar: - red: - artnet_address@keller: 1 - green: - artnet_address@keller: 2 - blue: - artnet_address@keller: 3 -``` - -### logic.yaml -Notice: First DMX channel is 1! Not 0! - -To send DMX Data to the universe set in plugin.yaml you have 4 possibilities: - -#### 0) Use items - -as explained above you can use items for that - -#### 1) Send single value -``sh.artnet1(, )`` - -Sets DMX_CHAN to value DMX_VALUE. - -Example: ``sh.artnet1(12,255)`` -If channels 1-11 are already set, they will not change. -If channels 1-11 are not set till now, the will be set to value 0. -This is needed because on a DMX bus you can not set just one specific channel. -You have to begin at first channel setting your values. - -#### 2) Send a list of values starting at channel -``sh.artnet1(, )`` -Sends to DMX Bus starting at - -Example: -``sh.artnet1(10,[0,33,44,55,99])`` -If channels 1-9 are already set, they will not change. -If channels 1-9 are not set till now, the will be set to value 0. -This is needed because on a DMX bus you can not set just one specific channel. -You have to begin at first channel setting your values. -Values in square brackets will be written to channel (10-14) - -#### 3) Send a list of values - -``sh.artnet1()`` - -Sends to DMX Bus starting at channel 1 - -This is nearly the same as 2) but without starting channel. - -Example: - -``sh.artnet1([0,33,44,55,99])`` -Values in Square brackets will be written to channel (1-5) diff --git a/artnet/__init__.py b/artnet/__init__.py index 5c9792a5e..bfdfc3de3 100755 --- a/artnet/__init__.py +++ b/artnet/__init__.py @@ -29,10 +29,11 @@ from lib.model.smartplugin import * from lib.module import Modules +from .webif import WebInterface class ArtNet_Model: - def __init__(self, ip, port: int, net: int, subnet: int, universe: int, instance_name, update_cycle: int, min_channels: int): + def __init__(self, ip, port: int, net: int, subnet: int, universe: int, instance_name, update_cycle: int, min_channels: int, plugin): self._ip = ip self._port = port @@ -42,6 +43,7 @@ def __init__(self, ip, port: int, net: int, subnet: int, universe: int, instance self._instance_name = instance_name self._update_cycle = update_cycle self._min_channels = min_channels + self._plugin = plugin self._items = [] @@ -91,8 +93,8 @@ def get_items(self): :return: array of items held by the device, sorted by their DMX-address """ - return sorted(self._items, key=lambda i: self.get_iattr_value(i.conf, "artnet_address")) - + return sorted(self._items, key=lambda i: self._plugin.get_iattr_value(i.conf, "artnet_address")) + def get_min_channels(self): """ @@ -106,7 +108,7 @@ def get_min_channels(self): class ArtNet(SmartPlugin): ALLOW_MULTIINSTANCE = True - PLUGIN_VERSION = "1.6.0" + PLUGIN_VERSION = "1.6.1" ADDR_ATTR = 'artnet_address' packet_counter = 1 @@ -116,6 +118,7 @@ def __init__(self, sh, *args, **kwargs): """ Initalizes the plugin. The parameters describe for this method are pulled from the entry in plugin.conf. """ + super().__init__() self.logger.info('Init ArtNet Plugin') self._model = ArtNet_Model(ip=self.get_parameter_value('ip'), @@ -125,14 +128,15 @@ def __init__(self, sh, *args, **kwargs): universe=self.get_parameter_value('artnet_universe'), instance_name=self.get_instance_name(), update_cycle=self.get_parameter_value('update_cycle'), - min_channels=self.get_parameter_value('min_channels') + min_channels=self.get_parameter_value('min_channels'), + plugin=self ) while len(self.dmxdata) < self._model._min_channels: self.dmxdata.append(0) self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.init_webinterface() + self.init_webinterface(WebInterface) self.logger.debug("Init ArtNet Plugin for %s done" % self._model._instance_name) @@ -180,7 +184,10 @@ def run(self): for it in self._model._items: adr = int(self.get_iattr_value(it.conf, self.ADDR_ATTR)) val = it() - if val < 0 or val > 255: + if val is None: + self.logger.warning(f"Value for address {adr} is None.") + continue + elif val < 0 or val > 255: self.logger.warning( "Impossible to update address: %s to value %s from item %s, value has to be >=0 and <=255" % (adr, val, it)) else: @@ -241,7 +248,7 @@ def send_frame(self, dmxframe): def __ArtDMX_broadcast(self): """ - Assemble data according to ArtDmx packet definition, see at + Assemble data according to ArtDmx packet definition, see at https://artisticlicence.com/WebSiteMaster/User Guides/art-net.pdf """ data = [] @@ -267,10 +274,11 @@ def __ArtDMX_broadcast(self): # Length of DMX Data, High Byte First data.append(struct.pack('>H', len(self.dmxdata))) - + # DMX Data for d in self.dmxdata: - data.append(struct.pack('B', int(d))) + if d is not None: + data.append(struct.pack('B', int(d))) # convert from list to string result = bytes() @@ -288,79 +296,3 @@ def __ArtDMX_broadcast(self): self._model._subnet, self._model._universe)) self.s.sendto(result, (self._model._ip, self._model._port)) - - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - # try/except to handle running in a core version that does not support modules - self.mod_http = Modules.get_instance().get_module('http') - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error("Plugin '{}': Not initializing the web interface".format(self.get_shortname())) - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - - self.tplenv = self.init_template_environment() - - @cherrypy.expose - def index(self, reload=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - tabcount = 1 - - tmpl = self.tplenv.get_template('index.html') - return tmpl.render(plugin_shortname=self.plugin.get_shortname(), - plugin_version=self.plugin.get_version(), - plugin_info=self.plugin.get_info(), - tabcount=tabcount, - p=self.plugin) diff --git a/artnet/assets/artnet_webif.png b/artnet/assets/artnet_webif.png new file mode 100644 index 000000000..bf248c8ca Binary files /dev/null and b/artnet/assets/artnet_webif.png differ diff --git a/artnet/plugin.yaml b/artnet/plugin.yaml index f03f81074..b4483b8e5 100755 --- a/artnet/plugin.yaml +++ b/artnet/plugin.yaml @@ -11,7 +11,7 @@ plugin: keywords: dmx # documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page - version: 1.6.0 # Plugin version + version: 1.6.1 # Plugin version sh_minversion: 1.5.1 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True @@ -54,7 +54,7 @@ parameters: de: 'Gibt das Art-Net Sub-Net an' en: 'Specifies the Art-Net Sub-Net to use' valid_min: 0 - valid_max: 15 + valid_max: 15 artnet_universe: type: int default: 0 @@ -85,7 +85,7 @@ item_attributes: logic_parameters: NONE -plugin_functions: +plugin_functions: send_single_value: type: void description: diff --git a/artnet/user_doc.rst b/artnet/user_doc.rst new file mode 100644 index 000000000..3fbdce443 --- /dev/null +++ b/artnet/user_doc.rst @@ -0,0 +1,136 @@ +.. index:: Plugins; artnet +.. index:: artnet + +====== +artnet +====== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Anforderungen +============= + +Sie benötigen ein Gerät, das Artnet versteht. Es wird die Verwendung der Software +`OLA `_ empfohlen, +um die ArtNet-Pakete in DMX-Signale zu übersetzen. Alternativ kann auch +ein beliebiger Art-Net zu DMX Adapter verwendet werde. (Getestet mit `Radig Art-Net Box `_) OLA unterstützt +die meisten zur Zeit erhältlichen USB -> DMX-Adapter. Für Spezifikationen +des Art-Net ist die `Art-Net Doku `_ heranzuziehen. + +Unterstützte Hardware +===================== + +Von `OLA `_ unterstützte Hardware + +Konfiguration +============= + +.. important:: + + Detaillierte Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/artnet` zu finden. + +plugins.yaml +~~~~~~~~~~~~ + +.. code-block:: yaml + + # etc/plugin.yaml + artnet1: + plugin_name: artnet + artnet_universe: 0 + artnet_net: 0 + artnet_subnet: 0 + ip: 192.168.0.99 + port: 6454 + update_cycle: 120 + instance: keller + +items.yaml +~~~~~~~~~~ + +.. code-block:: yaml + + lightbar: + red: + artnet_address@keller: 1 # DMX Adresse + green: + artnet_address@keller: 2 # DMX Adresse + blue: + artnet_address@keller: 3 # DMX Adresse + +logic.yaml +~~~~~~~~~~ + +Hinweis: Der erste DMX-Kanal ist 1! Nicht 0! + +Um DMX-Daten an das in plugin.yaml eingestellte "Universum" zu senden, gibt es vier +Möglichkeiten: + +a) Nutzen der Item-Einträge + +siehe oben + +b) einzelnen Wert senden + +``sh.artnet1(, )`` + +Setzt DMX_CHAN auf den Wert DMX_VALUE. + +Beispiel: ``sh.artnet1(12,255)`` +Wenn die Kanäle 1-11 bereits gesetzt sind, +werden sie nicht geändert. Wenn die Kanäle 1-11 noch nicht gesetzt sind, werden sie +auf den Wert 0 gesetzt. Dies ist notwendig, weil man bei einem DMX-Bus nicht nur einen +bestimmten Kanal einstellen kann. Sie müssen mit dem ersten Kanal beginnen und die +Werte einstellen. + +c) Liste von Werten ab einem bestimmten Kanal senden + +``sh.artnet1(, )`` + +Beispiel: ``sh.artnet1(10,[0,33,44,55,99])`` +Wenn die Kanäle 1-9 bereits eingestellt sind, werden sie nicht geändert. +Wenn die Kanäle 1-9 noch nicht gesetzt sind, wird der +auf den Wert 0 gesetzt werden. Dies ist notwendig, weil man auf einem DMX-Bus nicht +nur einen bestimmten Kanal einstellen kann. Sie müssen mit dem ersten Kanal beginnen +mit der Einstellung der Werte. Die Werte in eckigen Klammern werden an die Kanäle 10-14 geschickt. + +d) Liste von Werten setzen + +``sh.artnet1()`` + +Sendet an den DMX Bus beginnend mit Kanal 1, prinzipiell äquivalent mit Variante c. + +Beispiel: ``sh.artnet1([0,33,44,55,99])`` +Die Werte in eckigen Klammern werden auf den Kanal (1-5) geschrieben + +Web Interface +============= + +Das Web Interface enthält folgende Informationen: + +- **Pfad**: Itempfad + +- **Typ**: Itemtyp + +- **Artnet-Kanal**: Nummer des Kanals + +- **Artnet-Wert**: Artnet Wert + +- **Itemwert**: Wert des Items + +- **Letztes Update**: Zeit und Datum der letzten Itemaktualisierung + +- **Letzter Change**: Zeit und Datum der letzten Itemänderung + + +.. image:: assets/artnet_webif.png + :height: 1150px + :width: 2782px + :scale: 30% + :alt: Web Interface + :align: center diff --git a/artnet/webif/__init__.py b/artnet/webif/__init__.py new file mode 100755 index 000000000..25b8162a0 --- /dev/null +++ b/artnet/webif/__init__.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import logging +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tabcount = 1 + + tmpl = self.tplenv.get_template('index.html') + pagelength = self.plugin.get_parameter_value('webif_pagelength') + return tmpl.render(plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + webif_pagelength=pagelength, + tabcount=tabcount, + p=self.plugin) diff --git a/artnet/webif/templates/index.html b/artnet/webif/templates/index.html index 5a434a427..582a3e09b 100755 --- a/artnet/webif/templates/index.html +++ b/artnet/webif/templates/index.html @@ -4,7 +4,20 @@ {% if language not in ['en','de'] %} {% set language = 'en' %} {% endif %} +{% block pluginscripts %} + +{% endblock pluginscripts %} {% block headtable %} @@ -29,12 +42,11 @@
{% endblock %} + {% block bodytab1 %} -
-
- +
- + @@ -51,7 +63,7 @@ {% else %} {% set instance_key = "artnet_address" %} {% endif %} - + @@ -63,6 +75,4 @@ {% endfor %}
{{ _('Pfad') }} {{ _('Typ') }} {{ _('Artnet-Kanal') }}
{{ item.id() }} {{ item.type() }} {{ item.conf[instance_key] }}
-
-
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/asterisk/README.md b/asterisk/README.md deleted file mode 100755 index 199a098aa..000000000 --- a/asterisk/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# Asterisk - -## Requirements - -A running asterisk daemon with a configured Asterisk Manager Interface (AMI) is necessary. -In manager.config its required to enable at least: -``read = system,call,user,cdr`` and ``write = system,call,orginate`` - -## Configuration - -### plugin.yaml - -The plugin needs the username and password of the AMI and a IP and port address if asterisk does not run on localhost. - -```yaml -ast: - plugin_name: asterisk - username: admin - password: secret - host: 127.0.0.1 # default - port: 5038 # default - -``` - -### items.yaml - -#### ast_dev - -It is possible to specify the ``ast_dev`` attribute to an bool item in items.yaml. -The argument could be a number or string and correspond to the asterisk device configuration. -E.g. ``2222`` for the following device in asterisk ``sip.conf``: - -``` -[2222] -secret=very -context=internal -``` - -#### ast_box -The mailbox number of this phone. It will be set to the number of new messages in this mailbox. - -#### ast_db -Specify the database entry which will be updated at an item change. - -In items.yaml: - -```yaml -office: - - fon: - type: bool - ast_dev: 2222 - ast_db: active/office - - box: - type: num - ast_box: 22 -``` - -Calling the '2222' from sip client or making a call from it, item ``office.fon`` will be set to True. -After finishing the call, it will be set to False. - - -### logic.yaml - -It is possible to specify the `ast_userevent` keyword to every logic in logic.yaml. - -``` -logic1: - ast_userevent: Call - -logic2: - ast_userevent: Action -``` - -In the asterisk extensions.conf ``exten => _X.,n,UserEvent(Call,Source: ${CALLERID(num)},Value: ${CALLERID(name)})`` would trigger 'logic1' every time, this UserEvent is sent. - -A specified destination for the logic will be triggered e.g. ``exten => _X.,n,UserEvent(Call,Source: ${CALLERID(num)},Destination: Office,Value: ${CALLERID(name)})`` - - -### Functions - - -#### call(source, dest, context, callerid=None) - -`sh.ast.call('SIP/200', '240', 'door')` would initate a call from the SIP extention '200' to the extention '240' with the 'door' context. Optional a callerid for the call is usable. - -#### db_write(key, value) - -``sh.ast.db_write('dnd/office', 1)`` would set the asterisk db entry ``dnd/office`` to ``1``. - -#### db_read(key) - -``dnd = sh.ast.db_read('dnd/office')`` would set ``dnd`` to the value of the asterisk db entry ``dnd/office``. - -#### mailbox_count(mailbox, context='default') - -``mbc = sh.ast.mailbox_count('2222')`` would set ``mbc`` to a tuple ``(old_messages, new_messages)``. - -#### hangup(device) - -``sh.ast.hangup('30')`` would close all connections from or to the device ``30``. diff --git a/asterisk/__init__.py b/asterisk/__init__.py index a7425724e..b415b3427 100755 --- a/asterisk/__init__.py +++ b/asterisk/__init__.py @@ -31,7 +31,7 @@ class Asterisk(SmartPlugin): - PLUGIN_VERSION = "1.4.0" + PLUGIN_VERSION = "1.4.2" DB = 'ast_db' DEV = 'ast_dev' @@ -77,6 +77,92 @@ def __init__(self, sh): self._trigger_logics = {} self._log_in = lib.log.Log(self.get_sh(), 'env.asterisk.log.in', ['start', 'name', 'number', 'duration', 'direction']) + def run(self): + """ + Run method for the plugin + """ + self.logger.debug("Run method called") + if self._client.connect(): + self.alive = True + else: + self.logger.error(f'Connection to {self.host}:{self.port} not possible, plugin not starting') + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.debug("Stop method called") + if self._client.connected(): + self._client.close() + self.alive = False + self._reply_lock.acquire() + self._reply_lock.notify() + self._reply_lock.release() + + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + The plugin can, corresponding to its attribute keywords, decide what to do with + the item in future, like adding it to an internal array for future reference + :param item: The item to process. + :return: If the plugin needs to be informed of an items change you should return a call back function + like the function update_item down below. An example when this is needed is the knx plugin + where parse_item returns the update_item function when the attribute knx_send is found. + This means that when the items value is about to be updated, the call back function is called + with the item, caller, source and dest as arguments and in case of the knx plugin the value + can be sent to the knx with a knx write function within the knx plugin. + """ + if self.has_iattr(item.conf, Asterisk.DEV): + self._devices[self.get_iattr_value(item.conf, Asterisk.DEV)] = item + if self.has_iattr(item.conf, Asterisk.BOX): + self._mailboxes[self.get_iattr_value(item.conf, Asterisk.BOX)] = item + if self.has_iattr(item.conf, Asterisk.DB): + return self.update_item + + + def parse_logic(self, logic): + """ + Default plugin parse_logic method + """ + if Asterisk.USEREVENT in logic.conf: + event = logic.conf[Asterisk.USEREVENT] + if event not in self._trigger_logics: + self._trigger_logics[event] = [logic] + else: + self._trigger_logics[event].append(logic) + + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Item has been updated + + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that + is managed by this plugin. + + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + if self.alive and caller != self.get_shortname(): + self.logger.debug("Update item: {}, item has been changed outside this plugin".format(item.id())) + if self.has_iattr(item.conf, Asterisk.DB): + value = item() + if isinstance(value, bool): + value = int(item()) + self.db_write(self.get_iattr_value(item.conf, Asterisk.DB), value) + + + def handle_connect(self, client): + self._command(self._init_cmd, reply=False) + for mb in self._mailboxes: + mbc = self.mailbox_count(mb) + if mbc is not None: + self._mailboxes[mb](mbc[1]) + + def _command(self, d, reply=True): """ This function sends a command to the Asterisk Server @@ -150,8 +236,28 @@ def hangup(self, hang): if device == hang: self._command({'Action': 'Hangup', 'Channel': channel}, reply=False) - def found_terminator(self, data): - data = data.decode() + def found_terminator(self, client, data): + """called then terminator is found + + :param client: tcp client which is used for the connection + :param str data: the response from asterisk server + :return : None + """ + if isinstance(data, bytes): + data = data.decode() + + """ + the response will be normally like ``key: value`` + exception is start of communication which presents e.g. + ``` + Asterisk Call Manager/5.0.4 + Response: Success + Message: Authentication accepted + ``` + The following code puts everything into the dict ``event`` + It only implements a very basic inspection of the response + """ + self.logger.debug(f"data to inspect: {data}") event = {} for line in data.splitlines(): key, sep, value = line.partition(': ') @@ -246,84 +352,3 @@ def _get_device(self, channel): channel, s, d = channel.rpartition('-') a, b, channel = channel.partition('/') return channel - - def parse_item(self, item): - """ - Default plugin parse_item method. Is called when the plugin is initialized. - The plugin can, corresponding to its attribute keywords, decide what to do with - the item in future, like adding it to an internal array for future reference - :param item: The item to process. - :return: If the plugin needs to be informed of an items change you should return a call back function - like the function update_item down below. An example when this is needed is the knx plugin - where parse_item returns the update_item function when the attribute knx_send is found. - This means that when the items value is about to be updated, the call back function is called - with the item, caller, source and dest as arguments and in case of the knx plugin the value - can be sent to the knx with a knx write function within the knx plugin. - """ - if self.has_iattr(item.conf, Asterisk.DEV): - self._devices[self.get_iattr_value(item.conf, Asterisk.DEV)] = item - if self.has_iattr(item.conf, Asterisk.BOX): - self._mailboxes[self.get_iattr_value(item.conf, Asterisk.BOX)] = item - if self.has_iattr(item.conf, Asterisk.DB): - return self.update_item - - def update_item(self, item, caller=None, source=None, dest=None): - """ - Item has been updated - - This method is called, if the value of an item has been updated by SmartHomeNG. - It should write the changed value out to the device (hardware/interface) that - is managed by this plugin. - - :param item: item to be updated towards the plugin - :param caller: if given it represents the callers name - :param source: if given it represents the source - :param dest: if given it represents the dest - """ - if self.alive and caller != self.get_shortname(): - self.logger.debug("Update item: {}, item has been changed outside this plugin".format(item.id())) - if self.has_iattr(item.conf, Asterisk.DB): - value = item() - if isinstance(value, bool): - value = int(item()) - self.db_write(self.get_iattr_value(item.conf, Asterisk.DB), value) - - def parse_logic(self, logic): - """ - Default plugin parse_logic method - """ - if Asterisk.USEREVENT in logic.conf: - event = logic.conf[Asterisk.USEREVENT] - if event not in self._trigger_logics: - self._trigger_logics[event] = [logic] - else: - self._trigger_logics[event].append(logic) - - def run(self): - """ - Run method for the plugin - """ - self.logger.debug("Run method called") - if self._client.connect(): - self.alive = True - else: - self.logger.error(f'Connection to {self.host}:{self.port} not possible, plugin not starting') - - def handle_connect(self): - self._command(self._init_cmd, reply=False) - for mb in self._mailboxes: - mbc = self.mailbox_count(mb) - if mbc is not None: - self._mailboxes[mb](mbc[1]) - - def stop(self): - """ - Stop method for the plugin - """ - self.logger.debug("Stop method called") - if self._client.connected(): - self._client.close() - self.alive = False - self._reply_lock.acquire() - self._reply_lock.notify() - self._reply_lock.release() diff --git a/asterisk/plugin.yaml b/asterisk/plugin.yaml index 18d1020ed..7a8772e9f 100755 --- a/asterisk/plugin.yaml +++ b/asterisk/plugin.yaml @@ -5,13 +5,13 @@ plugin: description: de: 'Ansteuerung einer Asterisk Telefonanlage' en: 'Control of an Asterisk PBX' - maintainer: 'Nobody' + maintainer: 'bmxp' tester: 'Nobody' # Who tests this plugin? keywords: PBX Asterisk VOIP ISDN state: ready documentation: https://www.smarthomeng.de/user/plugins/asterisk/README.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/ - version: 1.4.0 # Plugin version + version: 1.4.2 # Plugin version sh_minversion: 1.9.0 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) # py_minversion: 3.6 # minimum Python version to use for this plugin diff --git a/asterisk/user_doc.rst b/asterisk/user_doc.rst new file mode 100644 index 000000000..cde4ecd90 --- /dev/null +++ b/asterisk/user_doc.rst @@ -0,0 +1,182 @@ +.. index:: Plugins; asterisk +.. index:: asterisk + + +======== +asterisk +======== + + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Ansteuerung einer **Asterisk** Telefonanlage + + +Anforderungen +============= + +... + +Notwendige Software +------------------- + +Für den Betrieb wird vorausgesetzt das eine Version von Asterisk installiert ist und der entsprechende Daemon läuft. +Es ist ebenfalls notwendig, das das Asterisk Manager Interface (AMI) installiert und funktionell ist. +In der ``manager.config`` muss mindestens +``read = system,call,user,cdr`` und ``write = system,call,orginate`` +eingerichtet sein. + +Unterstützte Geräte +------------------- + + + + +Konfiguration +============= + +Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/asterisk` beschrieben. + + +plugin.yaml +----------- + +Zu den Informationen, welche Parameter in der ../etc/plugin.yaml konfiguriert werden können bzw. müssen, bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + + + +items.yaml +---------- + +Zu den Informationen, welche Attribute in der Item Konfiguration verwendet werden können bzw. müssen, bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +.. code:: yaml + + buero: + telefon: + type: bool + ast_dev: 2222 + ast_db: active/buero + + box: + type: num + ast_box: 22 + + +Bei einem Anruf bei ``2222`` von einem SIP client oder +bei Anruf von dem entsprechenden Gerät wird das Item ``buero.telefon`` auf ``True`` gesetzt. +Wird der Anruf beendet, so wird das Item ``buero.telefon`` auf ``False`` gesetzt. + +ast_dev +~~~~~~~ + +Dieses keyword kann bei einem Item mit Typ Bool verwendet werden. +Der Parameter für dieses keyword ist ein String, der identisch zu einer Gerätebenennung in der sip.conf ist. +Im unteren Beispiel also ``device22``: + +.. code:: ini + + [device22] + secret=very + context=internal + +ast_db +~~~~~~ + +Gibt den Databank Eintrag an der bei einer Item Änderung aktualisiert wird + +ast_box +~~~~~~~ + +Hier wird die Mailbox Nummer des Telefones eingegeben. Die Anzahl der neuen Nachrichten wird dann an dieses Item übermittelt +logic.yaml +---------- + +Zu den Informationen, welche Konfigurationsmöglichkeiten für Logiken bestehen, bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +Beispiel für eine Logik: + +..code:: yaml + + logic1: + ast_userevent: Call + + logic2: + ast_userevent: Action + +Für jede Logik kann in der ``logic.yaml`` das keyword ``ast_userevent`` angegeben werden. + +In der Asterisk ``extensions.conf`` muss dann angegeben werden +``exten => _X.,n,UserEvent(Call,Source: ${CALLERID(num)},Value: ${CALLERID(name)})`` + +Damit würde die ``logic1`` jedesmal getriggert, wenn das UserEvent geschickt wird. + +A specified destination for the logic will be triggered e.g. +``exten => _X.,n,UserEvent(Call,Source: ${CALLERID(num)},Destination: Office,Value: ${CALLERID(name)})`` + + +Funktionen +---------- + +Zu den Informationen, welche Funktionen das Plugin bereitstellt (z.B. zur Nutzung in Logiken), bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +call(source, dest, context, callerid=None) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``sh.ast.call('SIP/200', '240', 'door')`` +würde einen Anruf von der SIP extension ``200`` zur extention ``240`` starten mit dem ``door`` context. +Optional kann eine callerid mit angegeben werden + + +db_write(key, value) +~~~~~~~~~~~~~~~~~~~~ + +``sh.ast.db_write('dnd/office', 1)`` würde den Asterisk Datenbank Eintrag ``dnd/office`` auf ``1`` setzen + +db_read(key) +~~~~~~~~~~~~ + +``dnd = sh.ast.db_read('dnd/office')`` würde ``dnd`` auf den Wert des Asterisk Datenbank Eintrages ``dnd/office`` setzen. + +mailbox_count(mailbox, context='default') +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``mbc = sh.ast.mailbox_count('2222')`` würde ``mbc`` auf ein Wertetupel ``(old_messages, new_messages)`` setzen. + +hangup(device) +~~~~~~~~~~~~~~ + +``sh.ast.hangup('30')`` would close all connections from or to the device ``30``. + + +.. todo ist noch die Implementation des + Web Interface + ============= + + Das Plugin hat aktuell kein Webinterface + + Tab 1: + ---------------------- + + + + .. image:: assets/webif_tab1.jpg + :class: screenshot + + +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This file implements the web interface for the Sample plugin. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after being rendered + """ + pagelength = self.plugin.get_parameter_value('webif_pagelength') + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=0) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + # if dataSets are used, define them here + if dataSet == 'overview': + # get the new data from the plugin variable called _webdata + data = self.plugin._webdata + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/asterisk/webif/static/img/plugin_logo.svg b/asterisk/webif/static/img/plugin_logo.svg new file mode 100644 index 000000000..0ad939fa0 --- /dev/null +++ b/asterisk/webif/static/img/plugin_logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/memlog/webif/static/img/readme.txt b/asterisk/webif/static/img/readme.txt old mode 100755 new mode 100644 similarity index 100% rename from memlog/webif/static/img/readme.txt rename to asterisk/webif/static/img/readme.txt diff --git a/asterisk/webif/templates/index.html b/asterisk/webif/templates/index.html new file mode 100644 index 000000000..29d022b1f --- /dev/null +++ b/asterisk/webif/templates/index.html @@ -0,0 +1,262 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 0 %} + +{% set update_interval = (200 * (log_array | length)) %} + + +{% set dataSet = 'devices_info' %} + + +{% set update_params = item_id %} + + +{% set buttons = false %} + + +{% set autorefresh_buttons = false %} + + +{% set reload_button = false %} + + +{% set close_button = false %} + + +{% set row_count = true %} + + +{% set initial_update = true %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Prompt 1{% if 1 == 2 %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}Prompt 4{{ _('Wert 4') }}
Prompt 2{{ _('Wert 2') }}Prompt 5-
Prompt 3-Prompt 6-
+{% endblock headtable %} + + + +{% block buttons %} +{% if 1==2 %} +
+ + +
+{% endif %} +{% endblock %} + + +{% set tabcount = 4 %} + + + +{% if item_count==0 %} + {% set start_tab = 2 %} +{% endif %} + + + +{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} +{% block bodytab1 %} + +
+ {{ _('Hier kommen bei Bedarf Informationen des Webinterfaces oberhalb der Tabelle hin.') }} (optional) +
+ + + + + + + + + + + + + {% for item in items %} + {% if p.has_iattr(item.conf, '') %} + + + + + + + {% endif %} + {% endfor %} + +
{{ _('Item') }}{{ _('Wert') }}
{{ item._path }}{{ item() }}
+ +
+ Etwaige Informationen unterhalb der Tabelle (optional) +
+ +{% endblock bodytab1 %} + + + +{% set tab2title = "" ~ p.get_shortname() ~ " Geräte (" ~ device_count ~ ")" %} +{% block bodytab2 %} +{% endblock bodytab2 %} + + + +{% block bodytab3 %} +{% endblock bodytab3 %} + + + +{% block bodytab4 %} +{% endblock bodytab4 %} diff --git a/avdevice/README.md b/avdevice/README.md deleted file mode 100755 index f36e753dd..000000000 --- a/avdevice/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# AV Device - -## Requirements -If you want to connect to your device via RS232 (recommended) you need to install: -Serial Python module - -Install it with: -sudo pip3 install serial --upgrade - -## Supported Hardware - -Hopefully several different AV devices based on TCP or Serial RS232 connections -Tested with Pioneer (< 2016 models) and Denon AV receivers, Epson projector Oppo Bluray player - -## Changelog -### v1.6.3 -* updated webinterface implementation, realtime values, fixes - -### v1.6.2 -* implement lineending send and response parameters -* fix struct visu_acl -* show item path for dependson item instead of name -* fix handling of negative number values - -### v1.6.1 -* use property.path instead of id() -* fix folder for command files reading - -### v1.6.0 -* Implement struct features -* fixed dependency check - -### v1.5.0 -* Minor code re-write using smartplugin methods and logging -* added config file for Denon AVR1100 -* fixed Denon example -* added Web Interface -* some bug fixes - -### v1.3.6 -* Major code re-write using multiple modules and classes, minimizing complexity -* Extended "translate" functionality with wildcards -* Implemented optional waiting time between multiple commands -* Improved Keep Command handling -* Several bug fixes and tests - -### v1.3.5 -* Implemented possibility to "translate" values -* Improved Wildcard handling -* Improved code -* Added Oppo support -* Improved response and queue handling - -### v1.3.4 -* Tested full Denon support -* Implemented Dependencies -* Implemented rudimentary Wildcard handling -* Implemented Initialization commands -* Improved Queue handling and CPU usage -* Bug fixes - -### v1.3.3 -* Added Denon support -* Added option to provide min-value in config file -* Improved response handling -* Implemented possibility to reload config files -* Improved verbose logging -* Bug fixes - -### v1.3.2 -* Added and tested full Denon support diff --git a/avdevice/__init__.py b/avdevice/__init__.py index 0b7d30c99..e178f5872 100755 --- a/avdevice/__init__.py +++ b/avdevice/__init__.py @@ -1201,7 +1201,7 @@ def _checkdependency(self, dep_function, dep_type): expectedvalue = eval(expectedvalue.lstrip('0')) except Exception: pass - if type(dependvalue) == type(expectedvalue): + if type(dependvalue) == type(expectedvalue) or (isinstance(dependvalue, (int,float)) and isinstance(expectedvalue, (int,float))): groupcount[group] += 1 if (dependvalue == expectedvalue and compare == '==') or \ (dependvalue >= expectedvalue and compare == '>=') or \ (dependvalue <= expectedvalue and compare == '<=') or \ @@ -1256,7 +1256,7 @@ def _checkdependency(self, dep_function, dep_type): self.logger.log(VERBOSE2, "Checking Dependency {}: Expectedvalue after Translation {}. Dependitem: {}, expected {}".format( self._name, expectedvalue, dependitem, expectedvalue)) - if type(dependvalue) == type(expectedvalue): + if type(dependvalue) == type(expectedvalue) or (isinstance(dependvalue, (int,float)) and isinstance(expectedvalue, (int,float))): groupcount[group] += 1 if (dependvalue == expectedvalue and compare == '==') or \ (dependvalue >= expectedvalue and compare == '>=') or \ (dependvalue <= expectedvalue and compare == '<=') or \ diff --git a/avdevice/plugin.yaml b/avdevice/plugin.yaml index 8afaf1461..29e05e7a0 100755 --- a/avdevice/plugin.yaml +++ b/avdevice/plugin.yaml @@ -3,8 +3,8 @@ plugin: # Global plugin attributes type: interface # plugin type (gateway, interface, protocol, system, web) description: - de: 'Steuerung eines AV Gerätes über TCP/IP oder RS232 Schnittstelle' - en: 'Controlling AV devices via TCP/IP or RS232' + de: 'Steuerung eines AV Gerätes über TCP/IP oder RS232 Schnittstelle. Ersetzt durch verschiedene SmartDevice Plugins.' + en: 'Controlling AV devices via TCP/IP or RS232. Replaced by multiple SmartDevice plugins.' description_long: de: 'Steuerung eines AV Gerätes über TCP/IP oder RS232 Schnittstelle. Das Plugin unterstützt eine Vielzahl von AV-Geräten und wurde mit folgenden Geräten getestet: @@ -12,6 +12,7 @@ plugin: - Denon AV Receiver > 2016 - Epson Projektor < 2010 - Oppo UHD Player + Zu diesen Geräten gibt es eigene neue Plugins, die auf dem SmartDevice Framework aufbauen. ' en: 'Controlling AV devices via TCP/IP or RS232 The plugin supports a variety of AV devices and was tested with the following models: @@ -19,13 +20,14 @@ plugin: - Denon AV Receiver > 2016 - Epson Projektor < 2010 - Oppo UHD Player + For these devices there are newer separate plugins based on the SmartDevice framework. ' requirements: de: 'pyserial Python Modul' en: 'pyserial python module' maintainer: onkelandy tester: Foxi352 # Who tests this plugin? - state: ready + state: deprecated keywords: av denon pioneer epson oppo player amp receiver projector rs232 telnet tcpip remote control support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1097870-neues-plugin-av-device-f%C3%BCr-yamaha-pioneer-denon-etc diff --git a/avdevice/user_doc_en.rst b/avdevice/user_doc_en.rst deleted file mode 100755 index 922e79049..000000000 --- a/avdevice/user_doc_en.rst +++ /dev/null @@ -1,536 +0,0 @@ -.. index:: Plugins; avdevice -.. index:: avdevice - -avdevice -######## - -Configuration -============= - -.. important:: - - You can find the configuration information in the user documentation. - -.. code-block:: yaml - - # etc/plugin.yaml - avdevice: - class_name: AVDevice - class_path: plugins.avdevice - model: sc-lx86 - #instance: pioneer_one - tcp_ip: 10.0.0.130 - #tcp_port: 23 - #tcp_timeout: 1 - rs232_port: /dev/ttyUSB1 - #rs232_baudrate: 9600 - #rs232_timeout: 0.1 - #ignoreresponse: 'RGB,RGC,RGD,GBH,GHH,VTA,AUA,AUB' - #forcebuffer: 'GEH01020, GEH04022, GEH05024' - #inputignoredisplay: '' - #dependson_item: '' - #dependson_value: True - #errorresponse: E02, E04, E06 - #resetonerror: False - #depend0_power0: False - #depend0_volume0: False - #sendretries: 10 - #resendwait: 1.0 - #reconnectretries: 13 - #reconnectcycle: 10 - #secondstokeep: 50 - #responsebuffer: 5 - #autoreconnect: false - #update_exclude: '' - -Items -===== - -avdevice_zone[0-4]@[instance]: [function] ------------------------------------------ - -Specifiy the zone number and instance. If you don’t use zones you can -either use ``avdevice`` or ``avdevice_zone0`` as attributes. - -The command has to correspond to a command in the relevant text -configuration file in the avdevice plugin folder named the same as the -``model`` configured in plugin.yaml. It is important to set the correct -type for each item. The Pioneer RS232 codeset expects bool and int types -only. For example to set the listening mode to "pure direct", the item -has to be int and you set it to the value "8". If you want to use the -``translation-feature`` you should set the item to ``foo``. This feature is -explained later. - -Full item examples are included as separate yaml files for Pioneer and -Denon devices. The examples include the tested items/commands and -allow easy copy/paste. - -Special attribute values (statusupdate and reload) are described in the user documentation. - - -avdevice_zone[0-4]_speakers@[instance]: [function] --------------------------------------------------- - -Specifiy the zone number and instance. This attribute is used to switch between -Speaker A, B and AB layout. Speakers Items are special and -should be set up the way mentioned in the following example. 1 and 2 -correspond to the value the speaker command expects (for example for -Pioneer receivers < 2016). - -.. code-block:: yaml - - # items/my.yaml - Pioneer: - type: foo - - Speakers: - type: num - visu_acl: rw - avdevice_zone1: speakers - - SpeakerA: - type: bool - visu_acl: rw - avdevice_zone1_speakers: 1 - - SpeakerB: - type: bool - visu_acl: rw - avdevice_zone1_speakers: 2 - - -avdevice_zone[0-4]_depend@[instance]: [function] ------------------------------------------------- - -Specifiy the zone number and instance. The depend attribute lets you -specifiy for each item if it depends on another item/function. If you -define such a dependency several things will happen: - -- The item only gets updated/changed if the dependency is fullfilled - -- Query command of the item will get removed from the queue if the dependency is not fullfilled - -- Query command of the item will (only) get added if one of the master items gets changed and the dependency is fullfilled. - -- After connecting to the device the query command of an item only gets added if you add ``init`` to the dependency configuration. - -You can use multiple depend items and attributes even for different -zones. You can even define ``and/or`` for the dependencies by adding up to -four different groups (a, b, c, d) after the value seperated by a comma -``,``. - -You can not only define a ``master item`` but also a ``master value`` and -several standard python comparison operators. - -If you don’t set an operator and value, ``==`` and ``True`` is assumed. If -you don’t set a group, group ``a`` is assumed. This means, if you add -several dependent function without a group, the functions will get -evaluated as ``or`` and dependency is fullfilled as soon as one of the -functions/items corresponds to the given value. - -The example below shows the following dependencies: - -- The disctype will always be queried after connecting to the device (as long as you have specified a query command in the command-file) - -- Audio language and encoding will be queried after connecting to the device or as so on as the item with the ``play`` function (Oppo.Play) is True - -- The track will get updated/queried if these dependencies are fullfilled: (play is True or status is play) AND verbose is set to 2 AND audiotype is either PCM or PCM 44.1/16 - -- The trackname will get updated/queried if these dependencies are fullfilled: (play is True or status is play) AND verbose is set to 2 AND audiotype is either PCM or PCM 44.1/16 AND disctpye is one of these three values: DVD-AUDIO, CDDA, DATA-DISC - - -.. code-block:: yaml - - # items/my.yaml - Oppo: - type: foo - - Power: - visu_acl: rw - type: bool - avdevice@oppo: power - - Verbose: - visu_acl: rw - type: num - cache: 'false' - enforce_updates: 'yes' - avdevice@oppo: verbose - - Status: - visu_acl: rw - type: str - cache: 'False' - enforce_updates: 'yes' - avdevice@oppo: status - on_change: - - ..Pause = True if value == 'PAUSE' else False - - ..Stop = True if not (value == 'PLAY' or value == 'PAUSE' or value == 'INVALID') else False - - ..Play = True if value == 'PLAY' else False - - Play: - visu_acl: rw - type: bool - enforce_updates: 'yes' - avdevice@oppo: play - - Disctype: - visu_acl: rw - type: str - cache: 'False' - enforce_updates: 'yes' - avdevice@oppo: disctype - avdevice_depend@oppo: init - - Audio: - type: foo - - Language: - visu_acl: rw - type: str - cache: 'False' - enforce_updates: 'yes' - avdevice@oppo: audiolanguage - avdevice_depend@oppo: - - play - - init - - Encoding: - visu_acl: rw - type: str - cache: 'False' - enforce_updates: 'yes' - avdevice@oppo: audiotype - avdevice_depend@oppo: - - play - - init - - Track: - visu_acl: rw - type: num - cache: 'False' - enforce_updates: 'yes' - avdevice@oppo: audiotrack - avdevice_depend@oppo: - - play = True, a - - status = PLAY, a - - verbose = 2, b - - audiotype = PCM, c - - audiotype = PCM 44.1/16, c - - Trackname: - visu_acl: rw - type: str - avdevice@oppo: trackname - avdevice_depend@oppo: - - disctype = DVD-AUDIO, a - - disctype = CDDA, a - - disctype = DATA-DISC, a - - play = True, b - - status = PLAY, b - - audiotype = PCM, c - - audiotype = PCM 44.1/16, c - - verbose = 2, d - -avdevice_zone[0-4]_init@[instance]: [function] ----------------------------------------------- - -Specifiy the zone number and instance. -The init attribute lets you set a specific command to a specific value as soon as the device is connected. For example if you want to always set the verbose level to 2 as soon as the plugin connects to it (at startup and after turning on the power socket or reconnecting the cable) you can define an additional item with the attribute "avdevice_init". The value of that item (Oppo.Verbose.Init) gets appended to the command linked to the verbose item (Oppo.Verbose). - -You can use multiple init items and attributes even for different zones. - -.. code-block:: yaml - - # items/my.yaml - Oppo: - type: foo - Verbose: - type: bool - visu_acl: rw - avdevice_zone1: verbose - - Init: - visu_acl: rw - type: bool - cache: 'true' - value: 2 - avdevice_zone1_init: verbose - - Pioneer: - type: foo - - Zone1: - type: foo - - Mute: - type: bool - visu_acl: rw - avdevice_zone1: mute - - Init: - visu_acl: rw - type: bool - cache: 'true' - value: True - avdevice_zone1_init: mute - - Zone2: - type: foo - - Mute: - type: bool - visu_acl: rw - avdevice_zone2: mute - - Init: - visu_acl: rw - type: bool - cache: 'true' - value: True - avdevice_zone2_init: mute - - -Commands -======== - -Configure your commands depending on your model and manufacturer. You -have to name the file the same as configured in the plugin.yaml as -“model”. E.g. if you’ve configured ``model: vsx-923`` you name the file -``vsx-923.txt`` - -Each line holds one specific command that should be sent to the device. -You also specify the zone, the query command, response command, etc. You -can comment out lines by placing a ``#`` in front of the line. You can also -comment a whole block by using ``’’’`` at the beginning and end of a block. - -- ``zone``: Number of zone. Has to correspond to the attribute in - item.yaml. E.g. for zone 1 use “avdevice_zone1: command”. Zone 0 - holds special commands like navigating in the menu, display reponse, - information about currently playing songs, etc. - -- ``function``: name of the function. You can name it whatever you - like. You reference this value in the item using avdevice_zoneX: - function. - -- ``functiontype``: for boolean functions use “on” or “off”. For - commands setting a specific value like source, input mode, volume, - etc. use “set”. To increase or decrease a value use the corresponding - “increase” or “decrease”. For everything else leave empty! - -- ``send``: the command to be sent, e.g. power off is “PF” for Pioneer - receivers. You can use a pipe “\|” if more than one command should be - sent. Add an integer or float to specify a pause in seconds between - the commands, like “PO\|2\|PO”. That might be necessary for power on - commands via RS232, e.g. for Pioneer receivers to power on “PO|PO” - forces the plugin to send the “PO” command twice. Use stars “\*” to - specify the format of the value to be sent. Let’s say your device - expects the value for volume as 3 digits, a “\*\*\*VL” ensures that - even setting the volume to “5” sends the command as “005VL” - -- ``query``: Query command. This is usually useful after setting up the - connection or turning on the power. This command gets also used if - the plugin doesn’t receive the correct answer after sending a - command. It is recommended to leave this value empty for all - functions except on, off and set. - -- ``response``: The expected response after sending a command. Use - “none” if you don’t want to wait for the correct response. Use “\*” the same way - as with the send command. You can even specify multiple response - possibilities separated by “\|”. - -- ``readwrite``: R for read only, W for write only, RW for Read and - Write. E.g. display values are read only whereas turning the volume - up might be a write operation only. Setting this correctly ensures a - fast and reliable plugin operation - -- ``invertresponse``: some devices are stupid enough to reply with a - “0” for “on” and “1” for “off”. E.g. a Pioneer receiver responds with - “PWR0” if the device is turned on. Configure with “yes” if your - device is quite stupid, too. - -- ``minvalue``: You can define the minimum value for setting a specific - function. This might be most relevant for setting the volume or - bass/trebble values. If you configure this with “-3” and set the bass - to “-5” (via Visu or CLI) the value will get clamped by the plugin - and set to “-3”. - -- ``maxvalue``: You can define the maximum value for setting a specific - function. This might be most relevant for setting the volume. If you - configure this with “100” and set the volume to “240” (via Visu or - CLI) the value will get clamped by the plugin and set to “100”. - -- ``responsetype``: Defines the type of the response value and can be - set to “bool”, “num” or “str” or a mixture of them (separated by a - pipe “\|” or comma “,”). Most response types are set automatically on - startup but you can force a specific type using this value. It is - recommended to use the values suggested in the txt files that come - with the plugin. - -- ``translationfile``: If you want to translate a specific value/code - to something else, define the name of a txt file in the translation folder - here that holds the information on how to translate which value. This feature - is described later in more detail. - -.. code-block:: none - - # plugins/avdevice/pioneer.txt - ZONE; FUNCTION; FUNCTIONTYPE; SEND; QUERY; RESPONSE; READWRITE; INVERTRESPONSE; MINVALUE; MAXVALUE; RESPONSETYPE; TRANSLATIONFILE - 1; power; on; PO|PO; ?P; PWR*; RW; yes - 1; power; off; PF; ?P; PWR*; RW; yes - 1; volume+; increase; VU; ; VOL; W - 1; volume-; decrease; VD; ; VOL; W - 1; volume; set; ***VL; ?V; VOL***; RW; ; 80; 185 - 1; input; set; **FN; ?F; FN**; RW - 1; speakers; set; *SPK; ?SPK; SPK*; RW - ''' - #commented out from here - 2; power; on; APO|APO; ?AP; APR*; RW; yes - 2; power; off; APF; ?AP; APR*; RW; yes - 0; title; ; ; ; GEH01020; R - 0; station; ; ; ; GEH04022; R - 0; genre; ; ; ; GEH05024; R - #commented out until here - ''' - 0; display; ; ?FL; ?FL; FL******************************; R - 1; input; set; **FN; ?F; FN**; RW; ; ; ; ; pioneer_input - 1; mode; set; ****SR; ?S; SR****; RW; ; ; ; num; pioneer_SR - 1; playingmode; ; ?L; ?L; LM****; R; ; ; ; str,int; pioneer_LM - #0; test; ; ; ; noidea; R (commented out) - - -Struct Templates -================ - -Since smarthomeNG 1.6 you can use templates provided by the plugin: - -- general: Display, menu, cursor, statusupdate, reload config, etc. -- speaker_selection: speaker A, B or both -- individual_volume: set the volume of each speaker individually -- sound_settings: listening Mode, bass, trebble, dynamic compression, etc. -- video_settings: aspect Ratio, monitorout, etc. -- zone1, zone2, zone3: several relevant functions like source, volume, etc. - -The templates might include too many items or items your device does not support. As long as there is no command in the models/model.txt file, the items are just ignored. So no problem! - - -Translations -============ - -You could create a file called denon_volume.txt and link it -in your model.txt file to convert 3 digit volume to a float. Denon -receivers handle e.g. 50.5 as 505. If you want to use value limits or -visualize the volume correctly in your VISU you should use the following -translation file: - -.. code-block:: none - - # plugins/avdevice/denon_volume.txt - CODE; TRANSLATION - ***; **.* - -Pioneer receivers use numbers to define input source or listening mode -what is very cryptic and not very user friendly. Therefore you should -use the relevant files in the plugins folder like pioneer_input. That -file looks something like this: - -.. code-block:: none - - # plugins/avdevice/pioneer_input.txt - CODE; TRANSLATION - 00; PHONO - 01; CD - 02; TUNER - -Now, when the plugin receives FN01 as a response, the response gets -converted to “CD”. Vice versa you can even update your item to “CD” and -the plugin will send “01FN” as a command. It is advised to define the -according item as ``type: foo`` so you can either use a number or string, -just the way you like. - - -Wildcards -========= - -For the model.txt file you can use question marks as a wild card if the -response of the device includes information for several different items. -This is the case with a lot of responses from Oppo bluray players. - -Use a “?” for “any single character”, use “??” for “two characters of -any value” and so on. If the length of the wildcard can differ, use a -“?{str}” meaning that the plugin expects a string of any given length. - -The definition for audiotype in the example means that the expected -response consists of: “@QAT OK” in the beginning followed by a single -character followed by a “/” and another single character again. After -that is the relevant part of the response, the value of the item, -defined by exactly three digits/characters. Behind that is a blank and -any value consisting of five characters or digits. - -The example definition for audiotrack means that the response can be: -“@UAT” followed by any word/number without a specific length, followed -by a blank and the real value consisting of two characters. The response -could also start with “@QTK OK” followed by the relevant value -consisting of exactly one digit/character. After that there will be a -“/” and any character/digit. It is important to add the “/?” in the end -because the plugin also compares the length of the response with the -expected length (calculated from the response in the command-file). It -is not relevant, if you use a {str} in your response because then the -length can not be determined. - -This feature is still under development. Feel free to experiment with it -and post your experience in the knx-forum. - -.. code-block:: none - - # plugins/avdevice/oppo-udp203.txt - ZONE; FUNCTION; FUNCTIONTYPE; SEND; QUERY; RESPONSE; READWRITE; INVERTRESPONSE; MINVALUE; MAXVALUE; RESPONSETYPE; TRANSLATIONFILE - 0; audiotype; ; ; #QAT; @QAT OK ?/? *** ?????; R; ; ; ; str - 0; audiotrack; ; #AUD; #QTK; @UAT ?{str} **|@QTK OK */?; RW; ; ; ; num - - -Webinterface -============ - -Use the web interface to see which item using the plugin is set to which value. -Furthermore you can see a history of the commands and queries being sent by the -plugin. You can also use the web interface to reload your configuration file. - -.. image:: avdevice_webif.png - :height: 1618px - :width: 3338px - :scale: 25% - :alt: Web Interface - :align: center - -Troubleshooting -=============== -1.) Have a look at the webinterface. You'll figure out the item ids and values -as well as a history of the commands. - -2.) Have a look at the smarthome logfile. If you can’t figure out the -reason for your problem, change the verbose level in logging.yaml. You -can use level 10 (=DEBUG), 9 (VERBOSE1) and 8 (VERBOSE2) as debugging -levels. - -3.) Concerning send and response entries in the text file, make sure the -number of stars correspond to the way your device wants to receive the -command or sends the response. Example 1: Your Pioneer receiver expects -the value for the volume as three digits. So the command needs three -stars. If you now set the item to a value with only two digits, like 90, -the plugin converts the command automatically to have a leading 0. -Example 2: Your Denon receiver responds with values like ON, OFF or -STANDBY to power commands. Replace every character with a star! ON = 2 -stars, OFF = 3 stars, etc. Example 3: Sending or receiving strings of -different length like “CD”, “GAME”, etc. should be set up with one star -only. Alternatively you can use "\*{str}". Set the responsetype -accordingly! - -4.) Set the response type in the textfile to the correct value. The -plugin tries to anticipate the correct value but that doesn’t always -work. The sleep timer of Denon devices is a wonderfully sick example: -You can set values between 1 and 120 to set the timer in minutes. If you -want to turn it off, the receiver expects the value “OFF” instead of a -zero. The plugin fixes that problem if you set the responsetype to -bool|num. As soon as you set the item to 0, it magically converts that -value to “OFF” and the other way around when receiving “OFF”. diff --git a/avdevice/webif/templates/index.html b/avdevice/webif/templates/index.html index d86af636b..75fd9d722 100755 --- a/avdevice/webif/templates/index.html +++ b/avdevice/webif/templates/index.html @@ -18,33 +18,24 @@ + var link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('href', 'static/js/google-prettify/prettify.css'); + document.head.appendChild(link); + var script = document.createElement('script'); + script.setAttribute('src', 'static/js/google-prettify/prettify.js'); + document.head.appendChild(script); +}; + + +/** + * Compute the absolute coordinates and dimensions of an HTML element. + * @param {!Element} element Element to match. + * @return {!Object} Contains height, width, x, and y properties. + * @private + */ +Code.getBBox_ = function(element) { + var height = element.offsetHeight; + var width = element.offsetWidth; + var x = 0; + var y = 0; + do { + x += element.offsetLeft; + y += element.offsetTop; + element = element.offsetParent; + } while (element); + return { + height: height, + width: width, + x: x, + y: y + }; +}; + + +/** + * Init on window load + * */ +window.addEventListener('load', Code.init); + diff --git a/blockly/_pv_1_4_0/webif/static/js/npm.js b/blockly/_pv_1_4_0/webif/static/js/npm.js new file mode 100755 index 000000000..bf6aa8060 --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/js/npm.js @@ -0,0 +1,13 @@ +// This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. +require('../../js/transition.js') +require('../../js/alert.js') +require('../../js/button.js') +require('../../js/carousel.js') +require('../../js/collapse.js') +require('../../js/dropdown.js') +require('../../js/modal.js') +require('../../js/tooltip.js') +require('../../js/popover.js') +require('../../js/scrollspy.js') +require('../../js/tab.js') +require('../../js/affix.js') \ No newline at end of file diff --git a/blockly/_pv_1_4_0/webif/static/shblocks/sh_items.js b/blockly/_pv_1_4_0/webif/static/shblocks/sh_items.js new file mode 100755 index 000000000..c630e15ec --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/shblocks/sh_items.js @@ -0,0 +1,211 @@ +/** + * @license + * Visual Blocks Editor for smarthome.py + * + * Copyright 2015 Dirk Wallmeier + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Variable blocks for Blockly. + * @author DW + */ +'use strict'; + +//goog.provide('Blockly.Blocks.sh_items'); +//goog.require('Blockly.Blocks'); + +Blockly.Blocks['sh_item_obj'] = { + init: function() { + var hiddenFieldPath = new Blockly.FieldTextInput("Path"); + hiddenFieldPath.setVisible(false); + var hiddenFieldType = new Blockly.FieldTextInput("Type"); + hiddenFieldType.setVisible(false); + var fixedFieldName = new Blockly.FieldTextInput("Name"); + this.appendDummyInput() + //.appendField("Item: ") + .appendField(fixedFieldName, "N") + .appendField(hiddenFieldPath, "P") + .appendField(hiddenFieldType, "T") + this.setOutput(true, "shItemType"); + this.setColour(210); + this.setTooltip(this.getFieldValue('P')); + this.setHelpUrl('http://www.example.com/'); + this.setEditable(false); + } +}; + +Blockly.Python['sh_item_obj'] = function(block) { + var iName = block.getFieldValue('N'); + var iPath = block.getFieldValue('P'); + + // TODO: Assemble Python into code variable. +// var code = 'sh.return_item("' + iPath + '")'; + var code = 'sh.items.return_item("' + iPath + '")'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_ATOMIC]; + //return code; +}; + + + +/*Blockly.Blocks['sh_item'] = { + /** + * Block for item + * @this Blockly.Block + * / + init: function() { + var itemlist = new Blockly.FieldTextInput('0'); + itemlist.setVisible(false); + var dropdown = new Blockly.FieldDropdown( function () { + var il = new Array(); + il = itemlist.getValue(); + if (il != '0') { il = eval("(function(){return " + il + ";})()");}; + return il; + } ); + this.setColour(340); + this.appendDummyInput() + .appendField(itemlist, 'ITEMLIST') + .appendField('Item') + .appendField(dropdown, 'ITEM'); + this.setOutput(true, "SHITEM"); + this.setTooltip('Gibt ein Item Objekt zurück.'); + }, +}; + +Blockly.Python['sh_item'] = function(block) { + // Variable getter. + var code = 'sh.' + block.getFieldValue('ITEM'); + return [code, Blockly.Python.ORDER_ATOMIC]; +}; +*/ + +Blockly.Blocks['sh_item_get'] = { + /** + * Block for item getter. -> this is a "Sensor" + * @this Blockly.Block + */ + init: function() { + this.setHelpUrl(''); + this.setColour(260); + this.appendValueInput("ITEMOBJECT") + .setCheck("shItemType") + .appendField("Wert von"); + this.setInputsInline(true); + this.setOutput(true); + this.setTooltip('Gibt den Wert des Items zurück.'); + } +}; + +Blockly.Python['sh_item_get'] = function(block) { + var itemobj = Blockly.Python.valueToCode(block, 'ITEMOBJECT', Blockly.Python.ORDER_ATOMIC) || 'item'; + var code = itemobj + '()'; + //return [code, Blockly.Python.ORDER_NONE]; + return code; +}; + + +Blockly.Blocks['sh_item_set'] = { + /** + * Block for item setter. + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#7wv5ve + */ + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(260); + this.appendValueInput("ITEMOJECT") + .setCheck("shItemType") + .appendField("setze"); + this.appendValueInput("VALUE") + .appendField("auf den Wert"); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(''); + } +}; + +Blockly.Python['sh_item_set'] = function(block) { + var itemobject = Blockly.Python.valueToCode(block, 'ITEMOJECT', Blockly.Python.ORDER_ATOMIC) || 'item'; + var value = Blockly.Python.valueToCode(block, 'VALUE', Blockly.Python.ORDER_ATOMIC) || '0'; + // TODO: Assemble Python into code variable. + //var code = '...'; + var code = itemobject + '(' + value + ')\n'; + //return [code, Blockly.Python.ORDER_FUNCTION_CALL]; + return code; +}; + +Blockly.Blocks['sh_item_hasattr'] = { + init: function() { + this.appendDummyInput() + .appendField("das Item"); + this.appendValueInput("ITEM") + .setCheck("shItemType"); + this.appendDummyInput() + .appendField("hat das Attribut") + .appendField(new Blockly.FieldTextInput("default"), "ATTR"); + this.setInputsInline(true); + this.setOutput(true, "Boolean"); + this.setColour(120); + this.setTooltip(''); + this.setHelpUrl('http://www.example.com/'); + } +}; + +Blockly.Python['sh_item_hasattr'] = function(block) { + var value_item = Blockly.Python.valueToCode(block, 'ITEM', Blockly.Python.ORDER_ATOMIC); + var text_attr = block.getFieldValue('ATTR'); + // TODO: Assemble Python into code variable. + var code = '...'; + // TODO: Change ORDER_NONE to the correct strength. + var code = 'sh.iHasAttr(' + value_item + ', ' + text_attr +' )'; + return [code, Blockly.Python.ORDER_NONE]; + +}; + +/** +Blockly.Blocks['sh_item_attr'] = { + init: function() { + var attrlist = new Blockly.FieldTextInput('0'); + attrlist.setVisible(false); + var dropdown = new Blockly.FieldDropdown( function () { + var al = new Array(); + al = attrlist.getValue(); + if (al != '0') { al = eval("(function(){return " + al + ";})()");}; + return al; + } ); + this.appendDummyInput() + .appendField("Attribut") + .appendField(dropdown, "ATTR") + .appendField("von Item"); + this.appendValueInput("ITEM") + .setCheck("shItemType"); + this.setInputsInline(true); + this.setOutput(true, null); + this.setColour(120); + this.setTooltip(''); + this.setHelpUrl('http://www.example.com/'); + } +}; + +Blockly.Python['sh_item_attr'] = function(block) { + var dropdown_attr = block.getFieldValue('ATTR'); + var value_item = Blockly.Python.valueToCode(block, 'ITEM', Blockly.Python.ORDER_ATOMIC); + // TODO: Assemble Python into code variable. + var code = '...'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + +*/ \ No newline at end of file diff --git a/blockly/_pv_1_4_0/webif/static/shblocks/sh_logic.js b/blockly/_pv_1_4_0/webif/static/shblocks/sh_logic.js new file mode 100755 index 000000000..28f7b64eb --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/shblocks/sh_logic.js @@ -0,0 +1,117 @@ +/** + * @license + + + + + */ +'use strict'; +//goog.provide('Blockly.Blocks.sh_logic'); +//goog.require('Blockly.Blocks'); + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#ojesy8 + */ +Blockly.Blocks['shlogic_by'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("Auslöser (trigger['by'])"); + this.setOutput(true); + this.setTooltip(''); + } +}; +Blockly.Python['shlogic_by'] = function(block) { + var code = "trigger['by']"; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#umj2u6 + */ +Blockly.Blocks['shlogic_value'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("Auslöser (trigger['value'])"); + this.setOutput(true); + this.setTooltip(''); + } +}; +Blockly.Python['shlogic_value'] = function(block) { + var code = "trigger['value']"; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#p3ajqk + */ +Blockly.Blocks['shlogic_source'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("Auslöser (trigger['Source'])"); + this.setOutput(true); + this.setTooltip(''); + } +}; +Blockly.Python['shlogic_source'] = function(block) { + var code = "trigger['source']"; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#8jdhtt + */ +Blockly.Blocks['shlogic_dest'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("Auslöser (trigger['Dest'])"); + this.setOutput(true); + this.setTooltip(''); + } +}; +Blockly.Python['shlogic_dest'] = function(block) { + var code = "trigger['dest']"; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#r74ibg + */ +/** +Blockly.Blocks['shlogic_trigger'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("diese Logik auslösen um "); + this.appendValueInput("DATETIME"); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(''); + } +}; +Blockly.Python['shlogic_trigger'] = function(block) { + var value_datetime = Blockly.Python.valueToCode(block, 'DATETIME', Blockly.Python.ORDER_ATOMIC); + // TODO: Assemble Python into code variable. + var code = '...'; + return code; +}; + */ + diff --git a/blockly/_pv_1_4_0/webif/static/shblocks/sh_logics.js b/blockly/_pv_1_4_0/webif/static/shblocks/sh_logics.js new file mode 100755 index 000000000..4d0392e38 --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/shblocks/sh_logics.js @@ -0,0 +1,476 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2012 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Logic blocks for Blockly. + * @author q.neutron@gmail.com (Quynh Neutron) + */ +'use strict'; + +//goog.provide('Blockly.Blocks.sh_trigger'); +//goog.require('Blockly.Blocks'); + + +/** + * Logic main block + */ +Blockly.Blocks['sh_logic_main'] = { + /** + * Block for if/elseif/else condition. + * @this Blockly.Block + */ + init: function() { +/** + Blockly.HSV_SATURATION = 0.45; + Blockly.HSV_VALUE = 0.65; + */ + this.setColour(125); + this.appendValueInput("LOGIC") + .setCheck("shItemType") + .setAlign(Blockly.ALIGN_LEFT) + .appendField("Logik") + .appendField(new Blockly.FieldTextInput("new_logic"), 'LOGIC_NAME') + .appendField('(Dateiname zum speichern ohne Extension)'); + this.appendStatementInput('DO'); +// this.appendStatementInput('DO') +// .appendField('starte'); + this.appendDummyInput() + .appendField(new Blockly.FieldCheckbox("TRUE"), "ACTIVE") + .appendField(new Blockly.FieldTextInput("Kommentar"), "COMMENT"); + + this.setPreviousStatement(false); + this.setNextStatement(false); + this.setTooltip('Block wird ausgeführt, sobald sich der Wert des Triggers ändert.'); + } +}; + + +function GetTriggerComment(trigger_block) +{ + var comment = ''; + var trigger_comment = trigger_block.getFieldValue('COMMENT'); + if (trigger_comment != 'Kommentar') { + comment += trigger_comment; + }; + return comment.trim(); +}; + +function GetTrigger(trigger_block) +{ + var trigger = ''; + var trigger_id = trigger_block.getFieldValue('NAME'); + if (trigger_block.data == 'sh_trigger_cycle') { + var trigger_id= trigger_block.getFieldValue('NAME'); + trigger += ' cycle: ' + trigger_block.getFieldValue('TRIG_CYCLE'); + }; + if (trigger_block.data == 'sh_trigger_item') { + var trigger_id= trigger_block.getFieldValue('NAME'); + var itemcode = Blockly.Python.valueToCode(trigger_block, 'TRIG_ITEM', Blockly.Python.ORDER_ATOMIC); + var itemid = itemcode.split('"')[1] + trigger += ' watch_item: ' + itemid; + }; + if (trigger_block.data == 'sh_trigger_sun') { + var offset = trigger_block.getFieldValue('OFFSET'); + var plusminus = trigger_block.getFieldValue('PLUSMINUS'); + var sun = trigger_block.getFieldValue('SUN'); + trigger += ' crontab: ' + sun + plusminus + offset; + }; + if (trigger_block.data == 'sh_trigger_daily') { +// var trigger_id= trigger_block.getFieldValue('NAME'); + var hh = trigger_block.getFieldValue('HH'); + var mm = trigger_block.getFieldValue('MM'); + trigger += ' crontab: ' + + mm + ' ' + hh + ' * *'; + }; + if (trigger_block.data == 'sh_trigger_init') { + trigger += ' crontab: init'; + }; + if (trigger_id != '' && trigger_id != 'trigger_id') + { + trigger += ' = ' + trigger_id + }; + return trigger; +}; + +function GetMultiTriggers(trigger_block) +{ + var cr_list = []; + var crc_list = []; + var wi_list = []; + var wic_list = []; + var contab_triggers = ['sh_trigger_sun', 'sh_trigger_daily', 'sh_trigger_init']; + var next_block = trigger_block; + + while (next_block != null) { + if (next_block.data != '') + { + if (contab_triggers.indexOf(next_block.data) > -1) + { + var trigger = GetTrigger(next_block); + if (trigger.trim() != '') { + cr_list.push(trigger.split(':')[1].trim()); + }; + crc_list.push(GetTriggerComment(next_block)); + }; + if (next_block.data == 'sh_trigger_item') + { + var trigger = GetTrigger(next_block); + if (trigger.trim() != '') { + wi_list.push(trigger.split(':')[1].trim()); + }; + wic_list.push(GetTriggerComment(next_block)); + }; + }; + var next_block = next_block.getNextBlock(); + }; + return [cr_list, crc_list, wi_list, wic_list]; +}; + +function NextLevel(trigger_block, logicname, ignore_crontab, ignore_watchitem) +{ + var tr_insert = '' + var tr_comment = '' + if (trigger_block != null) { + if (trigger_block.data != null) { + var comment = GetTriggerComment(trigger_block) + var trigger = GetTrigger(trigger_block); + if (ignore_crontab && (trigger.trim().substring(0,8) == 'crontab:')) + { + trigger = ''; + }; + if (ignore_watchitem && (trigger.trim().substring(0,11) == 'watch_item:')) + { + trigger = ''; + }; + if (trigger.trim() != '') { + tr_insert += '#trigger#'+logicname+'#filename: '+logicname+'.py#' + trigger.trim() + '#' + comment + '\n'; + var line = trigger; + if (comment != '') { + line = line.padEnd(50) + ' # ' + comment + }; + tr_comment += line + '\n'; + }; + + var next_block = trigger_block.getNextBlock(); + var next = NextLevel(next_block, logicname, ignore_crontab, ignore_watchitem); + tr_insert += next[0]; + tr_comment += next[1]; + }; + return [tr_insert, tr_comment]; + }; +}; + +Blockly.Python['sh_logic_main'] = function(block) +{ + this.data = 'sh_logic_main' + var trigger_block = block.getChildren(); + var triggerid = Blockly.Python.variableDB_.getDistinctName('trigger_id', Blockly.Variables.NAME_TYPE); + var itemcode = Blockly.Python.valueToCode(block, 'TRIG_ITEM', Blockly.Python.ORDER_ATOMIC); + var itemid = itemcode.split('"')[1] + //var item = block.getFieldValue('TRIG_ITEM'); + var branch = Blockly.Python.statementToCode(block, 'DO') ; + //var branch = Blockly.Python.statementToCode(block, 'DO') || ' pass\n'; + var checkbox_active = (block.getFieldValue('ACTIVE') == 'TRUE') ? 'True' : 'False'; + var text_comment = block.getFieldValue('COMMENT'); + + var triggerid = block.getFieldValue('NAME'); + var itemcode = Blockly.Python.valueToCode(block, 'TRIG_ITEM', Blockly.Python.ORDER_ATOMIC); + var itemid = itemcode.split('"')[1] + + var code = ''; + var trigger = ''; + var logicname = block.getFieldValue('LOGIC_NAME').toLowerCase().replace(" ", "_"); + block.setFieldValue(logicname, 'LOGIC_NAME'); + + var active = block.getFieldValue('ACTIVE'); + if (active == 'TRUE') { + active = 'True'; + } else { + active = 'False'; + }; + + if (text_comment.length > 0) { + code += '#comment#'+logicname+'#filename: '+logicname+'.py#active: ' + active + '#' + text_comment + '\n'; + }; + + var tr_list = GetMultiTriggers(trigger_block[0]); + var tr_crontab_list = tr_list[0]; + var tr_crontabc_list = tr_list[1]; + var tr_watchitem_list = tr_list[2]; + var tr_watchitemc_list = tr_list[3]; + + if (trigger_block.length > 0) { + var next = NextLevel(trigger_block[0], logicname, (tr_crontab_list.length > 1), (tr_watchitem_list.length > 1)); + code += next[0]; + }; + + if (tr_crontab_list.length > 1) + { + var tr_list = ''; + var co_list = ''; + for (var t in tr_crontab_list) { + if (t > 0) { + tr_list += ','; + co_list += ','; + }; + tr_list += "'"+tr_crontab_list[t]+"'"; + co_list += "'"+tr_crontabc_list[t]+"'"; + }; + code += '#trigger#'+logicname+'#filename: '+logicname+'.py#crontab: [' + tr_list + ']#[' + co_list + ']\n'; + }; + + if (tr_watchitem_list.length > 1) + { + var tr_list = ''; + var co_list = ''; + for (var t in tr_watchitem_list) { + if (t > 0) { + tr_list += ','; + co_list += ','; + }; + tr_list += "'"+tr_watchitem_list[t]+"'"; + co_list += "'"+tr_watchitemc_list[t]+"'"; + }; + code += '#trigger#'+logicname+'#filename: '+logicname+'.py#watch_item: [' + tr_list + ']#[' + co_list + ']\n'; + }; + + code += '"""\n' + 'Logic '+ logicname + '.py\n'; + code += '\n' + text_comment + '\n'; + + code += "\nTHIS FILE WAS GENERATED FROM A BLOCKY LOGIC WORKSHEET - DON'T EDIT THIS FILE, use the Blockly plugin instead !\n" + if (next[1] != '') { + var trigger_comment = trigger_block[0].getFieldValue('COMMENT'); + code += '\nto be configured in /etc/logic.yaml:\n'; + code += "\n"+logicname+":\n"; + var line = " filename: "+logicname+".py" + if (text_comment != '') { + line = line.padEnd(50) + ' # ' + text_comment + }; + code += line + '\n'; + code += next[1]; + }; + + if (tr_watchitem_list.length > 1) + { + code += ' watch_item:\n' + for (var t in tr_watchitem_list) { + code += ' - ' + tr_watchitem_list[t] + '\n'; + }; + }; + if (tr_crontab_list.length > 1) + { + code += ' crontab:\n' + for (var t in tr_crontab_list) { + code += ' - ' + tr_crontab_list[t] + '\n'; + }; + }; + code += '"""\n'; + + code += "logic_active = "+ active +"\n"; + code += "if (logic_active == True):\n"; + //code += " logger.info('ITEM TRIGGER id: \{\}, value: \{\}'.format(logic.name, trigger['value'] )) \n"; + code += branch; + return code + "\n\n"; +}; + + +/** + * Trigger die Logic bei Änderung des Items + */ +Blockly.Blocks['sh_trigger_item'] = { + /** + * Block for if/elseif/else condition. + * @this Blockly.Block + */ + init: function() { + this.data = 'sh_trigger_item' + this.setColour(190); + this.appendValueInput("TRIG_ITEM") + .setCheck("shItemType") + .setAlign(Blockly.ALIGN_LEFT) + .appendField("Trigger: Auslösen bei Änderung von"); +// this.appendStatementInput('DO') +// .appendField('starte'); +// this.appendDummyInput() +// .appendField(new Blockly.FieldCheckbox("TRUE"), "ACTIVE") + this.appendDummyInput() + .appendField("als Trigger") + .appendField(new Blockly.FieldTextInput("trigger_id"), "NAME"); + this.appendDummyInput() + .appendField("Kommentar") + .appendField(new Blockly.FieldTextInput(""), "COMMENT"); + + this.setInputsInline(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip('Block wird ausgeführt, sobald sich der Wert des Triggers ändert.'); + } +}; + +Blockly.Python['sh_trigger_item'] = function(block) +{ + var code = ''; + return code; +}; + + +/** + * Trigger ... alle x sec. + */ +Blockly.Blocks['sh_trigger_cycle'] = { + /** + * Block for + * @this Blockly.Block + */ + init: function() { + this.data = 'sh_trigger_cycle'; + this.setColour(190); + this.appendDummyInput() + .appendField('Trigger: alle') +// .appendField(new Blockly.FieldTextInput('60', +// Blockly.FieldTextInput.nonnegativeIntegerValidator), 'TRIG_CYCLE') + .appendField(new Blockly.FieldNumber(60, 0), 'TRIG_CYCLE') + .appendField('Sekunden auslösen') +// this.appendDummyInput() + .appendField("als Trigger") + .appendField(new Blockly.FieldTextInput("trigger_id"), "NAME"); + this.appendDummyInput() + .appendField("Kommentar") + .appendField(new Blockly.FieldTextInput(""), "COMMENT"); + this.setInputsInline(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip('Block wird nach vorgegebener Zeit wiederholt ausgeführt.'); + } +}; + +Blockly.Python['sh_trigger_cycle'] = function(block) { + var code = '' + return code; +}; + +/** + * Trigger vor/nach Sonnen-Auf-/Untergang. + */ +Blockly.Blocks['sh_trigger_sun'] = { + /** + * Block for + * @this Blockly.Block + */ + init: function() { + this.data = 'sh_trigger_sun'; + this.setColour(190); + this.appendDummyInput() + .appendField('Trigger (crontab): Auslösen') +// .appendField(new Blockly.FieldTextInput('0', Blockly.FieldTextInput.nonnegativeIntegerValidator), 'OFFSET') + .appendField(new Blockly.FieldNumber(0, 0), 'OFFSET') + .appendField('Minuten') + .appendField(new Blockly.FieldDropdown( [['vor', '-'], ['nach', '+']] ), 'PLUSMINUS') + .appendField('Sonnen-') + .appendField(new Blockly.FieldDropdown( [['Aufgang', 'sunrise'], ['Untergang', 'sunset']] ), 'SUN') + .appendField("als Trigger") + .appendField(new Blockly.FieldTextInput("trigger_id"), "NAME"); + this.appendDummyInput() + .appendField("Kommentar") + .appendField(new Blockly.FieldTextInput(""), "COMMENT"); + this.setInputsInline(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip('Block wird vor/nach Sonnenaufgang/Sonnenuntergang ausgeführt.'); + } +}; + +Blockly.Python['sh_trigger_sun'] = function(block) +{ + var code = ''; + return code; +}; + + +/** + * Trigger taeglich um HH:MM Uhr + */ +Blockly.Blocks['sh_trigger_daily'] = { + /** + * Block for + * @this Blockly.Block + */ + init: function() { + this.data = 'sh_trigger_daily'; + this.setColour(190); + this.appendDummyInput() + .appendField('Trigger (crontab): Jeden Tag ') + .appendField('um') +// .appendField(new Blockly.FieldTextInput('0', Blockly.FieldTextInput.nonnegativeIntegerValidator), 'HH') + .appendField(new Blockly.FieldNumber(0, 0), 'HH') + .appendField(':') +// .appendField(new Blockly.FieldTextInput('0', Blockly.FieldTextInput.nonnegativeIntegerValidator), 'MM') + .appendField(new Blockly.FieldNumber(0, 0), 'MM') + .appendField('Uhr') +// this.appendDummyInput() +// .appendField(new Blockly.FieldCheckbox("TRUE"), "ACTIVE"); + .appendField("als Trigger") + .appendField(new Blockly.FieldTextInput("trigger_id"), "NAME"); + this.appendDummyInput() + .appendField("Kommentar") + .appendField(new Blockly.FieldTextInput(""), "COMMENT"); + this.setInputsInline(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip('Block wird täglich zur gegebenen Stunde ausgeführt.'); + } +}; + +Blockly.Python['sh_trigger_daily'] = function(block) +{ + var code = ''; + return code; +}; + + +/** + * Trigger bei Initialisierung auslösen + */ +Blockly.Blocks['sh_trigger_init'] = { + /** + * Block for + * @this Blockly.Block + */ + init: function() { + this.data = 'sh_trigger_init'; + this.setColour(190); + this.appendDummyInput() + .appendField('Trigger (crontab): Bei Initialisierung auslosen, ') + .appendField("als Trigger") + .appendField(new Blockly.FieldTextInput("Init"), "NAME"); + this.appendDummyInput() + .appendField("Kommentar") + .appendField(new Blockly.FieldTextInput(""), "COMMENT"); + this.setInputsInline(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip('Block wird bei der Initialisierung ausgeführt.'); + } +}; + +Blockly.Python['sh_trigger_init'] = function(block) +{ + var code = ''; + return code; +}; diff --git a/blockly/_pv_1_4_0/webif/static/shblocks/sh_notify.js b/blockly/_pv_1_4_0/webif/static/shblocks/sh_notify.js new file mode 100755 index 000000000..b62facb43 --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/shblocks/sh_notify.js @@ -0,0 +1,95 @@ +/** + * @license + + + + + */ +'use strict'; +//goog.provide('Blockly.Blocks.sh_logic'); +//goog.require('Blockly.Blocks'); + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#c2zkf2 + */ +Blockly.Blocks['shnotify_email'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("eine eMail senden an") + .appendField(new Blockly.FieldTextInput("mail@smarthome.py"), "TO") + .appendField("mit Betreff") + .appendField(new Blockly.FieldTextInput("Nachricht von SmartHome"), "SUBJECT") + .appendField("und Text:"); + this.appendValueInput("TEXT") + .setCheck("String"); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(''); + } +}; +Blockly.Python['shnotify_email'] = function(block) { + var to = block.getFieldValue('TO'); + var subject = block.getFieldValue('SUBJECT'); + var text = Blockly.Python.valueToCode(block, 'TO', Blockly.Python.ORDER_ATOMIC); + // TODO: Assemble Python into code variable. + var code = 'if sh.mail: sh.mail('+to+', '+subject+', '+text+')'; + return code; +}; + + +Blockly.Blocks['shnotify_prowl'] = { + /** + * Block for + */ + init: function() { + this.setColour(340); + this.appendDummyInput().appendField('Sende Nachricht mit PROWL:'); + }, +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#ptwi98 + */ +Blockly.Blocks['shnotify_nma'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("eine NMA Nachricht senden") + .appendField("mit Betreff") + .appendField(new Blockly.FieldTextInput("Nachricht von SmartHome"), "SUBJECT") + .appendField("und Text:"); + this.appendValueInput("TEXT") + .setCheck("String"); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(''); + } +}; +Blockly.Python['shnotify_nma'] = function(block) { + var text_subject = block.getFieldValue('SUBJECT'); + var value_text = Blockly.Python.valueToCode(block, 'TEXT', Blockly.Python.ORDER_ATOMIC); + // TODO: Assemble Python into code variable. + var code = '...'; + return code; +}; + + +/** + * https:// + */ +Blockly.Blocks['shnotify_pushbullit'] = { + /** + * Block for + */ + init: function() { + this.setColour(340); + this.appendDummyInput().appendField('Sende Nachricht mit Pushbullit:'); + }, +}; + diff --git a/blockly/_pv_1_4_0/webif/static/shblocks/sh_time.js b/blockly/_pv_1_4_0/webif/static/shblocks/sh_time.js new file mode 100755 index 000000000..be835843a --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/shblocks/sh_time.js @@ -0,0 +1,123 @@ +/** + * @license + + + + + + */ +'use strict'; +//goog.provide('Blockly.Blocks.sh_logic'); +//goog.require('Blockly.Blocks'); + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#dngzfj + */ +Blockly.Blocks['shtime_now'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("Zeit: jetzt"); + this.setInputsInline(true); + this.setOutput(true, "DateTime"); + this.setTooltip(''); + } +}; +Blockly.Python['shtime_now'] = function(block) { +// var code = 'sh.now()'; + var code = 'sh.shtime.now()'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#rvch7e + */ +Blockly.Blocks['shtime_time'] = { + init: function() { + var hh_mmValidator = function(text) { + if(text === null) { + return null; + } + text = text.replace(/O/ig, '0'); + text = text.replace(/,/g, ':'); + var hh_mm = text.split(':'); + if (hh_mm.length !== 2) { + return null; + } + return String('00'+hh_mm[0]).slice(-2) + ':' + String('00'+hh_mm[1]).slice(-2); + }; + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField(new Blockly.FieldTextInput("12:00", hh_mmValidator ), "TIME") + .appendField("Uhr"); + this.setInputsInline(true); + this.setOutput(true, "DateTime"); + this.setTooltip(''); + } +}; + +Blockly.Python['shtime_time'] = function(block) { + var text_time = block.getFieldValue('TIME'); + var code = 'datetime.strptime("'+text_time+'", "%H:%M")'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#5yobn6 + */ +Blockly.Blocks['shtime_sunpos'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(300); + this.appendValueInput("DELTA") + .appendField(new Blockly.FieldDropdown([["Azimut Winkel des Sonnenstands", "0"], + ["Altitude Winkel des Sonnenstands", "1"]]), + "AA") + .appendField(new Blockly.FieldDropdown([["+", "+"], + ["-", "-"]]), "PM"); + this.appendDummyInput() + .appendField("Minuten"); + this.setInputsInline(true); + this.setOutput(true, "Number"); + this.setTooltip(''); + } +}; +Blockly.Python['shtime_sunpos'] = function(block) { + var delta = Blockly.Python.valueToCode(block, 'DELTA', Blockly.Python.ORDER_ATOMIC); + var aa = block.getFieldValue('AA'); + var pm = block.getFieldValue('PM'); + if (delta === '') { pm = ''; }; + var code = 'sh.sun.pos('+pm+delta+')['+aa+']'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + + +Blockly.Blocks['shtime_moon'] = { + /** + * Block for + */ + init: function() { + this.setColour(340); + this.appendDummyInput().appendField('Mond:'); + }, +}; + +Blockly.Blocks['shtime_auto'] = { + /** + * Block for + */ + init: function() { + this.setColour(340); + this.appendDummyInput().appendField('Autotimer:'); + }, +}; + diff --git a/blockly/_pv_1_4_0/webif/static/shblocks/sh_tools.js b/blockly/_pv_1_4_0/webif/static/shblocks/sh_tools.js new file mode 100755 index 000000000..aa6773a3f --- /dev/null +++ b/blockly/_pv_1_4_0/webif/static/shblocks/sh_tools.js @@ -0,0 +1,115 @@ +/** + * @license + + + + */ +'use strict'; +//goog.provide('Blockly.Blocks.sh_tools'); +//goog.require('Blockly.Blocks'); + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#prgbjr + */ +Blockly.Blocks['shtools_logger'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(45); + this.appendDummyInput() + .appendField("schreibe ins Log"); + this.appendValueInput("LOGTEXT") + .setCheck("String"); + this.appendDummyInput() + .appendField("mit log-level") + .appendField(new Blockly.FieldDropdown([["debug", "DEBUG"], ["info", "INFO"], ["warning", "WARNING"], ["error", "ERROR"], ["critical", "CRITICAL"]]), "LOGLEVEL"); + this.setInputsInline(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setTooltip(''); + } +}; +Blockly.Python['shtools_logger'] = function(block) { + var loglevel = block.getFieldValue('LOGLEVEL').toLowerCase(); + var logtext = Blockly.Python.valueToCode(block, 'LOGTEXT', Blockly.Python.ORDER_NONE) || '\'\''; + var code = "logger." + loglevel + "(" + logtext + ")\n"; + return code; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#ipzxr2 + */ +Blockly.Blocks['shtools_dewpoint'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("Taupunkt bei") + this.appendValueInput("TEMP") + .appendField("°C Temperatur und ") + this.appendValueInput("HUM") + .appendField("% rel.Feuchtigkeit"); + this.setInputsInline(true); + this.setOutput(true, "Number"); + this.setTooltip(''); + } +}; +Blockly.Python['shtools_dewpoint'] = function(block) { + var value_hum = Blockly.Python.valueToCode(block, 'HUM', Blockly.Python.ORDER_ATOMIC); + var value_temp = Blockly.Python.valueToCode(block, 'TEMP', Blockly.Python.ORDER_ATOMIC); + var code = 'sh.tools.dewpoint(' + value_temp + ', ' + value_hum + ')'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#onut2y + */ +Blockly.Blocks['shtools_fetchurl'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("open URL") + .appendField(new Blockly.FieldTextInput("http://"), "URL"); + this.setInputsInline(true); + this.setOutput(true, "String"); + this.setTooltip(''); + } +}; +Blockly.Python['shtools_fetchurl'] = function(block) { + var text_url = block.getFieldValue('URL'); + var code = 'sh.tools.fetch_url("' + text_url + '")' ; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + +/** + * https://blockly-demo.appspot.com/static/demos/blockfactory/index.html#eqhv9d + */ +Blockly.Blocks['shtools_fetchurl2'] = { + init: function() { + this.setHelpUrl('http://www.example.com/'); + this.setColour(210); + this.appendDummyInput() + .appendField("open URL") + .appendField(new Blockly.FieldTextInput("http://"), "URL") + .appendField(" mit username") + .appendField(new Blockly.FieldTextInput(""), "USER") + .appendField(": password") + .appendField(new Blockly.FieldTextInput(""), "PASSWORD"); + this.setInputsInline(true); + this.setOutput(true, "String"); + this.setTooltip(''); + } +}; +Blockly.Python['shtools_fetchurl2'] = function(block) { + var text_url = block.getFieldValue('URL'); + var text_user = block.getFieldValue('USER'); + var text_password = block.getFieldValue('PASSWORD'); + var code = 'sh.tools.fetch_url("' + text_url + '", "' + text_user + '", "' + text_password + '")'; + // TODO: Change ORDER_NONE to the correct strength. + return [code, Blockly.Python.ORDER_NONE]; +}; + diff --git a/blockly/webif/templates/base.html b/blockly/_pv_1_4_0/webif/templates/base.html similarity index 100% rename from blockly/webif/templates/base.html rename to blockly/_pv_1_4_0/webif/templates/base.html diff --git a/blockly/_pv_1_4_0/webif/templates/blockly.html b/blockly/_pv_1_4_0/webif/templates/blockly.html new file mode 100755 index 000000000..ec829f053 --- /dev/null +++ b/blockly/_pv_1_4_0/webif/templates/blockly.html @@ -0,0 +1,108 @@ + + +{% extends "base.html" %} + +{% block content %} + +{% include "logics_blockly_toolbox.html" %} + + + + + + + + + + + + + + + + +
+

Blockly {{ _('Logik-Editor') }} {{ _('für SmartHomeNG') }} +

+
+ + + + + + + + +
Blocks Python +
+ {% if cmd != 'edit' and cmd != 'new' %} + + + + + + + {% else %} + + + + + {% endif %} + +
+
+
+
+
+

+  
+  
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/blockly/_pv_1_4_0/webif/templates/logics_blockly_toolbox.html b/blockly/_pv_1_4_0/webif/templates/logics_blockly_toolbox.html
new file mode 100755
index 000000000..6193b5890
--- /dev/null
+++ b/blockly/_pv_1_4_0/webif/templates/logics_blockly_toolbox.html
@@ -0,0 +1,292 @@
+
diff --git a/blockly/_pv_1_4_0/webif/templates/new.blockly b/blockly/_pv_1_4_0/webif/templates/new.blockly
new file mode 100755
index 000000000..95b741298
--- /dev/null
+++ b/blockly/_pv_1_4_0/webif/templates/new.blockly
@@ -0,0 +1 @@
+newFALSEVorlage für eine neue Logiksh_logic_mainInitTrigger Beispiel: Bei der Initialisierung auslösensh_trigger_initWARNINGBeispiel: Logeintrag der Logik
\ No newline at end of file
diff --git a/blockly/assets/blockly_webif.png b/blockly/assets/blockly_webif.png
new file mode 100644
index 000000000..4c19a75d6
Binary files /dev/null and b/blockly/assets/blockly_webif.png differ
diff --git a/blockly/locale.yaml b/blockly/locale.yaml
new file mode 100755
index 000000000..5d0341fc4
--- /dev/null
+++ b/blockly/locale.yaml
@@ -0,0 +1,12 @@
+plugin_translations:
+    # Translations for the plugin specially for the web interface
+    'Logik-Editor für SmartHomeNG':           {'de': '=', 'en': 'Logic-Editor for SmartHomeNG', 'fr': 'Editeur de Logiques pour SmartHomeNG', 'pl': 'Edytor logiki SmartHomeNG'}
+    'Aktivieren':           {'de': '=', 'en': 'Enable', 'fr': 'Activer', 'pl': 'Włącz'}
+    'Beenden':           {'de': '=', 'en': 'Stop', 'fr': 'Terminer', 'pl': 'Stop'}
+    'Speichern':           {'de': '=', 'en': 'Save', 'fr': 'Sauvegarder', 'pl': 'Zapisz'}
+    'Speichern & schließen':           {'de': '=', 'en': 'Save & Close', 'fr': 'Sauvegarder & Fermer', 'pl': 'Zapisz & Zamknij'}
+    'Blöcke speichern':           {'de': '=', 'en': 'Save Blocks', 'fr': 'Enregistrer les blocs', 'pl': 'Zapisz Bloki'}
+    'Verwerfen':           {'de': '=', 'en': 'Undo Changes', 'fr': 'Annuler', 'pl': 'Cofnij Zmiany'}
+    'Änderungen verwerfen':           {'de': '=', 'en': 'Undo Changes', 'fr': 'Annuler modifications', 'pl': 'Cofnij Zmiany'}
+    'Leeren':           {'de': '=', 'en': 'Clear', 'fr': 'Vider', 'pl': 'Wyczyść'}
+    'Schließen':           {'de': '=', 'en': 'Close', 'fr': 'Fermer', 'pl': 'Zamknij'}
diff --git a/blockly/plugin.yaml b/blockly/plugin.yaml
index 225aebf99..20a44e759 100755
--- a/blockly/plugin.yaml
+++ b/blockly/plugin.yaml
@@ -7,13 +7,13 @@ plugin:
         de: 'Blockly - graphischer Editor für Logiken - Noch in der Entwicklung, nicht für die Nutzung gedacht'
         en: 'Blockly - graphical editor for logics - Still in development, not for use'
     maintainer: msinn, psilo909
-    tester: '?'
+    tester: onkelandy
     state: develop
 #    keywords: iot xyz
 #    documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md        # url of documentation (wiki) page
 #    support: https://knx-user-forum.de/forum/supportforen/smarthome-py/959964-support-thread-für-das-backend-plugin
 
-    version: 1.4.0                # Plugin version
+    version: 1.5.0                # Plugin version
     sh_minversion: 1.4             # minimum shNG version to use this plugin
 #    sh_maxversion:                 # maximum shNG version to use this plugin (leave empty if latest)
     multi_instance: False          # plugin supports multi instance
@@ -42,4 +42,3 @@ logic_parameters: NONE
 
 plugin_functions: NONE
 # Definition of plugin functions defined by this plugin
-
diff --git a/blockly/tests/test_backend_blocklylogics.py b/blockly/tests/test_backend_blocklylogics.py
index fe2bcc4e4..2ba0042e8 100755
--- a/blockly/tests/test_backend_blocklylogics.py
+++ b/blockly/tests/test_backend_blocklylogics.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 import sys
-print(sys.path)
+print(f"sys.path={sys.path}")
 
 from tests import common
 import cherrypy
@@ -92,7 +92,15 @@ def test_blockly(self):
 
 
 class MockBackendServer():
+    import os
+    cwd = os.getcwd()
+    print(f"blockly cwd={cwd}")
+    os.chdir('..')
+    cwd = os.getcwd()
+    print(f"blockly new cwd={cwd}")
+
     _sh = MockSmartHome()
+    print(f"blockly etc_dir = {_sh.get_etcdir()}")
 
     def __init__(self):
         self._sh.with_items_from(common.BASE + "/tests/resources/blockly_items.conf")
diff --git a/blockly/user_doc.rst b/blockly/user_doc.rst
new file mode 100644
index 000000000..0aedb2a90
--- /dev/null
+++ b/blockly/user_doc.rst
@@ -0,0 +1,87 @@
+.. index:: Plugins; blockly
+.. index:: blockly
+
+=======
+blockly
+=======
+
+.. image:: webif/static/img/plugin_logo.svg
+   :alt: plugin logo
+   :width: 300px
+   :height: 300px
+   :scale: 50 %
+   :align: left
+
+Beschreibung von developers.google.com:
+Die Blockly-Bibliothek fügt Ihrer App einen Editor hinzu, der Codierungskonzepte
+als ineinandergreifende Blöcke darstellt.
+Es gibt syntaktisch korrekten Code in der Programmiersprache Ihrer Wahl aus.
+Sie können benutzerdefinierte Blöcke erstellen, um eine Verbindung zu Ihrer
+eigenen Anwendung herzustellen.
+
+Voraussetzungen
+===============
+
+Dieses Plugin läuft unter Python >= 3.4 sowie den Bibliotheken cherrypy
+und jinja2.
+
+.. important::
+
+    Dieses Plugin benötigt das SmartHomeNG Modul ``http``.
+
+Konfiguration
+=============
+
+.. important::
+
+    Detaillierte Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/blockly` zu finden.
+
+
+.. code-block:: yaml
+
+   # etc/plugin.yaml
+   blockly:
+       plugin_name: blockly
+
+
+Aktualisierung
+==============
+
+Es ist möglich, das gesamte ZIP File von `Github `_
+herunterzuladen. Aus dem entpackten File sind folgende Dateien nötig:
+
+* blockly_compressed.js
+
+* blocks_compressed.js
+
+* python_compressed.js
+
+* demos/code/style.css
+
+* msg/js/de.js
+
+* msg/js/en.js
+
+* msg/js/fr.js
+
+* LICENSE
+
+* README.md
+
+Diese Dateien müssen direkt in den Ordner ``plugins/blockly/webif/static/blockly`` kopiert werden.
+
+Zusätzlich muss das komplette ``media`` Verzeichnis in den gleichen Ordner kopiert werden.
+
+
+Web Interface
+=============
+
+Das Web Interface ist das Kernstück des Plugins - hier ist es möglich, mit Hilfe
+der verschiedenen Blöcke einfach Logiken für SmarthomeNG zu erstellen.
+
+.. image:: assets/blockly_webif.png
+   :height: 1504px
+   :width: 3330px
+   :scale: 25%
+   :alt: Web Interface
+   :align: center
diff --git a/blockly/utils.py b/blockly/utils.py
index 77866c2c6..e93424ee3 100755
--- a/blockly/utils.py
+++ b/blockly/utils.py
@@ -27,29 +27,15 @@
 import collections
 from collections import OrderedDict
 
-
-# Funktionen für Jinja2 z.Zt außerhalb der Klasse Blockly, da ich Jinja2 noch nicht mit
-# Methoden einer Klasse zum laufen bekam
-
-
-# def get_basename(p):
-#     """
-#     returns the filename of a full pathname
-# 
-#     This function extends the jinja2 template engine
-#     """
-#     return os.path.basename(p)
-
-
 def remove_prefix(string, prefix):
     """
     Remove prefix from a string
-    
+
     :param string: String to remove the profix from
     :param prefix: Prefix to remove from string
     :type string: str
     :type prefix: str
-    
+
     :return: Strting with prefix removed
     :rtype: str
     """
@@ -58,178 +44,8 @@ def remove_prefix(string, prefix):
     return string
 
 
-translation_dict = {}
-translation_dict_en = {}
-translation_dict_de = {}
-translation_lang = ''
-
-
-def get_translation_lang():
-    global translation_lang
-    return translation_lang
-
-
-def load_translation_backuplanguages():
-    global translation_dict_en  # Needed to modify global copy of translation_dict
-    global translation_dict_de  # Needed to modify global copy of translation_dict
-
-    logger = logging.getLogger(__name__)
-
-    lang_filename = os.path.dirname(os.path.abspath(__file__)) + '/locale/' + 'en' + '.json'
-    try:
-        f = open(lang_filename, 'r')
-        translation_dict_en = json.load(f)
-    except Exception as e:
-        translation_dict_en = {}
-        logger.error("Blockly: load_translation language='{0}' failed: Error '{1}'".format('en', e))
-    logger.debug("Blockly: translation_dict_en='{0}'".format(translation_dict_en))
-
-    lang_filename = os.path.dirname(os.path.abspath(__file__)) + '/locale/' + 'de' + '.json'
-    try:
-        f = open(lang_filename, 'r')
-        translation_dict_de = json.load(f)
-    except Exception as e:
-        translation_dict_de = {}
-        logger.error("Blockly: load_translation language='{0}' failed: Error '{1}'".format('de', e))
-    logger.debug("Blockly: translation_dict_de='{0}'".format(translation_dict_de))
-
-    return
-
-
-def load_translation(language):
-    global translation_dict  # Needed to modify global copy of translation_dict
-    global translation_lang  # Needed to modify global copy of translation_lang
-
-    logger = logging.getLogger(__name__)
-
-    if translation_dict_en == {}:
-        load_translation_backuplanguages()
-
-    translation_lang = language.lower()
-    if translation_lang == '':
-        translation_dict = {}
-    else:
-        lang_filename = os.path.dirname(os.path.abspath(__file__)) + '/locale/' + translation_lang + '.json'
-        try:
-            f = open(lang_filename, 'r')
-        except Exception as e:
-            translation_lang = ''
-            logger.error("Blockly: load_translation language='{0}' failed: Error '{1}'".format(translation_lang, e))
-            return False
-        try:
-            translation_dict = json.load(f)
-        except Exception as e:
-            logger.error("Blockly: load_translation language='{0}': Error '{1}'".format(translation_lang, e))
-            return False
-    logger.debug("Blockly: translation_dict='{0}'".format(translation_dict))
-    return True
-
-
 def html_escape(str):
     str = str.rstrip().replace('<', '<').replace('>', '>')
     str = str.rstrip().replace('(', '(').replace(')', ')')
     html = str.rstrip().replace("'", ''').replace('"', '"')
     return html
-
-
-def _get_translation_for_block(lang, txt, block):
-    """
-    """
-    if lang == 'en':
-        blockdict = translation_dict_en.get('_' + block, {})
-    elif lang == 'de':
-        blockdict = translation_dict_de.get('_' + block, {})
-    else:
-        blockdict = translation_dict.get('_' + block, {})
-
-    return blockdict.get(txt, '')
-        
-        
-def _get_translation(txt, block):
-    """
-    Get translation with fallback to english and further fallback to german
-    """
-    logger = logging.getLogger(__name__)
-
-    if block != '':
-        tr = _get_translation_for_block('', txt, block)
-        if tr == '':
-            logger.info("Blockly: Language '{0}': Translation for '{1}' is missing!".format(translation_lang, txt))
-            tr = _get_translation_for_block('en', txt, block)
-            if tr == '':
-                tr = _get_translation_for_block('de', txt, block)
-    else:
-        tr = translation_dict.get(txt, '')
-        if tr == '':
-            logger.info("Blockly: Language '{0}': Translation for '{1}' is missing".format(translation_lang, txt))
-            tr = translation_dict_en.get(txt, '')
-            if tr == '':
-                logger.info("Blockly: Language '{0}': Translation for '{1}' is missing".format('en', txt))
-                tr = translation_dict_de.get(txt, '')
-    return tr
-    
-
-def translate(txt, block=''):
-    """
-    returns translated text
-    
-    This function extends the jinja2 template engine
-    """
-    logger = logging.getLogger(__name__)
-
-    txt = str(txt)
-    if translation_lang == '':
-        tr = txt
-    else:
-        tr = _get_translation(txt, block)
-
-        if tr == '':
-            logger.info("Blockly: -> Language '{0}': Translation for '{1}' is missing".format(translation_lang, txt))
-            tr = txt
-    return html_escape(tr)
-
-
-#def create_hash(plaintext):
-#    import hashlib
-#    hashfunc = hashlib.sha512()
-#    hashfunc.update(plaintext.encode())
-#    return hashfunc.hexdigest()
-
-
-#def parse_requirements(file_path):
-#    fobj = open(file_path)
-#    req_dict = {}
-#    for line in fobj:
-#        if len(line) > 0 and '#' not in line:
-#            if ">" in line:
-#                if line[0:line.find(">")].lower().strip() in req_dict:
-#                    req_dict[line[0:line.find(">")].lower().strip()] += " | " + line[line.find(">"):len(
-#                        line)].lower().strip()
-#                else:
-#                    req_dict[line[0:line.find(">")].lower().strip()] = line[line.find(">"):len(line)].lower().strip()
-#            elif "<" in line:
-#                if line[0:line.find("<")].lower().strip() in req_dict:
-#                    req_dict[line[0:line.find("<")].lower().strip()] += " | " + line[line.find("<"):len(
-#                        line)].lower().strip()
-#                else:
-#                    req_dict[line[0:line.find("<")].lower().strip()] = line[line.find("<"):len(line)].lower().strip()
-#            elif "=" in line:
-#                if line[0:line.find("=")].lower().strip() in req_dict:
-#                    req_dict[line[0:line.find("=")].lower().strip()] += " | " + line[line.find("="):len(
-#                        line)].lower().strip()
-#                else:
-#                    req_dict[line[0:line.find("=")].lower().strip()] = line[line.find("="):len(line)].lower().strip()
-#    fobj.close()
-#    return req_dict
-
-
-#def strip_quotes(string):
-#    string = string.strip()
-#    if len(string) > 0:
-#        if string[0] in ['"', "'"]:  # check if string starts with ' or "
-#            if string[0] == string[-1]:  # and end with it
-#                if string.count(string[0]) == 2:  # if they are the only one
-#                    string = string[1:-1]  # remove them
-#    return string
-
-
diff --git a/blockly/webif/__init__.py b/blockly/webif/__init__.py
new file mode 100755
index 000000000..d48bfe62d
--- /dev/null
+++ b/blockly/webif/__init__.py
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab
+#########################################################################
+#  Copyright 2016-     Oliver Hinckel                  github@ollisnet.de
+#  Based on ideas of sqlite plugin by Marcus Popp marcus@popp.mx
+#########################################################################
+#  This file is part of SmartHomeNG.
+#
+#  Sample Web Interface for new plugins to run with SmartHomeNG version 1.4
+#  and upwards.
+#
+#  SmartHomeNG is free software: you can redistribute it and/or modify
+#  it under the terms of the GNU General Public License as published by
+#  the Free Software Foundation, either version 3 of the License, or
+#  (at your option) any later version.
+#
+#  SmartHomeNG is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with SmartHomeNG. If not, see .
+#
+#########################################################################
+
+import datetime
+import time
+import os
+import json
+
+from lib.item import Items
+from lib.model.smartplugin import SmartPluginWebIf
+from lib.logic import Logics          # für update der /etc/logic.yaml
+from lib.logic import Logic           # für reload (bytecode)
+from lib.utils import Utils
+from ..utils import *
+
+# ------------------------------------------
+#    Webinterface of the plugin
+# ------------------------------------------
+
+import cherrypy
+import csv
+from jinja2 import Environment, FileSystemLoader
+
+class WebInterface(SmartPluginWebIf):
+    logics = None
+
+    logicname = ''
+    logic_filename = ''
+    cmd = ''
+    edit_redirect = ''
+
+    def __init__(self, webif_dir, plugin):
+        """
+        Initialization of instance of class WebInterface
+
+        :param webif_dir: directory where the webinterface of the plugin resides
+        :param plugin: instance of the plugin
+        :type webif_dir: str
+        :type plugin: object
+        """
+        self.logger = plugin.logger
+        self.webif_dir = webif_dir
+        self.plugin = plugin
+        self.items = Items.get_instance()
+        self.plugininstance = plugin
+        self._sh = self.plugininstance.get_sh()
+        self._sh_dir = self._sh.base_dir
+        self._section_prefix = self.plugininstance._parameters.get('section_prefix','')
+        self.logger.debug("WebInterface: section_prefix = {}".format(self._section_prefix))
+        self.logicname = ''
+        self.logic_filename = ''
+
+        self.tplenv = self.init_template_environment()
+
+    def html_escape(self, str):
+        return html_escape(str)
+
+
+    @cherrypy.expose
+    def index(self):
+
+        return self.index_html()
+
+    @cherrypy.expose
+    def index_html(self, cmd='', filename='', logicname='', v=0):
+
+        self.logger.info("index_html: cmd = '{}', filename = '{}', logicname = '{}'".format(cmd, filename, logicname))
+        if self.edit_redirect != '':
+            self.edit_html(cmd='edit', logicname=self.edit_redirect)
+
+        if self.logics is None:
+            self.logics = Logics.get_instance()
+
+        cherrypy.lib.caching.expires(0)
+
+        if cmd == '' and filename == '' and logicname == '':
+            cmd = self.cmd
+            if cmd == '':
+                cmd = 'new'
+
+        self.cmd = cmd.lower()
+        self.logger.info("index_html: cmd = {}, filename = {}, logicname = {}".format(cmd, filename, logicname))
+        if self.cmd == '':
+#            self.logic_filename = ''
+            self.logicname = ''
+        elif self.cmd == 'new':
+            self.logic_filename = 'new'
+            self.logicname = ''
+        elif self.cmd == 'edit' and filename != '':
+            self.logic_filename = filename
+            self.logicname = logicname
+        self.logger.info("index_html: self.logicname = '{}', self.logic_filename = '{}'".format(self.logicname, self.logic_filename))
+        language = self._sh.get_defaultlanguage()
+
+        tmpl = self.tplenv.get_template('blockly.html')
+        return tmpl.render(smarthome=self._sh,
+                           p=self.plugin,
+                           dyn_sh_toolbox=self._DynToolbox(self._sh),
+                           cmd=self.cmd,
+                           logicname=logicname,
+                           lang=language,
+                           timestamp=str(time.time()))
+
+
+    @cherrypy.expose
+    def edit_html(self, cmd='', filename='', logicname='', v=0):
+
+        if self.logics is None:
+            self.logics = Logics.get_instance()
+
+        cherrypy.lib.caching.expires(0)
+
+        if cmd == '' and filename == '' and logicname == '':
+            cmd = self.cmd
+            if cmd == '':
+                cmd = 'new'
+
+        self.cmd = cmd.lower()
+        self.logger.info("edit_html: cmd = {}, filename = {}, logicname = {}".format(cmd, filename, logicname))
+        if self.cmd == '':
+#            self.logic_filename = ''
+            self.logicname = ''
+        elif self.cmd == 'new':
+            self.logic_filename = 'new'
+            self.logicname = ''
+        elif self.cmd == 'edit' and filename != '':
+            self.logic_filename = filename
+            self.logicname = logicname
+        self.logger.info("edit_html: self.logicname = '{}', self.logic_filename = '{}'".format(self.logicname, self.logic_filename))
+        language = self._sh.get_defaultlanguage()
+
+        tmpl = self.tplenv.get_template('blockly.html')
+        return tmpl.render(smarthome=self._sh,
+                           p=self.plugin,
+                           dyn_sh_toolbox=self._DynToolbox(self._sh),
+                           cmd=self.cmd,
+                           logicname=logicname,
+                           lang=language,
+                           timestamp=str(time.time()))
+
+
+    def _DynToolbox(self, sh):
+        mytree = self._build_tree()
+        return mytree + "-\n"
+
+
+    def _build_tree(self):
+        # Get top level items
+        toplevelitems = []
+        allitems = sorted(self._sh.return_items(), key=lambda k: str.lower(k['_path']), reverse=False)
+        for item in allitems:
+            if item._path.find('.') == -1:
+                if item._path not in ['env_daily', 'env_init', 'env_loc', 'env_stat']:
+                    toplevelitems.append(item)
+
+        xml = '\n'
+        for item in toplevelitems:
+            xml += self._build_treelevel(item)
+#        self.logger.info("log_tree #  xml -> '{}'".format(str(xml)))
+        return xml
+
+
+    def _build_treelevel(self, item, parent='', level=0):
+        """
+        Builds one tree level of the items
+
+        This methods calls itself recursively while there are further child items
+        """
+        childitems = sorted(item.return_children(), key=lambda k: str.lower(k['_path']), reverse=False)
+
+        name = remove_prefix(item._path, parent+'.')
+        if childitems != []:
+            xml = ''
+            if (item.type() != 'foo') or (item() != None):
+#                self.logger.info("item._path = '{}', item.type() = '{}', item() = '{}', childitems = '{}'".format(item._path, item.type(), str(item()), childitems))
+                xml += self._build_leaf(name, item, level+1)
+                xml += ''.ljust(3*(level)) + '\n'.format(name, len(childitems)+1)
+            else:
+                xml += ''.ljust(3*(level)) + '\n'.format(name, len(childitems))
+            for grandchild in childitems:
+                xml += self._build_treelevel(grandchild, item._path, level+1)
+
+            xml += ''.ljust(3*(level)) + '  # name={}\n'.format(item._path)
+        else:
+            xml = self._build_leaf(name, item, level)
+        return xml
+
+
+    def _build_leaf(self, name, item, level=0):
+        """
+        Builds the leaf information for an entry in the item tree
+        """
+#        n = item._path.title().replace('.','_')
+        n = item._path
+        xml = ''.ljust(3*(level)) + '\n'
+        xml += ''.ljust(3*(level+1)) + '' + n + '>\n'
+        xml += ''.ljust(3*(level+1)) + '' + item._path + '>\n'
+        xml += ''.ljust(3*(level+1)) + '' + item.type() + '>\n'
+        xml += ''.ljust(3*(level)) + '\n'
+        return xml
+
+
+    @cherrypy.expose
+    def blockly_close_editor(self, content=''):
+        self.logger.warning("blockly_close_editor: content = '{}'".format(content))
+
+        self.logic_filename = ''
+        return
+
+
+    @cherrypy.expose
+    def blockly_load_logic(self, uniq_param=''):
+        self.logger.info("blockly_load_logic: self.logicname = '{}', self.logic_filename = '{}'".format(self.logicname, self.logic_filename))
+        if self.logicname == '' and self.edit_redirect == '':
+            if self.logic_filename == 'new':
+                fn_xml = self.plugininstance.path_join( self.webif_dir, 'templates') + '/' + "new.blockly"
+            else:
+                fn_xml = self._sh._logic_dir + "blockly_logics.blockly"
+        else:
+            if self.logic_filename == '':
+                fn_xml = self.plugininstance.path_join( self.webif_dir, 'templates') + '/' + "new.blockly"
+            else:
+                fn_xml = self._sh._logic_dir + self.logic_filename
+        self.logger.info("blockly_load_logic: fn_xml = {}".format(fn_xml))
+        return cherrypy.lib.static.serve_file(fn_xml, content_type='application/xml')
+
+
+    def blockly_update_config(self, code, name=''):
+        """
+        Fill configuration section in /etc/logic.yaml from header lines in generated code
+
+        Method is called from blockly_save_logic()
+
+        :param code: Python code of the logic
+        :param name: name of configuration section, if ommited, the section name is read from the source code
+        :type code: str
+        :type name: str
+        """
+        section = ''
+        active = False
+        config_list = []
+        for line in code.splitlines():
+            if (line.startswith('#comment#')):
+                if config_list == []:
+                    sc, fn, ac, fnco = line[9:].split('#')
+                    fnk, fnv = fn.split(':')
+                    ack, acv = ac.split(':')
+                    active = Utils.to_bool(acv.strip(), False)
+                    if section == '':
+                        section = sc;
+                        self.logger.info("blockly_update_config: #comment# section = '{}'".format(section))
+                    config_list.append([fnk.strip(), fnv.strip(), fnco])
+            elif line.startswith('#trigger#'):
+                sc, fn, tr, co = line[9:].split('#')
+                trk, trv = tr.split(':')
+                if config_list == []:
+                    fnk, fnv = fn.split(':')
+                    fnco = ''
+                    config_list.append([fnk.strip(), fnv.strip(), fnco])
+                if section == '':
+                    section = sc;
+                    self.logger.info("blockly_update_config: #trigger# section = '{}'".format(section))
+                config_list.append([trk.strip(), trv.strip(),co])
+            elif line.startswith('"""'):    # initial .rst-comment reached, stop scanning
+                break
+            else:                           # non-metadata lines between beginning of code and initial .rst-comment
+                pass
+
+        if section == '':
+            section = name
+        if self._section_prefix != '':
+            section = self._section_prefix + section
+        self.logger.info("blockly_update_config: section = '{}'".format(section))
+
+        self.logics.update_config_section(active, section, config_list)
+
+
+    def pretty_print_xml(self, xml_in):
+        import xml.dom.minidom
+
+        xml = xml.dom.minidom.parseString(xml_in)
+        xml_out = xml.toprettyxml()
+        return xml_out
+
+
+    @cherrypy.expose
+    def blockly_save_logic(self, py, xml, name):
+        """
+        Save the logic - Saves the Blocky xml and the Python code
+
+        :param py:
+        :param xml:
+        :param name:
+        :type py:
+        :type xml:
+        :type name:
+        """
+        self._pycode = py
+        self._xmldata = xml
+        fn_py = self._sh._logic_dir + name.lower() + ".py"
+        self.logic_filename = name.lower() + ".blockly"
+        fn_xml = self._sh._logic_dir + self.logic_filename
+        self.logger.info("blockly_save_logic: saving blockly logic {} as file {}".format(name, fn_py))
+        self.logger.debug("blockly_save_logic: SAVE PY blockly logic {} = {}\n '{}'".format(name, fn_py, py))
+        with open(fn_py, 'w') as fpy:
+            fpy.write(py)
+        self.logger.debug("blockly_save_logic: SAVE XML blockly logic {} = {}\n '{}'".format(name, fn_xml, xml))
+        xml = self.pretty_print_xml(xml)
+        with open(fn_xml, 'w') as fxml:
+            fxml.write(xml)
+
+        self.blockly_update_config(self._pycode, name)
+
+        section = name
+        if self._section_prefix != '':
+            section = self._section_prefix + section
+
+        self.logics.load_logic(section)
+        self.edit_redirect = name
diff --git a/blockly/webif/static/blockly/LICENSE b/blockly/webif/static/blockly/LICENSE
old mode 100755
new mode 100644
diff --git a/blockly/webif/static/blockly/README.md b/blockly/webif/static/blockly/README.md
old mode 100755
new mode 100644
index 999f96b26..5a0f3b8f2
--- a/blockly/webif/static/blockly/README.md
+++ b/blockly/webif/static/blockly/README.md
@@ -1,37 +1,58 @@
-# Blockly [![Build Status]( https://travis-ci.org/google/blockly.svg?branch=master)](https://travis-ci.org/google/blockly)
+# Blockly
 
+Google's Blockly is a library that adds a visual code editor to web and mobile apps. The Blockly editor uses interlocking, graphical blocks to represent code concepts like variables, logical expressions, loops, and more. It allows users to apply programming principles without having to worry about syntax or the intimidation of a blinking cursor on the command line. All code is free and open source.
 
-Google's Blockly is a web-based, visual programming editor.  Users can drag
-blocks together to build programs.  All code is free and open source.
+![](https://developers.google.com/blockly/images/sample.png)
 
-**The project page is https://developers.google.com/blockly/**
+## Getting Started with Blockly
 
-![](https://developers.google.com/blockly/images/sample.png)
+Blockly has many resources for learning how to use the library. Start at our [Google Developers Site](https://developers.google.com/blockly) to read the documentation on how to get started, configure Blockly, and integrate it into your application. The developers site also contains links to:
 
-Blockly has an active [developer forum](https://groups.google.com/forum/#!forum/blockly).  Please drop by and say hello. Show us your prototypes early; collectively we have a lot of experience and can offer hints which will save you time. We actively monitor the forums and typically respond to questions within 2 working days.
+- [Getting Started article](https://developers.google.com/blockly/guides/get-started/web)
+- [Getting Started codelab](https://blocklycodelabs.dev/codelabs/getting-started/index.html#0)
+- [More codelabs](https://blocklycodelabs.dev/)
+- [Demos and plugins](https://google.github.io/blockly-samples/)
 
 Help us focus our development efforts by telling us [what you are doing with
-Blockly](https://developers.google.com/blockly/registration).  The questionnaire only takes
+Blockly](https://developers.google.com/blockly/registration). The questionnaire only takes
 a few minutes and will help us better support the Blockly community.
 
-Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com)
+### Installing Blockly
 
-Want to contribute? Great! First, read [our guidelines for contributors](https://developers.google.com/blockly/guides/modify/contributing).
-
-## Releases
+Blockly is [available on npm](https://www.npmjs.com/package/blockly).
 
-We release by pushing the latest code to the master branch, followed by updating our [docs](https://developers.google.com/blockly) and [demo pages](https://blockly-demo.appspot.com). We typically release a new version of Blockly once a quarter (every 3 months). If there are breaking bugs, such as a crash when performing a standard action or a rendering issue that makes Blockly unusable, we will cherry-pick fixes to master between releases to fix them. The [releases page](https://github.com/google/blockly/releases) has a list of all releases.
-
-Releases are tagged by the release date (YYYYMMDD) with a leading '2.' and a trailing '.0' in case we ever need a major or minor version (such as [2.20190722.1](https://github.com/google/blockly/tree/2.20190722.1)). If you're using npm, you can install the ``blockly`` package on npm: 
 ```bash
 npm install blockly
 ```
 
-### New APIs
+For more information on installing and using Blockly, see the [Getting Started article](https://developers.google.com/blockly/guides/get-started/web).
 
-Once a new API is merged into master it is considered beta until the following release. We generally try to avoid changing an API after it has been merged to master, but sometimes we need to make changes after seeing how an API is used. If an API has been around for at least two releases we'll do our best to avoid breaking it.
+### Getting Help
 
-Unreleased APIs may change radically. Anything that is in `develop` but not `master` is subject to change without warning.
+- [Report a bug](https://developers.google.com/blockly/guides/modify/contribute/write_a_good_issue) or file a feature request on GitHub
+- Ask a question, or search others' questions, on our [developer forum](https://groups.google.com/forum/#!forum/blockly). You can also drop by to say hello and show us your prototypes; collectively we have a lot of experience and can offer hints which will save you time. We actively monitor the forums and typically respond to questions within 2 working days.
+
+### blockly-samples
+
+We have a number of resources such as example code, demos, and plugins in another repository called [blockly-samples](https://github.com/google/blockly-samples/). A plugin is a self-contained piece of code that adds functionality to Blockly. Plugins can add fields, define themes, create renderers, and much more. For more information, see the [Plugins documentation](https://developers.google.com/blockly/guides/plugins/overview).
+
+## Contributing to Blockly
+
+Want to make Blockly better? We welcome contributions to Blockly in the form of pull requests, bug reports, documentation, answers on the forum, and more! Check out our [Contributing Guidelines](https://developers.google.com/blockly/guides/modify/contributing) for more information. You might also want to look for issues tagged "[Help Wanted](https://github.com/google/blockly/labels/help%20wanted)" which are issues we think would be great for external contributors to help with.
+
+## Releases
+
+We release by pushing the latest code to the master branch, followed by updating the npm package, our [docs](https://developers.google.com/blockly), and [demo pages](https://google.github.io/blockly-samples/). If there are breaking bugs, such as a crash when performing a standard action or a rendering issue that makes Blockly unusable, we will cherry-pick fixes to master between releases to fix them. The [releases page](https://github.com/google/blockly/releases) has a list of all releases.
+
+We use [semantic versioning](https://semver.org/). Releases that have breaking changes or are otherwise not backwards compatible will have a new major version. Patch versions are reserved for bug-fix patches between scheduled releases.
+
+We now have a [beta release on npm](https://www.npmjs.com/package/blockly?activeTab=versions). If you'd like to test the upcoming release, or try out a not-yet-released new API, you can use the beta channel with:
+
+```bash
+npm install blockly@beta
+```
+
+As it is a beta channel, it may be less stable, and the APIs there are subject to change.
 
 ### Branches
 
@@ -43,14 +64,17 @@ There are two main branches for Blockly.
 
 **other branches:** - Larger changes may have their own branches until they are good enough for people to try out. These will be developed separately until we think they are almost ready for release. These branches typically get merged into develop immediately after a release to allow extra time for testing.
 
-## Issues and Milestones
+### New APIs
+
+Once a new API is merged into master it is considered beta until the following release. We generally try to avoid changing an API after it has been merged to master, but sometimes we need to make changes after seeing how an API is used. If an API has been around for at least two releases we'll do our best to avoid breaking it.
 
-We typically triage all bugs within 2 working days, which includes adding any appropriate labels and assigning it to a milestone. Please keep in mind, we are a small team so even feature requests that everyone agrees on may not be prioritized.
+Unreleased APIs may change radically. Anything that is in `develop` but not `master` is subject to change without warning.
 
-### Milestones
+## Issues and Milestones
 
-**Upcoming release** - The upcoming release milestone is for all bugs we plan on fixing before the next release. This typically has the form of `year_quarter_release` (such as `2019_q2_release`). Some bugs will be added to this release when they are triaged, others may be added closer to a release.
+We typically triage all bugs within 1 week, which includes adding any appropriate labels and assigning it to a milestone. Please keep in mind, we are a small team so even feature requests that everyone agrees on may not be prioritized.
 
-**Bug Bash Backlog** - These are bugs that we're still prioritizing. They haven't been added to a specific release yet, but we'll consider them for each release depending on relative priority and available time.
+## Good to Know
 
-**Icebox** - These are bugs that we do not intend to spend time on. They are either too much work or minor enough that we don't expect them to ever take priority. We are still happy to accept pull requests for these bugs.
+- Cross-browser Testing Platform and Open Source <3 Provided by [Sauce Labs](https://saucelabs.com)
+- We test browsers using [BrowserStack](https://browserstack.com)
diff --git a/blockly/webif/static/blockly/blockly_compressed.js b/blockly/webif/static/blockly/blockly_compressed.js
old mode 100755
new mode 100644
index 9de0658d8..43b7326ab
--- a/blockly/webif/static/blockly/blockly_compressed.js
+++ b/blockly/webif/static/blockly/blockly_compressed.js
@@ -1,1229 +1,1659 @@
-// Do not edit this file; automatically generated by build.py.
-'use strict';
+// Do not edit this file; automatically generated.
 
-
-var Blockly={};Blockly.Blocks=Object.create(null);
-Blockly.utils={};Blockly.utils.global=function(){return"object"===typeof self?self:"object"===typeof window?window:"object"===typeof global?global:this}();Blockly.Msg={};Blockly.utils.global.Blockly||(Blockly.utils.global.Blockly={});Blockly.utils.global.Blockly.Msg||(Blockly.utils.global.Blockly.Msg=Blockly.Msg);Blockly.utils.Coordinate=function(a,b){this.x=a;this.y=b};Blockly.utils.Coordinate.equals=function(a,b){return a==b?!0:a&&b?a.x==b.x&&a.y==b.y:!1};Blockly.utils.Coordinate.distance=function(a,b){var c=a.x-b.x,d=a.y-b.y;return Math.sqrt(c*c+d*d)};Blockly.utils.Coordinate.magnitude=function(a){return Math.sqrt(a.x*a.x+a.y*a.y)};Blockly.utils.Coordinate.difference=function(a,b){return new Blockly.utils.Coordinate(a.x-b.x,a.y-b.y)};
-Blockly.utils.Coordinate.sum=function(a,b){return new Blockly.utils.Coordinate(a.x+b.x,a.y+b.y)};Blockly.utils.Coordinate.prototype.scale=function(a){this.x*=a;this.y*=a;return this};Blockly.utils.Coordinate.prototype.translate=function(a,b){this.x+=a;this.y+=b;return this};Blockly.utils.string={};Blockly.utils.string.startsWith=function(a,b){return 0==a.lastIndexOf(b,0)};Blockly.utils.string.shortestStringLength=function(a){return a.length?a.reduce(function(a,c){return a.lengthb&&(b=c[d].length);d=-Infinity;var e=1;do{var f=d;var g=a;var h=[],k=c.length/e,l=1;for(d=0;df);return g};
-Blockly.utils.string.wrapScore_=function(a,b,c){for(var d=[0],e=[],f=0;fd&&(d=h,e=g)}return e?Blockly.utils.string.wrapMutate_(a,e,c):b};Blockly.utils.string.wrapToText_=function(a,b){for(var c=[],d=0;d=k?(e=2,g=k,(k=f.join(""))&&c.push(k),f.length=0):"{"==k?e=3:(f.push("%",k),e=0):2==e?"0"<=k&&"9">=k?g+=k:(c.push(parseInt(g,10)),h--,e=0):3==e&&(""==k?(f.splice(0,0,"%{"),h--,e=0):"}"!=k?f.push(k):(e=f.join(""),/[A-Z]\w*/i.test(e)?(k=e.toUpperCase(),(k=
-Blockly.utils.string.startsWith(k,"BKY_")?k.substring(4):null)&&k in Blockly.Msg?(e=Blockly.Msg[k],"string"==typeof e?Array.prototype.push.apply(c,Blockly.utils.tokenizeInterpolation_(e,b)):b?c.push(String(e)):c.push(e)):c.push("%{"+e+"}")):c.push("%{"+e+"}"),e=f.length=0))}(k=f.join(""))&&c.push(k);d=[];for(h=f.length=0;hc;c++)b[c]=Blockly.utils.genUid.soup_.charAt(Math.random()*a);return b.join("")};Blockly.utils.genUid.soup_="!#$%()*+,-./:;=?@[]^_`{|}~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
-Blockly.utils.is3dSupported=function(){if(void 0!==Blockly.utils.is3dSupported.cached_)return Blockly.utils.is3dSupported.cached_;if(!Blockly.utils.global.getComputedStyle)return!1;var a=document.createElement("p"),b="none",c={webkitTransform:"-webkit-transform",OTransform:"-o-transform",msTransform:"-ms-transform",MozTransform:"-moz-transform",transform:"transform"};document.body.insertBefore(a,null);for(var d in c)if(void 0!==a.style[d]){a.style[d]="translate3d(1px,1px,1px)";b=Blockly.utils.global.getComputedStyle(a);
-if(!b)return document.body.removeChild(a),!1;b=b.getPropertyValue(c[d])}document.body.removeChild(a);Blockly.utils.is3dSupported.cached_="none"!==b;return Blockly.utils.is3dSupported.cached_};Blockly.utils.runAfterPageLoad=function(a){if("object"!=typeof document)throw Error("Blockly.utils.runAfterPageLoad() requires browser document.");if("complete"==document.readyState)a();else var b=setInterval(function(){"complete"==document.readyState&&(clearInterval(b),a())},10)};
-Blockly.utils.getViewportBBox=function(){var a=Blockly.utils.style.getViewportPageOffset();return{right:document.documentElement.clientWidth+a.x,bottom:document.documentElement.clientHeight+a.y,top:a.y,left:a.x}};Blockly.utils.arrayRemove=function(a,b){var c=a.indexOf(b);if(-1==c)return!1;a.splice(c,1);return!0};
-Blockly.utils.getDocumentScroll=function(){var a=document.documentElement,b=window;return Blockly.utils.userAgent.IE&&b.pageYOffset!=a.scrollTop?new Blockly.utils.Coordinate(a.scrollLeft,a.scrollTop):new Blockly.utils.Coordinate(b.pageXOffset||a.scrollLeft,b.pageYOffset||a.scrollTop)};
-Blockly.utils.getBlockTypeCounts=function(a,b){var c=Object.create(null),d=a.getDescendants(!0);if(b){var e=a.getNextBlock();e&&(e=d.indexOf(e),d.splice(e,d.length-e))}e=0;for(var f;f=d[e];e++)c[f.type]?c[f.type]++:c[f.type]=1;return c};Blockly.utils.screenToWsCoordinates=function(a,b){var c=b.x,d=b.y,e=a.getInjectionDiv().getBoundingClientRect();c=new Blockly.utils.Coordinate(c-e.left,d-e.top);d=a.getOriginOffsetInPixels();return Blockly.utils.Coordinate.difference(c,d).scale(1/a.scale)};
-Blockly.Events={};Blockly.Events.group_="";Blockly.Events.recordUndo=!0;Blockly.Events.disabled_=0;Blockly.Events.CREATE="create";Blockly.Events.BLOCK_CREATE=Blockly.Events.CREATE;Blockly.Events.DELETE="delete";Blockly.Events.BLOCK_DELETE=Blockly.Events.DELETE;Blockly.Events.CHANGE="change";Blockly.Events.BLOCK_CHANGE=Blockly.Events.CHANGE;Blockly.Events.MOVE="move";Blockly.Events.BLOCK_MOVE=Blockly.Events.MOVE;Blockly.Events.VAR_CREATE="var_create";Blockly.Events.VAR_DELETE="var_delete";
-Blockly.Events.VAR_RENAME="var_rename";Blockly.Events.UI="ui";Blockly.Events.COMMENT_CREATE="comment_create";Blockly.Events.COMMENT_DELETE="comment_delete";Blockly.Events.COMMENT_CHANGE="comment_change";Blockly.Events.COMMENT_MOVE="comment_move";Blockly.Events.FINISHED_LOADING="finished_loading";Blockly.Events.BUMP_EVENTS=[Blockly.Events.BLOCK_CREATE,Blockly.Events.BLOCK_MOVE,Blockly.Events.COMMENT_CREATE,Blockly.Events.COMMENT_MOVE];Blockly.Events.FIRE_QUEUE_=[];
-Blockly.Events.fire=function(a){Blockly.Events.isEnabled()&&(Blockly.Events.FIRE_QUEUE_.length||setTimeout(Blockly.Events.fireNow_,0),Blockly.Events.FIRE_QUEUE_.push(a))};Blockly.Events.fireNow_=function(){for(var a=Blockly.Events.filter(Blockly.Events.FIRE_QUEUE_,!0),b=Blockly.Events.FIRE_QUEUE_.length=0,c;c=a[b];b++)if(c.workspaceId){var d=Blockly.Workspace.getById(c.workspaceId);d&&d.fireChangeListener(c)}};
-Blockly.Events.filter=function(a,b){var c=a.slice();b||c.reverse();for(var d=[],e=Object.create(null),f=0,g;g=c[f];f++)if(!g.isNull()){var h=[g.type,g.blockId,g.workspaceId].join(" "),k=e[h],l=k?k.event:null;if(!k)e[h]={event:g,index:f},d.push(g);else if(g.type==Blockly.Events.MOVE&&k.index==f-1)l.newParentId=g.newParentId,l.newInputName=g.newInputName,l.newCoordinate=g.newCoordinate,k.index=f;else if(g.type==Blockly.Events.CHANGE&&g.element==l.element&&g.name==l.name)l.newValue=g.newValue;else if(g.type!=
-Blockly.Events.UI||"click"!=g.element||"commentOpen"!=l.element&&"mutatorOpen"!=l.element&&"warningOpen"!=l.element)e[h]={event:g,index:1},d.push(g)}c=d.filter(function(a){return!a.isNull()});b||c.reverse();for(f=1;g=c[f];f++)g.type==Blockly.Events.CHANGE&&"mutation"==g.element&&c.unshift(c.splice(f,1)[0]);return c};Blockly.Events.clearPendingUndo=function(){for(var a=0,b;b=Blockly.Events.FIRE_QUEUE_[a];a++)b.recordUndo=!1};Blockly.Events.disable=function(){Blockly.Events.disabled_++};
-Blockly.Events.enable=function(){Blockly.Events.disabled_--};Blockly.Events.isEnabled=function(){return 0==Blockly.Events.disabled_};Blockly.Events.getGroup=function(){return Blockly.Events.group_};Blockly.Events.setGroup=function(a){Blockly.Events.group_="boolean"==typeof a?a?Blockly.utils.genUid():"":a};Blockly.Events.getDescendantIds=function(a){var b=[];a=a.getDescendants(!1);for(var c=0,d;d=a[c];c++)b[c]=d.id;return b};
-Blockly.Events.fromJson=function(a,b){switch(a.type){case Blockly.Events.CREATE:var c=new Blockly.Events.Create(null);break;case Blockly.Events.DELETE:c=new Blockly.Events.Delete(null);break;case Blockly.Events.CHANGE:c=new Blockly.Events.Change(null,"","","","");break;case Blockly.Events.MOVE:c=new Blockly.Events.Move(null);break;case Blockly.Events.VAR_CREATE:c=new Blockly.Events.VarCreate(null);break;case Blockly.Events.VAR_DELETE:c=new Blockly.Events.VarDelete(null);break;case Blockly.Events.VAR_RENAME:c=
-new Blockly.Events.VarRename(null,"");break;case Blockly.Events.UI:c=new Blockly.Events.Ui(null,"","","");break;case Blockly.Events.COMMENT_CREATE:c=new Blockly.Events.CommentCreate(null);break;case Blockly.Events.COMMENT_CHANGE:c=new Blockly.Events.CommentChange(null,"","");break;case Blockly.Events.COMMENT_MOVE:c=new Blockly.Events.CommentMove(null);break;case Blockly.Events.COMMENT_DELETE:c=new Blockly.Events.CommentDelete(null);break;default:throw Error("Unknown event type.");}c.fromJson(a);c.workspaceId=
-b.id;return c};Blockly.Events.disableOrphans=function(a){if((a.type==Blockly.Events.MOVE||a.type==Blockly.Events.CREATE)&&a.workspaceId){var b=Blockly.Workspace.getById(a.workspaceId);if(a=b.getBlockById(a.blockId)){var c=a.getParent();if(c&&c.isEnabled())for(b=a.getDescendants(!1),a=0;c=b[a];a++)c.setEnabled(!0);else if((a.outputConnection||a.previousConnection)&&!b.isDragging()){do a.setEnabled(!1),a=a.getNextBlock();while(a)}}}};
-Blockly.Events.Abstract=function(){this.workspaceId=void 0;this.group=Blockly.Events.getGroup();this.recordUndo=Blockly.Events.recordUndo};Blockly.Events.Abstract.prototype.toJson=function(){var a={type:this.type};this.group&&(a.group=this.group);return a};Blockly.Events.Abstract.prototype.fromJson=function(a){this.group=a.group};Blockly.Events.Abstract.prototype.isNull=function(){return!1};Blockly.Events.Abstract.prototype.run=function(a){};
-Blockly.Events.Abstract.prototype.getEventWorkspace_=function(){if(this.workspaceId)var a=Blockly.Workspace.getById(this.workspaceId);if(!a)throw Error("Workspace is null. Event must have been generated from real Blockly events.");return a};Blockly.utils.object={};Blockly.utils.object.inherits=function(a,b){a.superClass_=b.prototype;a.prototype=Object.create(b.prototype);a.prototype.constructor=a};Blockly.utils.object.mixin=function(a,b){for(var c in b)a[c]=b[c]};Blockly.utils.object.values=function(a){return Object.values?Object.values(a):Object.keys(a).map(function(b){return a[b]})};Blockly.utils.xml={};Blockly.utils.xml.NAME_SPACE="https://developers.google.com/blockly/xml";Blockly.utils.xml.document=function(){return document};Blockly.utils.xml.createElement=function(a){return Blockly.utils.xml.document().createElementNS(Blockly.utils.xml.NAME_SPACE,a)};Blockly.utils.xml.createTextNode=function(a){return Blockly.utils.xml.document().createTextNode(a)};Blockly.utils.xml.textToDomDocument=function(a){return(new DOMParser).parseFromString(a,"text/xml")};
-Blockly.utils.xml.domToText=function(a){return(new XMLSerializer).serializeToString(a)};Blockly.Events.BlockBase=function(a){Blockly.Events.BlockBase.superClass_.constructor.call(this);this.blockId=a.id;this.workspaceId=a.workspace.id};Blockly.utils.object.inherits(Blockly.Events.BlockBase,Blockly.Events.Abstract);Blockly.Events.BlockBase.prototype.toJson=function(){var a=Blockly.Events.BlockBase.superClass_.toJson.call(this);a.blockId=this.blockId;return a};
-Blockly.Events.BlockBase.prototype.fromJson=function(a){Blockly.Events.BlockBase.superClass_.fromJson.call(this,a);this.blockId=a.blockId};Blockly.Events.Change=function(a,b,c,d,e){a&&(Blockly.Events.Change.superClass_.constructor.call(this,a),this.element=b,this.name=c,this.oldValue=d,this.newValue=e)};Blockly.utils.object.inherits(Blockly.Events.Change,Blockly.Events.BlockBase);Blockly.Events.BlockChange=Blockly.Events.Change;Blockly.Events.Change.prototype.type=Blockly.Events.CHANGE;
-Blockly.Events.Change.prototype.toJson=function(){var a=Blockly.Events.Change.superClass_.toJson.call(this);a.element=this.element;this.name&&(a.name=this.name);a.newValue=this.newValue;return a};Blockly.Events.Change.prototype.fromJson=function(a){Blockly.Events.Change.superClass_.fromJson.call(this,a);this.element=a.element;this.name=a.name;this.newValue=a.newValue};Blockly.Events.Change.prototype.isNull=function(){return this.oldValue==this.newValue};
-Blockly.Events.Change.prototype.run=function(a){var b=this.getEventWorkspace_().getBlockById(this.blockId);if(b)switch(b.mutator&&b.mutator.setVisible(!1),a=a?this.newValue:this.oldValue,this.element){case "field":(b=b.getField(this.name))?b.setValue(a):console.warn("Can't set non-existent field: "+this.name);break;case "comment":b.setCommentText(a||null);break;case "collapsed":b.setCollapsed(!!a);break;case "disabled":b.setEnabled(!a);break;case "inline":b.setInputsInline(!!a);break;case "mutation":var c=
-"";b.mutationToDom&&(c=(c=b.mutationToDom())&&Blockly.Xml.domToText(c));if(b.domToMutation){var d=Blockly.Xml.textToDom(a||"");b.domToMutation(d)}Blockly.Events.fire(new Blockly.Events.Change(b,"mutation",null,c,a));break;default:console.warn("Unknown change type: "+this.element)}else console.warn("Can't change non-existent block: "+this.blockId)};
-Blockly.Events.Create=function(a){a&&(Blockly.Events.Create.superClass_.constructor.call(this,a),this.xml=a.workspace.rendered?Blockly.Xml.blockToDomWithXY(a):Blockly.Xml.blockToDom(a),this.ids=Blockly.Events.getDescendantIds(a))};Blockly.utils.object.inherits(Blockly.Events.Create,Blockly.Events.BlockBase);Blockly.Events.BlockCreate=Blockly.Events.Create;Blockly.Events.Create.prototype.type=Blockly.Events.CREATE;
-Blockly.Events.Create.prototype.toJson=function(){var a=Blockly.Events.Create.superClass_.toJson.call(this);a.xml=Blockly.Xml.domToText(this.xml);a.ids=this.ids;return a};Blockly.Events.Create.prototype.fromJson=function(a){Blockly.Events.Create.superClass_.fromJson.call(this,a);this.xml=Blockly.Xml.textToDom(a.xml);this.ids=a.ids};
-Blockly.Events.Create.prototype.run=function(a){var b=this.getEventWorkspace_();if(a)a=Blockly.utils.xml.createElement("xml"),a.appendChild(this.xml),Blockly.Xml.domToWorkspace(a,b);else{a=0;for(var c;c=this.ids[a];a++){var d=b.getBlockById(c);d?d.dispose(!1):c==this.blockId&&console.warn("Can't uncreate non-existent block: "+c)}}};
-Blockly.Events.Delete=function(a){if(a){if(a.getParent())throw Error("Connected blocks cannot be deleted.");Blockly.Events.Delete.superClass_.constructor.call(this,a);this.oldXml=a.workspace.rendered?Blockly.Xml.blockToDomWithXY(a):Blockly.Xml.blockToDom(a);this.ids=Blockly.Events.getDescendantIds(a)}};Blockly.utils.object.inherits(Blockly.Events.Delete,Blockly.Events.BlockBase);Blockly.Events.BlockDelete=Blockly.Events.Delete;Blockly.Events.Delete.prototype.type=Blockly.Events.DELETE;
-Blockly.Events.Delete.prototype.toJson=function(){var a=Blockly.Events.Delete.superClass_.toJson.call(this);a.ids=this.ids;return a};Blockly.Events.Delete.prototype.fromJson=function(a){Blockly.Events.Delete.superClass_.fromJson.call(this,a);this.ids=a.ids};
-Blockly.Events.Delete.prototype.run=function(a){var b=this.getEventWorkspace_();if(a){a=0;for(var c;c=this.ids[a];a++){var d=b.getBlockById(c);d?d.dispose(!1):c==this.blockId&&console.warn("Can't delete non-existent block: "+c)}}else a=Blockly.utils.xml.createElement("xml"),a.appendChild(this.oldXml),Blockly.Xml.domToWorkspace(a,b)};
-Blockly.Events.Move=function(a){a&&(Blockly.Events.Move.superClass_.constructor.call(this,a),a=this.currentLocation_(),this.oldParentId=a.parentId,this.oldInputName=a.inputName,this.oldCoordinate=a.coordinate)};Blockly.utils.object.inherits(Blockly.Events.Move,Blockly.Events.BlockBase);Blockly.Events.BlockMove=Blockly.Events.Move;Blockly.Events.Move.prototype.type=Blockly.Events.MOVE;
-Blockly.Events.Move.prototype.toJson=function(){var a=Blockly.Events.Move.superClass_.toJson.call(this);this.newParentId&&(a.newParentId=this.newParentId);this.newInputName&&(a.newInputName=this.newInputName);this.newCoordinate&&(a.newCoordinate=Math.round(this.newCoordinate.x)+","+Math.round(this.newCoordinate.y));return a};
-Blockly.Events.Move.prototype.fromJson=function(a){Blockly.Events.Move.superClass_.fromJson.call(this,a);this.newParentId=a.newParentId;this.newInputName=a.newInputName;a.newCoordinate&&(a=a.newCoordinate.split(","),this.newCoordinate=new Blockly.utils.Coordinate(Number(a[0]),Number(a[1])))};Blockly.Events.Move.prototype.recordNew=function(){var a=this.currentLocation_();this.newParentId=a.parentId;this.newInputName=a.inputName;this.newCoordinate=a.coordinate};
-Blockly.Events.Move.prototype.currentLocation_=function(){var a=this.getEventWorkspace_().getBlockById(this.blockId),b={},c=a.getParent();if(c){if(b.parentId=c.id,a=c.getInputWithBlock(a))b.inputName=a.name}else b.coordinate=a.getRelativeToSurfaceXY();return b};Blockly.Events.Move.prototype.isNull=function(){return this.oldParentId==this.newParentId&&this.oldInputName==this.newInputName&&Blockly.utils.Coordinate.equals(this.oldCoordinate,this.newCoordinate)};
-Blockly.Events.Move.prototype.run=function(a){var b=this.getEventWorkspace_(),c=b.getBlockById(this.blockId);if(c){var d=a?this.newParentId:this.oldParentId,e=a?this.newInputName:this.oldInputName;a=a?this.newCoordinate:this.oldCoordinate;var f=null;if(d&&(f=b.getBlockById(d),!f)){console.warn("Can't connect to non-existent block: "+d);return}c.getParent()&&c.unplug();if(a)e=c.getRelativeToSurfaceXY(),c.moveBy(a.x-e.x,a.y-e.y);else{c=c.outputConnection||c.previousConnection;if(e){if(b=f.getInput(e))var g=
-b.connection}else c.type==Blockly.PREVIOUS_STATEMENT&&(g=f.nextConnection);g?c.connect(g):console.warn("Can't connect to non-existent input: "+e)}}else console.warn("Can't move non-existent block: "+this.blockId)};Blockly.Events.FinishedLoading=function(a){this.workspaceId=a.id;this.group=Blockly.Events.getGroup();this.recordUndo=!1};Blockly.utils.object.inherits(Blockly.Events.FinishedLoading,Blockly.Events.Abstract);Blockly.Events.FinishedLoading.prototype.type=Blockly.Events.FINISHED_LOADING;Blockly.Events.FinishedLoading.prototype.toJson=function(){var a={type:this.type};this.group&&(a.group=this.group);this.workspaceId&&(a.workspaceId=this.workspaceId);return a};
-Blockly.Events.FinishedLoading.prototype.fromJson=function(a){this.workspaceId=a.workspaceId;this.group=a.group};Blockly.Events.VarBase=function(a){Blockly.Events.VarBase.superClass_.constructor.call(this);this.varId=a.getId();this.workspaceId=a.workspace.id};Blockly.utils.object.inherits(Blockly.Events.VarBase,Blockly.Events.Abstract);Blockly.Events.VarBase.prototype.toJson=function(){var a=Blockly.Events.VarBase.superClass_.toJson.call(this);a.varId=this.varId;return a};Blockly.Events.VarBase.prototype.fromJson=function(a){Blockly.Events.VarBase.superClass_.toJson.call(this);this.varId=a.varId};
-Blockly.Events.VarCreate=function(a){a&&(Blockly.Events.VarCreate.superClass_.constructor.call(this,a),this.varType=a.type,this.varName=a.name)};Blockly.utils.object.inherits(Blockly.Events.VarCreate,Blockly.Events.VarBase);Blockly.Events.VarCreate.prototype.type=Blockly.Events.VAR_CREATE;Blockly.Events.VarCreate.prototype.toJson=function(){var a=Blockly.Events.VarCreate.superClass_.toJson.call(this);a.varType=this.varType;a.varName=this.varName;return a};
-Blockly.Events.VarCreate.prototype.fromJson=function(a){Blockly.Events.VarCreate.superClass_.fromJson.call(this,a);this.varType=a.varType;this.varName=a.varName};Blockly.Events.VarCreate.prototype.run=function(a){var b=this.getEventWorkspace_();a?b.createVariable(this.varName,this.varType,this.varId):b.deleteVariableById(this.varId)};Blockly.Events.VarDelete=function(a){a&&(Blockly.Events.VarDelete.superClass_.constructor.call(this,a),this.varType=a.type,this.varName=a.name)};
-Blockly.utils.object.inherits(Blockly.Events.VarDelete,Blockly.Events.VarBase);Blockly.Events.VarDelete.prototype.type=Blockly.Events.VAR_DELETE;Blockly.Events.VarDelete.prototype.toJson=function(){var a=Blockly.Events.VarDelete.superClass_.toJson.call(this);a.varType=this.varType;a.varName=this.varName;return a};Blockly.Events.VarDelete.prototype.fromJson=function(a){Blockly.Events.VarDelete.superClass_.fromJson.call(this,a);this.varType=a.varType;this.varName=a.varName};
-Blockly.Events.VarDelete.prototype.run=function(a){var b=this.getEventWorkspace_();a?b.deleteVariableById(this.varId):b.createVariable(this.varName,this.varType,this.varId)};Blockly.Events.VarRename=function(a,b){a&&(Blockly.Events.VarRename.superClass_.constructor.call(this,a),this.oldName=a.name,this.newName=b)};Blockly.utils.object.inherits(Blockly.Events.VarRename,Blockly.Events.VarBase);Blockly.Events.VarRename.prototype.type=Blockly.Events.VAR_RENAME;
-Blockly.Events.VarRename.prototype.toJson=function(){var a=Blockly.Events.VarRename.superClass_.toJson.call(this);a.oldName=this.oldName;a.newName=this.newName;return a};Blockly.Events.VarRename.prototype.fromJson=function(a){Blockly.Events.VarRename.superClass_.fromJson.call(this,a);this.oldName=a.oldName;this.newName=a.newName};Blockly.Events.VarRename.prototype.run=function(a){var b=this.getEventWorkspace_();a?b.renameVariableById(this.varId,this.newName):b.renameVariableById(this.varId,this.oldName)};Blockly.utils.dom={};Blockly.utils.dom.SVG_NS="http://www.w3.org/2000/svg";Blockly.utils.dom.HTML_NS="http://www.w3.org/1999/xhtml";Blockly.utils.dom.XLINK_NS="http://www.w3.org/1999/xlink";Blockly.utils.dom.Node={ELEMENT_NODE:1,TEXT_NODE:3,COMMENT_NODE:8,DOCUMENT_POSITION_CONTAINED_BY:16};Blockly.utils.dom.cacheWidths_=null;Blockly.utils.dom.cacheReference_=0;
-Blockly.utils.dom.createSvgElement=function(a,b,c){a=document.createElementNS(Blockly.utils.dom.SVG_NS,a);for(var d in b)a.setAttribute(d,b[d]);document.body.runtimeStyle&&(a.runtimeStyle=a.currentStyle=a.style);c&&c.appendChild(a);return a};Blockly.utils.dom.addClass=function(a,b){var c=a.getAttribute("class")||"";if(-1!=(" "+c+" ").indexOf(" "+b+" "))return!1;c&&(c+=" ");a.setAttribute("class",c+b);return!0};
-Blockly.utils.dom.removeClass=function(a,b){var c=a.getAttribute("class");if(-1==(" "+c+" ").indexOf(" "+b+" "))return!1;c=c.split(/\s+/);for(var d=0;d]*[^/])?>[^<]*)\n([^<]*<\/)/;do{var c=a;a=a.replace(b,"$1
$2")}while(a!=c);return a};Blockly.Xml.domToPrettyText=function(a){a=Blockly.Xml.domToText(a).split("<");for(var b="",c=1;c"!=d.slice(-2)&&(b+="  ")}a=a.join("\n");a=a.replace(/(<(\w+)\b[^>]*>[^\n]*)\n *<\/\2>/g,"$1");return a.replace(/^\n/,"")};
-Blockly.Xml.textToDom=function(a){var b=Blockly.utils.xml.textToDomDocument(a);if(!b||!b.documentElement||b.getElementsByTagName("parsererror").length)throw Error("textToDom was unable to parse: "+a);return b.documentElement};Blockly.Xml.clearWorkspaceAndLoadFromXml=function(a,b){b.setResizesEnabled(!1);b.clear();var c=Blockly.Xml.domToWorkspace(a,b);b.setResizesEnabled(!0);return c};
-Blockly.Xml.domToWorkspace=function(a,b){if(a instanceof Blockly.Workspace){var c=a;a=b;b=c;console.warn("Deprecated call to Blockly.Xml.domToWorkspace, swap the arguments.")}var d;b.RTL&&(d=b.getWidth());c=[];Blockly.utils.dom.startTextWidthCache();var e=a.childNodes.length,f=Blockly.Events.getGroup();f||Blockly.Events.setGroup(!0);b.setResizesEnabled&&b.setResizesEnabled(!1);var g=!0;try{for(var h=0;hc)){var d=b.getSvgXY(a.getSvgRoot());a.outputConnection?(d.x+=(a.RTL?3:-3)*c,d.y+=13*c):a.previousConnection&&(d.x+=(a.RTL?-23:23)*c,d.y+=3*c);a=Blockly.utils.dom.createSvgElement("circle",{cx:d.x,cy:d.y,r:0,fill:"none",stroke:"#888","stroke-width":10},b.getParentSvg());Blockly.blockAnimations.connectionUiStep_(a,new Date,c)}};
-Blockly.blockAnimations.connectionUiStep_=function(a,b,c){var d=(new Date-b)/150;1a.workspace.scale)){var b=a.getHeightWidth().height;b=Math.atan(10/b)/Math.PI*180;a.RTL||(b*=-1);Blockly.blockAnimations.disconnectUiStep_(a.getSvgRoot(),b,new Date)}};
-Blockly.blockAnimations.disconnectUiStep_=function(a,b,c){var d=(new Date-c)/200;1c-Blockly.CURRENT_CONNECTION_PREFERENCE)}if(this.localConnection_||this.closestConnection_)console.error("Only one of localConnection_ and closestConnection_ was set.");
-else return!0}else return!(!this.localConnection_||!this.closestConnection_);console.error("Returning true from shouldUpdatePreviews, but it's not clear why.");return!0};Blockly.InsertionMarkerManager.prototype.getCandidate_=function(a){for(var b=this.getStartRadius_(),c=null,d=null,e=0;e=c+this.handleLength_&&(d+=
-e);this.setHandlePosition(this.constrainHandle_(d));this.onScroll_();a.stopPropagation();a.preventDefault()}};
-Blockly.Scrollbar.prototype.onMouseDownHandle_=function(a){this.workspace_.markFocused();this.cleanUp_();Blockly.utils.isRightButton(a)?a.stopPropagation():(this.startDragHandle=this.handlePosition_,this.workspace_.setupDragSurface(),this.startDragMouse_=this.horizontal_?a.clientX:a.clientY,Blockly.Scrollbar.onMouseUpWrapper_=Blockly.bindEventWithChecks_(document,"mouseup",this,this.onMouseUpHandle_),Blockly.Scrollbar.onMouseMoveWrapper_=Blockly.bindEventWithChecks_(document,"mousemove",this,this.onMouseMoveHandle_),
-a.stopPropagation(),a.preventDefault())};Blockly.Scrollbar.prototype.onMouseMoveHandle_=function(a){this.setHandlePosition(this.constrainHandle_(this.startDragHandle+((this.horizontal_?a.clientX:a.clientY)-this.startDragMouse_)));this.onScroll_()};Blockly.Scrollbar.prototype.onMouseUpHandle_=function(){this.workspace_.resetDragSurface();Blockly.Touch.clearTouchIdentifier();this.cleanUp_()};
-Blockly.Scrollbar.prototype.cleanUp_=function(){Blockly.hideChaff(!0);Blockly.Scrollbar.onMouseUpWrapper_&&(Blockly.unbindEvent_(Blockly.Scrollbar.onMouseUpWrapper_),Blockly.Scrollbar.onMouseUpWrapper_=null);Blockly.Scrollbar.onMouseMoveWrapper_&&(Blockly.unbindEvent_(Blockly.Scrollbar.onMouseMoveWrapper_),Blockly.Scrollbar.onMouseMoveWrapper_=null)};
-Blockly.Scrollbar.prototype.constrainHandle_=function(a){return a=0>=a||isNaN(a)||this.scrollViewSize_a)throw Error("Cannot unsubscribe a workspace that hasn't been subscribed.");this.subscribedWorkspaces_.splice(a,1)};Blockly.ThemeManager.prototype.subscribe=function(a,b,c){this.componentDB_[b]||(this.componentDB_[b]=[]);this.componentDB_[b].push({element:a,propertyName:c});b=this.theme_&&this.theme_.getComponentStyle(b);a.style[c]=b||""};
-Blockly.ThemeManager.prototype.unsubscribe=function(a){if(a)for(var b=Object.keys(this.componentDB_),c=0,d;d=b[c];c++){for(var e=this.componentDB_[d],f=e.length-1;0<=f;f--)e[f].element===a&&e.splice(f,1);this.componentDB_[d].length||delete this.componentDB_[d]}};Blockly.ThemeManager.prototype.dispose=function(){this.componentDB_=this.subscribedWorkspaces_=this.theme_=this.owner_=null};Blockly.Themes={};Blockly.Themes.Classic={};Blockly.Themes.Classic.defaultBlockStyles={colour_blocks:{colourPrimary:"20"},list_blocks:{colourPrimary:"260"},logic_blocks:{colourPrimary:"210"},loop_blocks:{colourPrimary:"120"},math_blocks:{colourPrimary:"230"},procedure_blocks:{colourPrimary:"290"},text_blocks:{colourPrimary:"160"},variable_blocks:{colourPrimary:"330"},variable_dynamic_blocks:{colourPrimary:"310"},hat_blocks:{colourPrimary:"330",hat:"cap"}};
-Blockly.Themes.Classic.categoryStyles={colour_category:{colour:"20"},list_category:{colour:"260"},logic_category:{colour:"210"},loop_category:{colour:"120"},math_category:{colour:"230"},procedure_category:{colour:"290"},text_category:{colour:"160"},variable_category:{colour:"330"},variable_dynamic_category:{colour:"310"}};Blockly.Themes.Classic=new Blockly.Theme(Blockly.Themes.Classic.defaultBlockStyles,Blockly.Themes.Classic.categoryStyles);Blockly.VariableMap=function(a){this.variableMap_=Object.create(null);this.workspace=a};Blockly.VariableMap.prototype.clear=function(){this.variableMap_=Object.create(null)};Blockly.VariableMap.prototype.renameVariable=function(a,b){var c=this.getVariable(b,a.type),d=this.workspace.getAllBlocks(!1);Blockly.Events.setGroup(!0);try{c&&c.getId()!=a.getId()?this.renameVariableWithConflict_(a,b,c,d):this.renameVariableAndUses_(a,b,d)}finally{Blockly.Events.setGroup(!1)}};
-Blockly.VariableMap.prototype.renameVariableById=function(a,b){var c=this.getVariableById(a);if(!c)throw Error("Tried to rename a variable that didn't exist. ID: "+a);this.renameVariable(c,b)};Blockly.VariableMap.prototype.renameVariableAndUses_=function(a,b,c){Blockly.Events.fire(new Blockly.Events.VarRename(a,b));a.name=b;for(b=0;bthis.remainingCapacityOfType(c))return!1;b+=a[c]}return b>this.remainingCapacity()?!1:!0};Blockly.Workspace.prototype.hasBlockLimits=function(){return Infinity!=this.options.maxBlocks||!!this.options.maxInstances};
-Blockly.Workspace.prototype.undo=function(a){var b=a?this.redoStack_:this.undoStack_,c=a?this.undoStack_:this.redoStack_,d=b.pop();if(d){for(var e=[d];b.length&&d.group&&d.group==b[b.length-1].group;)e.push(b.pop());for(b=0;d=e[b];b++)c.push(d);e=Blockly.Events.filter(e,a);Blockly.Events.recordUndo=!1;try{for(b=0;d=e[b];b++)d.run(a)}finally{Blockly.Events.recordUndo=!0}}};Blockly.Workspace.prototype.clearUndo=function(){this.undoStack_.length=0;this.redoStack_.length=0;Blockly.Events.clearPendingUndo()};
-Blockly.Workspace.prototype.addChangeListener=function(a){this.listeners_.push(a);return a};Blockly.Workspace.prototype.removeChangeListener=function(a){Blockly.utils.arrayRemove(this.listeners_,a)};Blockly.Workspace.prototype.fireChangeListener=function(a){if(a.recordUndo)for(this.undoStack_.push(a),this.redoStack_.length=0;this.undoStack_.length>this.MAX_UNDO&&0<=this.MAX_UNDO;)this.undoStack_.shift();for(var b=0,c;c=this.listeners_[b];b++)c(a)};
-Blockly.Workspace.prototype.getBlockById=function(a){return this.blockDB_[a]||null};Blockly.Workspace.prototype.getCommentById=function(a){return this.commentDB_[a]||null};Blockly.Workspace.prototype.allInputsFilled=function(a){for(var b=this.getTopBlocks(!1),c=0,d;d=b[c];c++)if(!d.allInputsFilled(a))return!1;return!0};Blockly.Workspace.prototype.getPotentialVariableMap=function(){return this.potentialVariableMap_};
-Blockly.Workspace.prototype.createPotentialVariableMap=function(){this.potentialVariableMap_=new Blockly.VariableMap(this)};Blockly.Workspace.prototype.getVariableMap=function(){return this.variableMap_};Blockly.Workspace.WorkspaceDB_=Object.create(null);Blockly.Workspace.getById=function(a){return Blockly.Workspace.WorkspaceDB_[a]||null};Blockly.Workspace.getAll=function(){var a=[],b;for(b in Blockly.Workspace.WorkspaceDB_)a.push(Blockly.Workspace.WorkspaceDB_[b]);return a};
-Blockly.Workspace.prototype.getThemeManager=function(){return this.themeManager_};Blockly.Bubble=function(a,b,c,d,e,f){this.workspace_=a;this.content_=b;this.shape_=c;c=Blockly.Bubble.ARROW_ANGLE;this.workspace_.RTL&&(c=-c);this.arrow_radians_=Blockly.utils.math.toRadians(c);a.getBubbleCanvas().appendChild(this.createDom_(b,!(!e||!f)));this.setAnchorLocation(d);e&&f||(b=this.content_.getBBox(),e=b.width+2*Blockly.Bubble.BORDER_WIDTH,f=b.height+2*Blockly.Bubble.BORDER_WIDTH);this.setBubbleSize(e,f);this.positionBubble_();this.renderArrow_();this.rendered_=!0;a.options.readOnly||
-(Blockly.bindEventWithChecks_(this.bubbleBack_,"mousedown",this,this.bubbleMouseDown_),this.resizeGroup_&&Blockly.bindEventWithChecks_(this.resizeGroup_,"mousedown",this,this.resizeMouseDown_))};Blockly.Bubble.BORDER_WIDTH=6;Blockly.Bubble.ARROW_THICKNESS=5;Blockly.Bubble.ARROW_ANGLE=20;Blockly.Bubble.ARROW_BEND=4;Blockly.Bubble.ANCHOR_RADIUS=8;Blockly.Bubble.onMouseUpWrapper_=null;Blockly.Bubble.onMouseMoveWrapper_=null;Blockly.Bubble.prototype.resizeCallback_=null;
-Blockly.Bubble.unbindDragEvents_=function(){Blockly.Bubble.onMouseUpWrapper_&&(Blockly.unbindEvent_(Blockly.Bubble.onMouseUpWrapper_),Blockly.Bubble.onMouseUpWrapper_=null);Blockly.Bubble.onMouseMoveWrapper_&&(Blockly.unbindEvent_(Blockly.Bubble.onMouseMoveWrapper_),Blockly.Bubble.onMouseMoveWrapper_=null)};Blockly.Bubble.bubbleMouseUp_=function(){Blockly.Touch.clearTouchIdentifier();Blockly.Bubble.unbindDragEvents_()};Blockly.Bubble.prototype.rendered_=!1;Blockly.Bubble.prototype.anchorXY_=null;
-Blockly.Bubble.prototype.relativeLeft_=0;Blockly.Bubble.prototype.relativeTop_=0;Blockly.Bubble.prototype.width_=0;Blockly.Bubble.prototype.height_=0;Blockly.Bubble.prototype.autoLayout_=!0;
-Blockly.Bubble.prototype.createDom_=function(a,b){this.bubbleGroup_=Blockly.utils.dom.createSvgElement("g",{},null);var c={filter:"url(#"+this.workspace_.options.embossFilterId+")"};Blockly.utils.userAgent.JAVA_FX&&(c={});c=Blockly.utils.dom.createSvgElement("g",c,this.bubbleGroup_);this.bubbleArrow_=Blockly.utils.dom.createSvgElement("path",{},c);this.bubbleBack_=Blockly.utils.dom.createSvgElement("rect",{"class":"blocklyDraggable",x:0,y:0,rx:Blockly.Bubble.BORDER_WIDTH,ry:Blockly.Bubble.BORDER_WIDTH},
-c);b?(this.resizeGroup_=Blockly.utils.dom.createSvgElement("g",{"class":this.workspace_.RTL?"blocklyResizeSW":"blocklyResizeSE"},this.bubbleGroup_),c=2*Blockly.Bubble.BORDER_WIDTH,Blockly.utils.dom.createSvgElement("polygon",{points:"0,x x,x x,0".replace(/x/g,c.toString())},this.resizeGroup_),Blockly.utils.dom.createSvgElement("line",{"class":"blocklyResizeLine",x1:c/3,y1:c-1,x2:c-1,y2:c/3},this.resizeGroup_),Blockly.utils.dom.createSvgElement("line",{"class":"blocklyResizeLine",x1:2*c/3,y1:c-1,x2:c-
-1,y2:2*c/3},this.resizeGroup_)):this.resizeGroup_=null;this.bubbleGroup_.appendChild(a);return this.bubbleGroup_};Blockly.Bubble.prototype.getSvgRoot=function(){return this.bubbleGroup_};Blockly.Bubble.prototype.setSvgId=function(a){this.bubbleGroup_.dataset&&(this.bubbleGroup_.dataset.blockId=a)};Blockly.Bubble.prototype.bubbleMouseDown_=function(a){var b=this.workspace_.getGesture(a);b&&b.handleBubbleStart(a,this)};Blockly.Bubble.prototype.showContextMenu_=function(a){};
-Blockly.Bubble.prototype.isDeletable=function(){return!1};
-Blockly.Bubble.prototype.resizeMouseDown_=function(a){this.promote_();Blockly.Bubble.unbindDragEvents_();Blockly.utils.isRightButton(a)||(this.workspace_.startDrag(a,new Blockly.utils.Coordinate(this.workspace_.RTL?-this.width_:this.width_,this.height_)),Blockly.Bubble.onMouseUpWrapper_=Blockly.bindEventWithChecks_(document,"mouseup",this,Blockly.Bubble.bubbleMouseUp_),Blockly.Bubble.onMouseMoveWrapper_=Blockly.bindEventWithChecks_(document,"mousemove",this,this.resizeMouseMove_),Blockly.hideChaff());
-a.stopPropagation()};Blockly.Bubble.prototype.resizeMouseMove_=function(a){this.autoLayout_=!1;a=this.workspace_.moveDrag(a);this.setBubbleSize(this.workspace_.RTL?-a.x:a.x,a.y);this.workspace_.RTL&&this.positionBubble_()};Blockly.Bubble.prototype.registerResizeEvent=function(a){this.resizeCallback_=a};Blockly.Bubble.prototype.promote_=function(){var a=this.bubbleGroup_.parentNode;return a.lastChild!==this.bubbleGroup_?(a.appendChild(this.bubbleGroup_),!0):!1};
-Blockly.Bubble.prototype.setAnchorLocation=function(a){this.anchorXY_=a;this.rendered_&&this.positionBubble_()};
-Blockly.Bubble.prototype.layoutBubble_=function(){var a=this.workspace_.getMetrics();a.viewLeft/=this.workspace_.scale;a.viewWidth/=this.workspace_.scale;a.viewTop/=this.workspace_.scale;a.viewHeight/=this.workspace_.scale;var b=this.getOptimalRelativeLeft_(a),c=this.getOptimalRelativeTop_(a),d=this.shape_.getBBox(),e={x:b,y:-this.height_-Blockly.BlockSvg.MIN_BLOCK_Y},f={x:-this.width_-30,y:c};c={x:d.width,y:c};var g={x:b,y:d.height};b=d.widtha.viewWidth)return b;if(this.workspace_.RTL)var c=this.anchorXY_.x-b,d=c-this.width_,e=a.viewLeft+a.viewWidth,f=a.viewLeft+Blockly.Scrollbar.scrollbarThickness/this.workspace_.scale;else d=b+this.anchorXY_.x,c=d+this.width_,f=a.viewLeft,e=a.viewLeft+a.viewWidth-Blockly.Scrollbar.scrollbarThickness/this.workspace_.scale;this.workspace_.RTL?de&&(b=-(e-this.anchorXY_.x)):
-de&&(b=e-this.anchorXY_.x-this.width_);return b};Blockly.Bubble.prototype.getOptimalRelativeTop_=function(a){var b=-this.height_/4;if(this.height_>a.viewHeight)return b;var c=this.anchorXY_.y+b,d=c+this.height_,e=a.viewTop;a=a.viewTop+a.viewHeight-Blockly.Scrollbar.scrollbarThickness/this.workspace_.scale;var f=this.anchorXY_.y;ca&&(b=a-f-this.height_);return b};
-Blockly.Bubble.prototype.positionBubble_=function(){var a=this.anchorXY_.x;a=this.workspace_.RTL?a-(this.relativeLeft_+this.width_):a+this.relativeLeft_;this.moveTo(a,this.relativeTop_+this.anchorXY_.y)};Blockly.Bubble.prototype.moveTo=function(a,b){this.bubbleGroup_.setAttribute("transform","translate("+a+","+b+")")};Blockly.Bubble.prototype.getBubbleSize=function(){return new Blockly.utils.Size(this.width_,this.height_)};
-Blockly.Bubble.prototype.setBubbleSize=function(a,b){var c=2*Blockly.Bubble.BORDER_WIDTH;a=Math.max(a,c+45);b=Math.max(b,c+20);this.width_=a;this.height_=b;this.bubbleBack_.setAttribute("width",a);this.bubbleBack_.setAttribute("height",b);this.resizeGroup_&&(this.workspace_.RTL?this.resizeGroup_.setAttribute("transform","translate("+2*Blockly.Bubble.BORDER_WIDTH+","+(b-c)+") scale(-1 1)"):this.resizeGroup_.setAttribute("transform","translate("+(a-c)+","+(b-c)+")"));this.autoLayout_&&this.layoutBubble_();
-this.positionBubble_();this.renderArrow_();this.resizeCallback_&&this.resizeCallback_()};
-Blockly.Bubble.prototype.renderArrow_=function(){var a=[],b=this.width_/2,c=this.height_/2,d=-this.relativeLeft_,e=-this.relativeTop_;if(b==d&&c==e)a.push("M "+b+","+c);else{e-=c;d-=b;this.workspace_.RTL&&(d*=-1);var f=Math.sqrt(e*e+d*d),g=Math.acos(d/f);0>e&&(g=2*Math.PI-g);var h=g+Math.PI/2;h>2*Math.PI&&(h-=2*Math.PI);var k=Math.sin(h),l=Math.cos(h),m=this.getBubbleSize();h=(m.width+m.height)/Blockly.Bubble.ARROW_THICKNESS;h=Math.min(h,m.width,m.height)/4;m=1-Blockly.Bubble.ANCHOR_RADIUS/f;d=b+
-m*d;e=c+m*e;m=b+h*l;var n=c+h*k;b-=h*l;c-=h*k;k=g+this.arrow_radians_;k>2*Math.PI&&(k-=2*Math.PI);g=Math.sin(k)*f/Blockly.Bubble.ARROW_BEND;f=Math.cos(k)*f/Blockly.Bubble.ARROW_BEND;a.push("M"+m+","+n);a.push("C"+(m+f)+","+(n+g)+" "+d+","+e+" "+d+","+e);a.push("C"+d+","+e+" "+(b+f)+","+(c+g)+" "+b+","+c)}a.push("z");this.bubbleArrow_.setAttribute("d",a.join(" "))};Blockly.Bubble.prototype.setColour=function(a){this.bubbleBack_.setAttribute("fill",a);this.bubbleArrow_.setAttribute("fill",a)};
-Blockly.Bubble.prototype.dispose=function(){Blockly.Bubble.unbindDragEvents_();Blockly.utils.dom.removeNode(this.bubbleGroup_);this.shape_=this.content_=this.workspace_=this.resizeGroup_=this.bubbleBack_=this.bubbleArrow_=this.bubbleGroup_=null};Blockly.Bubble.prototype.moveDuringDrag=function(a,b){a?a.translateSurface(b.x,b.y):this.moveTo(b.x,b.y);this.relativeLeft_=this.workspace_.RTL?this.anchorXY_.x-b.x-this.width_:b.x-this.anchorXY_.x;this.relativeTop_=b.y-this.anchorXY_.y;this.renderArrow_()};
-Blockly.Bubble.prototype.getRelativeToSurfaceXY=function(){return new Blockly.utils.Coordinate(this.anchorXY_.x+this.relativeLeft_,this.anchorXY_.y+this.relativeTop_)};Blockly.Bubble.prototype.setAutoLayout=function(a){this.autoLayout_=a};Blockly.Events.CommentBase=function(a){this.commentId=a.id;this.workspaceId=a.workspace.id;this.group=Blockly.Events.getGroup();this.recordUndo=Blockly.Events.recordUndo};Blockly.utils.object.inherits(Blockly.Events.CommentBase,Blockly.Events.Abstract);Blockly.Events.CommentBase.prototype.toJson=function(){var a=Blockly.Events.CommentBase.superClass_.toJson.call(this);this.commentId&&(a.commentId=this.commentId);return a};
-Blockly.Events.CommentBase.prototype.fromJson=function(a){Blockly.Events.CommentBase.superClass_.fromJson.call(this,a);this.commentId=a.commentId};Blockly.Events.CommentChange=function(a,b,c){a&&(Blockly.Events.CommentChange.superClass_.constructor.call(this,a),this.oldContents_=b,this.newContents_=c)};Blockly.utils.object.inherits(Blockly.Events.CommentChange,Blockly.Events.CommentBase);Blockly.Events.CommentChange.prototype.type=Blockly.Events.COMMENT_CHANGE;
-Blockly.Events.CommentChange.prototype.toJson=function(){var a=Blockly.Events.CommentChange.superClass_.toJson.call(this);a.newContents=this.newContents_;return a};Blockly.Events.CommentChange.prototype.fromJson=function(a){Blockly.Events.CommentChange.superClass_.fromJson.call(this,a);this.newContents_=a.newValue};Blockly.Events.CommentChange.prototype.isNull=function(){return this.oldContents_==this.newContents_};
-Blockly.Events.CommentChange.prototype.run=function(a){var b=this.getEventWorkspace_().getCommentById(this.commentId);b?b.setContent(a?this.newContents_:this.oldContents_):console.warn("Can't change non-existent comment: "+this.commentId)};Blockly.Events.CommentCreate=function(a){a&&(Blockly.Events.CommentCreate.superClass_.constructor.call(this,a),this.xml=a.toXmlWithXY())};Blockly.utils.object.inherits(Blockly.Events.CommentCreate,Blockly.Events.CommentBase);
-Blockly.Events.CommentCreate.prototype.type=Blockly.Events.COMMENT_CREATE;Blockly.Events.CommentCreate.prototype.toJson=function(){var a=Blockly.Events.CommentCreate.superClass_.toJson.call(this);a.xml=Blockly.Xml.domToText(this.xml);return a};Blockly.Events.CommentCreate.prototype.fromJson=function(a){Blockly.Events.CommentCreate.superClass_.fromJson.call(this,a);this.xml=Blockly.Xml.textToDom(a.xml)};
-Blockly.Events.CommentCreate.prototype.run=function(a){Blockly.Events.CommentCreateDeleteHelper(this,a)};Blockly.Events.CommentCreateDeleteHelper=function(a,b){var c=a.getEventWorkspace_();if(b){var d=Blockly.utils.xml.createElement("xml");d.appendChild(a.xml);Blockly.Xml.domToWorkspace(d,c)}else(c=c.getCommentById(a.commentId))?c.dispose(!1,!1):console.warn("Can't uncreate non-existent comment: "+a.commentId)};
-Blockly.Events.CommentDelete=function(a){a&&(Blockly.Events.CommentDelete.superClass_.constructor.call(this,a),this.xml=a.toXmlWithXY())};Blockly.utils.object.inherits(Blockly.Events.CommentDelete,Blockly.Events.CommentBase);Blockly.Events.CommentDelete.prototype.type=Blockly.Events.COMMENT_DELETE;Blockly.Events.CommentDelete.prototype.toJson=function(){return Blockly.Events.CommentDelete.superClass_.toJson.call(this)};
-Blockly.Events.CommentDelete.prototype.fromJson=function(a){Blockly.Events.CommentDelete.superClass_.fromJson.call(this,a)};Blockly.Events.CommentDelete.prototype.run=function(a){Blockly.Events.CommentCreateDeleteHelper(this,!a)};Blockly.Events.CommentMove=function(a){a&&(Blockly.Events.CommentMove.superClass_.constructor.call(this,a),this.comment_=a,this.oldCoordinate_=a.getXY(),this.newCoordinate_=null)};Blockly.utils.object.inherits(Blockly.Events.CommentMove,Blockly.Events.CommentBase);
-Blockly.Events.CommentMove.prototype.recordNew=function(){if(!this.comment_)throw Error("Tried to record the new position of a comment on the same event twice.");this.newCoordinate_=this.comment_.getXY();this.comment_=null};Blockly.Events.CommentMove.prototype.type=Blockly.Events.COMMENT_MOVE;Blockly.Events.CommentMove.prototype.setOldCoordinate=function(a){this.oldCoordinate_=a};
-Blockly.Events.CommentMove.prototype.toJson=function(){var a=Blockly.Events.CommentMove.superClass_.toJson.call(this);this.newCoordinate_&&(a.newCoordinate=Math.round(this.newCoordinate_.x)+","+Math.round(this.newCoordinate_.y));return a};Blockly.Events.CommentMove.prototype.fromJson=function(a){Blockly.Events.CommentMove.superClass_.fromJson.call(this,a);a.newCoordinate&&(a=a.newCoordinate.split(","),this.newCoordinate_=new Blockly.utils.Coordinate(Number(a[0]),Number(a[1])))};
-Blockly.Events.CommentMove.prototype.isNull=function(){return Blockly.utils.Coordinate.equals(this.oldCoordinate_,this.newCoordinate_)};Blockly.Events.CommentMove.prototype.run=function(a){var b=this.getEventWorkspace_().getCommentById(this.commentId);if(b){a=a?this.newCoordinate_:this.oldCoordinate_;var c=b.getXY();b.moveBy(a.x-c.x,a.y-c.y)}else console.warn("Can't move non-existent comment: "+this.commentId)};Blockly.BubbleDragger=function(a,b){this.draggingBubble_=a;this.workspace_=b;this.deleteArea_=null;this.wouldDeleteBubble_=!1;this.startXY_=this.draggingBubble_.getRelativeToSurfaceXY();this.dragSurface_=Blockly.utils.is3dSupported()&&b.getBlockDragSurface()?b.getBlockDragSurface():null};Blockly.BubbleDragger.prototype.dispose=function(){this.dragSurface_=this.workspace_=this.draggingBubble_=null};
-Blockly.BubbleDragger.prototype.startBubbleDrag=function(){Blockly.Events.getGroup()||Blockly.Events.setGroup(!0);this.workspace_.setResizesEnabled(!1);this.draggingBubble_.setAutoLayout(!1);this.dragSurface_&&this.moveToDragSurface_();this.draggingBubble_.setDragging&&this.draggingBubble_.setDragging(!0);var a=this.workspace_.getToolbox();if(a){var b=this.draggingBubble_.isDeletable()?"blocklyToolboxDelete":"blocklyToolboxGrab";a.addStyle(b)}};
-Blockly.BubbleDragger.prototype.dragBubble=function(a,b){var c=this.pixelsToWorkspaceUnits_(b);c=Blockly.utils.Coordinate.sum(this.startXY_,c);this.draggingBubble_.moveDuringDrag(this.dragSurface_,c);this.draggingBubble_.isDeletable()&&(this.deleteArea_=this.workspace_.isDeleteArea(a),this.updateCursorDuringBubbleDrag_())};
-Blockly.BubbleDragger.prototype.maybeDeleteBubble_=function(){var a=this.workspace_.trashcan;this.wouldDeleteBubble_?(a&&setTimeout(a.close.bind(a),100),this.fireMoveEvent_(),this.draggingBubble_.dispose(!1,!0)):a&&a.close();return this.wouldDeleteBubble_};
-Blockly.BubbleDragger.prototype.updateCursorDuringBubbleDrag_=function(){this.wouldDeleteBubble_=this.deleteArea_!=Blockly.DELETE_AREA_NONE;var a=this.workspace_.trashcan;this.wouldDeleteBubble_?(this.draggingBubble_.setDeleteStyle(!0),this.deleteArea_==Blockly.DELETE_AREA_TRASH&&a&&a.setOpen_(!0)):(this.draggingBubble_.setDeleteStyle(!1),a&&a.setOpen_(!1))};
-Blockly.BubbleDragger.prototype.endBubbleDrag=function(a,b){this.dragBubble(a,b);var c=this.pixelsToWorkspaceUnits_(b);c=Blockly.utils.Coordinate.sum(this.startXY_,c);this.draggingBubble_.moveTo(c.x,c.y);this.maybeDeleteBubble_()||(this.dragSurface_&&this.dragSurface_.clearAndHide(this.workspace_.getBubbleCanvas()),this.draggingBubble_.setDragging&&this.draggingBubble_.setDragging(!1),this.fireMoveEvent_());this.workspace_.setResizesEnabled(!0);this.workspace_.toolbox_&&(c=this.draggingBubble_.isDeletable()?
-"blocklyToolboxDelete":"blocklyToolboxGrab",this.workspace_.toolbox_.removeStyle(c));Blockly.Events.setGroup(!1)};Blockly.BubbleDragger.prototype.fireMoveEvent_=function(){if(this.draggingBubble_.isComment){var a=new Blockly.Events.CommentMove(this.draggingBubble_);a.setOldCoordinate(this.startXY_);a.recordNew();Blockly.Events.fire(a)}};
-Blockly.BubbleDragger.prototype.pixelsToWorkspaceUnits_=function(a){a=new Blockly.utils.Coordinate(a.x/this.workspace_.scale,a.y/this.workspace_.scale);this.workspace_.isMutator&&a.scale(1/this.workspace_.options.parentWorkspace.scale);return a};Blockly.BubbleDragger.prototype.moveToDragSurface_=function(){this.draggingBubble_.moveTo(0,0);this.dragSurface_.translateSurface(this.startXY_.x,this.startXY_.y);this.dragSurface_.setBlocksAndShow(this.draggingBubble_.getSvgRoot())};Blockly.constants={};Blockly.LINE_MODE_MULTIPLIER=40;Blockly.PAGE_MODE_MULTIPLIER=125;Blockly.DRAG_RADIUS=5;Blockly.FLYOUT_DRAG_RADIUS=10;Blockly.SNAP_RADIUS=28;Blockly.CONNECTING_SNAP_RADIUS=Blockly.SNAP_RADIUS;Blockly.CURRENT_CONNECTION_PREFERENCE=8;Blockly.INSERTION_MARKER_COLOUR="#000000";Blockly.BUMP_DELAY=250;Blockly.BUMP_RANDOMNESS=10;Blockly.COLLAPSE_CHARS=30;Blockly.LONGPRESS=750;Blockly.SOUND_LIMIT=100;Blockly.DRAG_STACK=!0;Blockly.HSV_SATURATION=.45;Blockly.HSV_VALUE=.65;
-Blockly.SPRITE={width:96,height:124,url:"sprites.png"};Blockly.INPUT_VALUE=1;Blockly.OUTPUT_VALUE=2;Blockly.NEXT_STATEMENT=3;Blockly.PREVIOUS_STATEMENT=4;Blockly.DUMMY_INPUT=5;Blockly.ALIGN_LEFT=-1;Blockly.ALIGN_CENTRE=0;Blockly.ALIGN_RIGHT=1;Blockly.DRAG_NONE=0;Blockly.DRAG_STICKY=1;Blockly.DRAG_BEGIN=1;Blockly.DRAG_FREE=2;Blockly.OPPOSITE_TYPE=[];Blockly.OPPOSITE_TYPE[Blockly.INPUT_VALUE]=Blockly.OUTPUT_VALUE;Blockly.OPPOSITE_TYPE[Blockly.OUTPUT_VALUE]=Blockly.INPUT_VALUE;
-Blockly.OPPOSITE_TYPE[Blockly.NEXT_STATEMENT]=Blockly.PREVIOUS_STATEMENT;Blockly.OPPOSITE_TYPE[Blockly.PREVIOUS_STATEMENT]=Blockly.NEXT_STATEMENT;Blockly.TOOLBOX_AT_TOP=0;Blockly.TOOLBOX_AT_BOTTOM=1;Blockly.TOOLBOX_AT_LEFT=2;Blockly.TOOLBOX_AT_RIGHT=3;Blockly.DELETE_AREA_NONE=null;Blockly.DELETE_AREA_TRASH=1;Blockly.DELETE_AREA_TOOLBOX=2;Blockly.VARIABLE_CATEGORY_NAME="VARIABLE";Blockly.VARIABLE_DYNAMIC_CATEGORY_NAME="VARIABLE_DYNAMIC";Blockly.PROCEDURE_CATEGORY_NAME="PROCEDURE";
-Blockly.RENAME_VARIABLE_ID="RENAME_VARIABLE_ID";Blockly.DELETE_VARIABLE_ID="DELETE_VARIABLE_ID";Blockly.Events.Ui=function(a,b,c,d){Blockly.Events.Ui.superClass_.constructor.call(this);this.blockId=a?a.id:null;this.workspaceId=a?a.workspace.id:void 0;this.element=b;this.oldValue=c;this.newValue=d;this.recordUndo=!1};Blockly.utils.object.inherits(Blockly.Events.Ui,Blockly.Events.Abstract);Blockly.Events.Ui.prototype.type=Blockly.Events.UI;
-Blockly.Events.Ui.prototype.toJson=function(){var a=Blockly.Events.Ui.superClass_.toJson.call(this);a.element=this.element;void 0!==this.newValue&&(a.newValue=this.newValue);this.blockId&&(a.blockId=this.blockId);return a};Blockly.Events.Ui.prototype.fromJson=function(a){Blockly.Events.Ui.superClass_.fromJson.call(this,a);this.element=a.element;this.newValue=a.newValue;this.blockId=a.blockId};Blockly.WorkspaceDragger=function(a){this.workspace_=a;this.startScrollXY_=new Blockly.utils.Coordinate(a.scrollX,a.scrollY)};Blockly.WorkspaceDragger.prototype.dispose=function(){this.workspace_=null};Blockly.WorkspaceDragger.prototype.startDrag=function(){Blockly.selected&&Blockly.selected.unselect();this.workspace_.setupDragSurface()};Blockly.WorkspaceDragger.prototype.endDrag=function(a){this.drag(a);this.workspace_.resetDragSurface()};
-Blockly.WorkspaceDragger.prototype.drag=function(a){a=Blockly.utils.Coordinate.sum(this.startScrollXY_,a);this.workspace_.scroll(a.x,a.y)};Blockly.FlyoutDragger=function(a){Blockly.FlyoutDragger.superClass_.constructor.call(this,a.getWorkspace());this.scrollbar_=a.scrollbar_;this.horizontalLayout_=a.horizontalLayout_};Blockly.utils.object.inherits(Blockly.FlyoutDragger,Blockly.WorkspaceDragger);Blockly.FlyoutDragger.prototype.drag=function(a){a=Blockly.utils.Coordinate.sum(this.startScrollXY_,a);this.horizontalLayout_?this.scrollbar_.set(-a.x):this.scrollbar_.set(-a.y)};Blockly.Tooltip={};Blockly.Tooltip.visible=!1;Blockly.Tooltip.blocked_=!1;Blockly.Tooltip.LIMIT=50;Blockly.Tooltip.mouseOutPid_=0;Blockly.Tooltip.showPid_=0;Blockly.Tooltip.lastX_=0;Blockly.Tooltip.lastY_=0;Blockly.Tooltip.element_=null;Blockly.Tooltip.poisonedElement_=null;Blockly.Tooltip.OFFSET_X=0;Blockly.Tooltip.OFFSET_Y=10;Blockly.Tooltip.RADIUS_OK=10;Blockly.Tooltip.HOVER_MS=750;Blockly.Tooltip.MARGINS=5;Blockly.Tooltip.DIV=null;
-Blockly.Tooltip.createDom=function(){Blockly.Tooltip.DIV||(Blockly.Tooltip.DIV=document.createElement("div"),Blockly.Tooltip.DIV.className="blocklyTooltipDiv",document.body.appendChild(Blockly.Tooltip.DIV))};Blockly.Tooltip.bindMouseEvents=function(a){Blockly.bindEvent_(a,"mouseover",null,Blockly.Tooltip.onMouseOver_);Blockly.bindEvent_(a,"mouseout",null,Blockly.Tooltip.onMouseOut_);a.addEventListener("mousemove",Blockly.Tooltip.onMouseMove_,!1)};
-Blockly.Tooltip.onMouseOver_=function(a){if(!Blockly.Tooltip.blocked_){for(a=a.currentTarget;"string"!=typeof a.tooltip&&"function"!=typeof a.tooltip;)a=a.tooltip;Blockly.Tooltip.element_!=a&&(Blockly.Tooltip.hide(),Blockly.Tooltip.poisonedElement_=null,Blockly.Tooltip.element_=a);clearTimeout(Blockly.Tooltip.mouseOutPid_)}};
-Blockly.Tooltip.onMouseOut_=function(a){Blockly.Tooltip.blocked_||(Blockly.Tooltip.mouseOutPid_=setTimeout(function(){Blockly.Tooltip.element_=null;Blockly.Tooltip.poisonedElement_=null;Blockly.Tooltip.hide()},1),clearTimeout(Blockly.Tooltip.showPid_))};
-Blockly.Tooltip.onMouseMove_=function(a){if(Blockly.Tooltip.element_&&Blockly.Tooltip.element_.tooltip&&!Blockly.Tooltip.blocked_)if(Blockly.Tooltip.visible){var b=Blockly.Tooltip.lastX_-a.pageX;a=Blockly.Tooltip.lastY_-a.pageY;Math.sqrt(b*b+a*a)>Blockly.Tooltip.RADIUS_OK&&Blockly.Tooltip.hide()}else Blockly.Tooltip.poisonedElement_!=Blockly.Tooltip.element_&&(clearTimeout(Blockly.Tooltip.showPid_),Blockly.Tooltip.lastX_=a.pageX,Blockly.Tooltip.lastY_=a.pageY,Blockly.Tooltip.showPid_=setTimeout(Blockly.Tooltip.show_,
-Blockly.Tooltip.HOVER_MS))};Blockly.Tooltip.hide=function(){Blockly.Tooltip.visible&&(Blockly.Tooltip.visible=!1,Blockly.Tooltip.DIV&&(Blockly.Tooltip.DIV.style.display="none"));Blockly.Tooltip.showPid_&&clearTimeout(Blockly.Tooltip.showPid_)};Blockly.Tooltip.block=function(){Blockly.Tooltip.hide();Blockly.Tooltip.blocked_=!0};Blockly.Tooltip.unblock=function(){Blockly.Tooltip.blocked_=!1};
-Blockly.Tooltip.show_=function(){if(!Blockly.Tooltip.blocked_&&(Blockly.Tooltip.poisonedElement_=Blockly.Tooltip.element_,Blockly.Tooltip.DIV)){Blockly.Tooltip.DIV.innerHTML="";for(var a=Blockly.Tooltip.element_.tooltip;"function"==typeof a;)a=a();a=Blockly.utils.string.wrap(a,Blockly.Tooltip.LIMIT);a=a.split("\n");for(var b=0;bc+window.scrollY&&(e-=Blockly.Tooltip.DIV.offsetHeight+2*Blockly.Tooltip.OFFSET_Y);a?d=Math.max(Blockly.Tooltip.MARGINS-window.scrollX,
-d):d+Blockly.Tooltip.DIV.offsetWidth>b+window.scrollX-2*Blockly.Tooltip.MARGINS&&(d=b-Blockly.Tooltip.DIV.offsetWidth-2*Blockly.Tooltip.MARGINS);Blockly.Tooltip.DIV.style.top=e+"px";Blockly.Tooltip.DIV.style.left=d+"px"}};Blockly.Gesture=function(a,b){this.startWorkspace_=this.targetBlock_=this.startBlock_=this.startField_=this.startBubble_=this.currentDragDeltaXY_=this.mouseDownXY_=null;this.creatorWorkspace_=b;this.isDraggingBubble_=this.isDraggingBlock_=this.isDraggingWorkspace_=this.hasExceededDragRadius_=!1;this.mostRecentEvent_=a;this.flyout_=this.workspaceDragger_=this.blockDragger_=this.bubbleDragger_=this.onUpWrapper_=this.onMoveWrapper_=null;this.isEnding_=this.hasStarted_=this.calledUpdateIsDragging_=!1;
-this.healStack_=!Blockly.DRAG_STACK};
-Blockly.Gesture.prototype.dispose=function(){Blockly.Touch.clearTouchIdentifier();Blockly.Tooltip.unblock();this.creatorWorkspace_.clearGesture();this.onMoveWrapper_&&Blockly.unbindEvent_(this.onMoveWrapper_);this.onUpWrapper_&&Blockly.unbindEvent_(this.onUpWrapper_);this.flyout_=this.startWorkspace_=this.targetBlock_=this.startBlock_=this.startField_=null;this.blockDragger_&&(this.blockDragger_.dispose(),this.blockDragger_=null);this.workspaceDragger_&&(this.workspaceDragger_.dispose(),this.workspaceDragger_=
-null);this.bubbleDragger_&&(this.bubbleDragger_.dispose(),this.bubbleDragger_=null)};Blockly.Gesture.prototype.updateFromEvent_=function(a){var b=new Blockly.utils.Coordinate(a.clientX,a.clientY);this.updateDragDelta_(b)&&(this.updateIsDragging_(),Blockly.longStop_());this.mostRecentEvent_=a};
-Blockly.Gesture.prototype.updateDragDelta_=function(a){this.currentDragDeltaXY_=Blockly.utils.Coordinate.difference(a,this.mouseDownXY_);return this.hasExceededDragRadius_?!1:this.hasExceededDragRadius_=Blockly.utils.Coordinate.magnitude(this.currentDragDeltaXY_)>(this.flyout_?Blockly.FLYOUT_DRAG_RADIUS:Blockly.DRAG_RADIUS)};
-Blockly.Gesture.prototype.updateIsDraggingFromFlyout_=function(){return this.flyout_.isBlockCreatable_(this.targetBlock_)?!this.flyout_.isScrollable()||this.flyout_.isDragTowardWorkspace(this.currentDragDeltaXY_)?(this.startWorkspace_=this.flyout_.targetWorkspace_,this.startWorkspace_.updateScreenCalculationsIfScrolled(),Blockly.Events.getGroup()||Blockly.Events.setGroup(!0),this.startBlock_=null,this.targetBlock_=this.flyout_.createBlock(this.targetBlock_),this.targetBlock_.select(),!0):!1:!1};
-Blockly.Gesture.prototype.updateIsDraggingBubble_=function(){if(!this.startBubble_)return!1;this.isDraggingBubble_=!0;this.startDraggingBubble_();return!0};Blockly.Gesture.prototype.updateIsDraggingBlock_=function(){if(!this.targetBlock_)return!1;this.flyout_?this.isDraggingBlock_=this.updateIsDraggingFromFlyout_():this.targetBlock_.isMovable()&&(this.isDraggingBlock_=!0);return this.isDraggingBlock_?(this.startDraggingBlock_(),!0):!1};
-Blockly.Gesture.prototype.updateIsDraggingWorkspace_=function(){if(this.flyout_?this.flyout_.isScrollable():this.startWorkspace_&&this.startWorkspace_.isDraggable())this.workspaceDragger_=this.flyout_?new Blockly.FlyoutDragger(this.flyout_):new Blockly.WorkspaceDragger(this.startWorkspace_),this.isDraggingWorkspace_=!0,this.workspaceDragger_.startDrag()};
-Blockly.Gesture.prototype.updateIsDragging_=function(){if(this.calledUpdateIsDragging_)throw Error("updateIsDragging_ should only be called once per gesture.");this.calledUpdateIsDragging_=!0;this.updateIsDraggingBubble_()||this.updateIsDraggingBlock_()||this.updateIsDraggingWorkspace_()};
-Blockly.Gesture.prototype.startDraggingBlock_=function(){this.blockDragger_=new Blockly.BlockDragger(this.targetBlock_,this.startWorkspace_);this.blockDragger_.startBlockDrag(this.currentDragDeltaXY_,this.healStack_);this.blockDragger_.dragBlock(this.mostRecentEvent_,this.currentDragDeltaXY_)};
-Blockly.Gesture.prototype.startDraggingBubble_=function(){this.bubbleDragger_=new Blockly.BubbleDragger(this.startBubble_,this.startWorkspace_);this.bubbleDragger_.startBubbleDrag();this.bubbleDragger_.dragBubble(this.mostRecentEvent_,this.currentDragDeltaXY_)};
-Blockly.Gesture.prototype.doStart=function(a){Blockly.utils.isTargetInput(a)?this.cancel():(this.hasStarted_=!0,Blockly.blockAnimations.disconnectUiStop(),this.startWorkspace_.updateScreenCalculationsIfScrolled(),this.startWorkspace_.isMutator&&this.startWorkspace_.resize(),this.startWorkspace_.markFocused(),this.mostRecentEvent_=a,Blockly.hideChaff(!!this.flyout_),Blockly.Tooltip.block(),this.targetBlock_&&(!this.targetBlock_.isInFlyout&&a.shiftKey?(Blockly.navigation.enableKeyboardAccessibility(),
-this.creatorWorkspace_.getCursor().setCurNode(Blockly.navigation.getTopNode(this.targetBlock_))):this.targetBlock_.select()),Blockly.utils.isRightButton(a)?this.handleRightClick(a):("touchstart"!=a.type.toLowerCase()&&"pointerdown"!=a.type.toLowerCase()||"mouse"==a.pointerType||Blockly.longStart_(a,this),this.mouseDownXY_=new Blockly.utils.Coordinate(a.clientX,a.clientY),this.healStack_=a.altKey||a.ctrlKey||a.metaKey,this.bindMouseEvents(a)))};
-Blockly.Gesture.prototype.bindMouseEvents=function(a){this.onMoveWrapper_=Blockly.bindEventWithChecks_(document,"mousemove",null,this.handleMove.bind(this));this.onUpWrapper_=Blockly.bindEventWithChecks_(document,"mouseup",null,this.handleUp.bind(this));a.preventDefault();a.stopPropagation()};
-Blockly.Gesture.prototype.handleMove=function(a){this.updateFromEvent_(a);this.isDraggingWorkspace_?this.workspaceDragger_.drag(this.currentDragDeltaXY_):this.isDraggingBlock_?this.blockDragger_.dragBlock(this.mostRecentEvent_,this.currentDragDeltaXY_):this.isDraggingBubble_&&this.bubbleDragger_.dragBubble(this.mostRecentEvent_,this.currentDragDeltaXY_);a.preventDefault();a.stopPropagation()};
-Blockly.Gesture.prototype.handleUp=function(a){this.updateFromEvent_(a);Blockly.longStop_();this.isEnding_?console.log("Trying to end a gesture recursively."):(this.isEnding_=!0,this.isDraggingBubble_?this.bubbleDragger_.endBubbleDrag(a,this.currentDragDeltaXY_):this.isDraggingBlock_?this.blockDragger_.endBlockDrag(a,this.currentDragDeltaXY_):this.isDraggingWorkspace_?this.workspaceDragger_.endDrag(this.currentDragDeltaXY_):this.isBubbleClick_()?this.doBubbleClick_():this.isFieldClick_()?this.doFieldClick_():
-this.isBlockClick_()?this.doBlockClick_():this.isWorkspaceClick_()&&this.doWorkspaceClick_(a),a.preventDefault(),a.stopPropagation(),this.dispose())};
-Blockly.Gesture.prototype.cancel=function(){this.isEnding_||(Blockly.longStop_(),this.isDraggingBubble_?this.bubbleDragger_.endBubbleDrag(this.mostRecentEvent_,this.currentDragDeltaXY_):this.isDraggingBlock_?this.blockDragger_.endBlockDrag(this.mostRecentEvent_,this.currentDragDeltaXY_):this.isDraggingWorkspace_&&this.workspaceDragger_.endDrag(this.currentDragDeltaXY_),this.dispose())};
-Blockly.Gesture.prototype.handleRightClick=function(a){this.targetBlock_?(this.bringBlockToFront_(),Blockly.hideChaff(this.flyout_),this.targetBlock_.showContextMenu_(a)):this.startBubble_?this.startBubble_.showContextMenu_(a):this.startWorkspace_&&!this.flyout_&&(Blockly.hideChaff(),this.startWorkspace_.showContextMenu_(a));a.preventDefault();a.stopPropagation();this.dispose()};
-Blockly.Gesture.prototype.handleWsStart=function(a,b){if(this.hasStarted_)throw Error("Tried to call gesture.handleWsStart, but the gesture had already been started.");this.setStartWorkspace_(b);this.mostRecentEvent_=a;this.doStart(a);Blockly.keyboardAccessibilityMode&&Blockly.navigation.setState(Blockly.navigation.STATE_WS)};
-Blockly.Gesture.prototype.handleFlyoutStart=function(a,b){if(this.hasStarted_)throw Error("Tried to call gesture.handleFlyoutStart, but the gesture had already been started.");this.setStartFlyout_(b);this.handleWsStart(a,b.getWorkspace())};Blockly.Gesture.prototype.handleBlockStart=function(a,b){if(this.hasStarted_)throw Error("Tried to call gesture.handleBlockStart, but the gesture had already been started.");this.setStartBlock(b);this.mostRecentEvent_=a};
-Blockly.Gesture.prototype.handleBubbleStart=function(a,b){if(this.hasStarted_)throw Error("Tried to call gesture.handleBubbleStart, but the gesture had already been started.");this.setStartBubble(b);this.mostRecentEvent_=a};Blockly.Gesture.prototype.doBubbleClick_=function(){this.startBubble_.setFocus&&this.startBubble_.setFocus();this.startBubble_.select&&this.startBubble_.select()};Blockly.Gesture.prototype.doFieldClick_=function(){this.startField_.showEditor_();this.bringBlockToFront_()};
-Blockly.Gesture.prototype.doBlockClick_=function(){this.flyout_&&this.flyout_.autoClose?this.targetBlock_.isEnabled()&&(Blockly.Events.getGroup()||Blockly.Events.setGroup(!0),this.flyout_.createBlock(this.targetBlock_).scheduleSnapAndBump()):Blockly.Events.fire(new Blockly.Events.Ui(this.startBlock_,"click",void 0,void 0));this.bringBlockToFront_();Blockly.Events.setGroup(!1)};
-Blockly.Gesture.prototype.doWorkspaceClick_=function(a){var b=this.creatorWorkspace_;a.shiftKey?(Blockly.navigation.enableKeyboardAccessibility(),a=new Blockly.utils.Coordinate(a.clientX,a.clientY),a=Blockly.utils.screenToWsCoordinates(b,a),a=Blockly.ASTNode.createWorkspaceNode(b,a),b.getCursor().setCurNode(a)):Blockly.selected&&Blockly.selected.unselect()};Blockly.Gesture.prototype.bringBlockToFront_=function(){this.targetBlock_&&!this.flyout_&&this.targetBlock_.bringToFront()};
-Blockly.Gesture.prototype.setStartField=function(a){if(this.hasStarted_)throw Error("Tried to call gesture.setStartField, but the gesture had already been started.");this.startField_||(this.startField_=a)};Blockly.Gesture.prototype.setStartBubble=function(a){this.startBubble_||(this.startBubble_=a)};Blockly.Gesture.prototype.setStartBlock=function(a){this.startBlock_||this.startBubble_||(this.startBlock_=a,a.isInFlyout&&a!=a.getRootBlock()?this.setTargetBlock_(a.getRootBlock()):this.setTargetBlock_(a))};
-Blockly.Gesture.prototype.setTargetBlock_=function(a){a.isShadow()?this.setTargetBlock_(a.getParent()):this.targetBlock_=a};Blockly.Gesture.prototype.setStartWorkspace_=function(a){this.startWorkspace_||(this.startWorkspace_=a)};Blockly.Gesture.prototype.setStartFlyout_=function(a){this.flyout_||(this.flyout_=a)};Blockly.Gesture.prototype.isBubbleClick_=function(){return!!this.startBubble_&&!this.hasExceededDragRadius_};
-Blockly.Gesture.prototype.isBlockClick_=function(){return!!this.startBlock_&&!this.hasExceededDragRadius_&&!this.isFieldClick_()};Blockly.Gesture.prototype.isFieldClick_=function(){return(this.startField_?this.startField_.isClickable():!1)&&!this.hasExceededDragRadius_&&(!this.flyout_||!this.flyout_.autoClose)};Blockly.Gesture.prototype.isWorkspaceClick_=function(){return!this.startBlock_&&!this.startBubble_&&!this.startField_&&!this.hasExceededDragRadius_};
-Blockly.Gesture.prototype.isDragging=function(){return this.isDraggingWorkspace_||this.isDraggingBlock_||this.isDraggingBubble_};Blockly.Gesture.prototype.hasStarted=function(){return this.hasStarted_};Blockly.Gesture.prototype.getInsertionMarkers=function(){return this.blockDragger_?this.blockDragger_.getInsertionMarkers():[]};Blockly.Gesture.inProgress=function(){for(var a=Blockly.Workspace.getAll(),b=0,c;c=a[b];b++)if(c.currentGesture_)return!0;return!1};Blockly.Field=function(a,b,c){this.tooltip_=this.validator_=this.value_=null;this.size_=new Blockly.utils.Size(0,0);this.markerSvg_=this.cursorSvg_=null;c&&this.configure_(c);this.setValue(a);b&&this.setValidator(b)};Blockly.Field.BORDER_RECT_DEFAULT_HEIGHT=16;Blockly.Field.TEXT_DEFAULT_HEIGHT=12.5;Blockly.Field.X_PADDING=10;Blockly.Field.Y_PADDING=10;Blockly.Field.DEFAULT_TEXT_OFFSET=Blockly.Field.X_PADDING/2;Blockly.Field.prototype.name=void 0;Blockly.Field.prototype.disposed=!1;
-Blockly.Field.prototype.maxDisplayLength=50;Blockly.Field.prototype.sourceBlock_=null;Blockly.Field.prototype.isDirty_=!0;Blockly.Field.prototype.visible_=!0;Blockly.Field.prototype.clickTarget_=null;Blockly.Field.NBSP="\u00a0";Blockly.Field.prototype.EDITABLE=!0;Blockly.Field.prototype.SERIALIZABLE=!1;Blockly.Field.prototype.configure_=function(a){var b=a.tooltip;"string"==typeof b&&(b=Blockly.utils.replaceMessageReferences(a.tooltip));b&&this.setTooltip(b)};
-Blockly.Field.prototype.setSourceBlock=function(a){if(this.sourceBlock_)throw Error("Field already bound to a block.");this.sourceBlock_=a};Blockly.Field.prototype.getSourceBlock=function(){return this.sourceBlock_};
-Blockly.Field.prototype.init=function(){this.fieldGroup_||(this.fieldGroup_=Blockly.utils.dom.createSvgElement("g",{},null),this.isVisible()||(this.fieldGroup_.style.display="none"),this.sourceBlock_.getSvgRoot().appendChild(this.fieldGroup_),this.initView(),this.updateEditable(),this.setTooltip(this.tooltip_),this.bindEvents_(),this.initModel())};Blockly.Field.prototype.initView=function(){this.createBorderRect_();this.createTextElement_()};Blockly.Field.prototype.initModel=function(){};
-Blockly.Field.prototype.createBorderRect_=function(){this.size_.height=Math.max(this.size_.height,Blockly.Field.BORDER_RECT_DEFAULT_HEIGHT);this.size_.width=Math.max(this.size_.width,Blockly.Field.X_PADDING);this.borderRect_=Blockly.utils.dom.createSvgElement("rect",{rx:4,ry:4,x:0,y:0,height:this.size_.height,width:this.size_.width},this.fieldGroup_)};
-Blockly.Field.prototype.createTextElement_=function(){this.textElement_=Blockly.utils.dom.createSvgElement("text",{"class":"blocklyText",y:Blockly.Field.TEXT_DEFAULT_HEIGHT,x:this.borderRect_?Blockly.Field.DEFAULT_TEXT_OFFSET:0},this.fieldGroup_);this.textContent_=document.createTextNode("");this.textElement_.appendChild(this.textContent_)};
-Blockly.Field.prototype.bindEvents_=function(){Blockly.Tooltip.bindMouseEvents(this.getClickTarget_());this.mouseDownWrapper_=Blockly.bindEventWithChecks_(this.getClickTarget_(),"mousedown",this,this.onMouseDown_)};Blockly.Field.prototype.fromXml=function(a){this.setValue(a.textContent)};Blockly.Field.prototype.toXml=function(a){a.textContent=this.getValue();return a};
-Blockly.Field.prototype.dispose=function(){Blockly.DropDownDiv.hideIfOwner(this);Blockly.WidgetDiv.hideIfOwner(this);this.mouseDownWrapper_&&Blockly.unbindEvent_(this.mouseDownWrapper_);Blockly.utils.dom.removeNode(this.fieldGroup_);this.disposed=!0};
-Blockly.Field.prototype.updateEditable=function(){var a=this.getClickTarget_();this.EDITABLE&&a&&(this.sourceBlock_.isEditable()?(Blockly.utils.dom.addClass(a,"blocklyEditableText"),Blockly.utils.dom.removeClass(a,"blocklyNonEditableText"),a.style.cursor=this.CURSOR):(Blockly.utils.dom.addClass(a,"blocklyNonEditableText"),Blockly.utils.dom.removeClass(a,"blocklyEditableText"),a.style.cursor=""))};
-Blockly.Field.prototype.isClickable=function(){return!!this.sourceBlock_&&this.sourceBlock_.isEditable()&&!!this.showEditor_&&"function"===typeof this.showEditor_};Blockly.Field.prototype.isCurrentlyEditable=function(){return this.EDITABLE&&!!this.sourceBlock_&&this.sourceBlock_.isEditable()};
-Blockly.Field.prototype.isSerializable=function(){var a=!1;this.name&&(this.SERIALIZABLE?a=!0:this.EDITABLE&&(console.warn("Detected an editable field that was not serializable. Please define SERIALIZABLE property as true on all editable custom fields. Proceeding with serialization."),a=!0));return a};Blockly.Field.prototype.isVisible=function(){return this.visible_};
-Blockly.Field.prototype.setVisible=function(a){if(this.visible_!=a){this.visible_=a;var b=this.getSvgRoot();b&&(b.style.display=a?"block":"none")}};Blockly.Field.prototype.setValidator=function(a){this.validator_=a};Blockly.Field.prototype.getValidator=function(){return this.validator_};Blockly.Field.prototype.classValidator=function(a){return a};
-Blockly.Field.prototype.callValidator=function(a){var b=this.classValidator(a);if(null===b)return null;void 0!==b&&(a=b);if(b=this.getValidator()){b=b.call(this,a);if(null===b)return null;void 0!==b&&(a=b)}return a};Blockly.Field.prototype.getSvgRoot=function(){return this.fieldGroup_};Blockly.Field.prototype.updateColour=function(){};Blockly.Field.prototype.render_=function(){this.textContent_&&(this.textContent_.nodeValue=this.getDisplayText_(),this.updateSize_())};
-Blockly.Field.prototype.updateWidth=function(){console.warn("Deprecated call to updateWidth, call Blockly.Field.updateSize_ to force an update to the size of the field, or Blockly.utils.dom.getTextWidth() to check the size of the field.");this.updateSize_()};Blockly.Field.prototype.updateSize_=function(){var a=Blockly.utils.dom.getTextWidth(this.textElement_);this.borderRect_&&(a+=Blockly.Field.X_PADDING,this.borderRect_.setAttribute("width",a));this.size_.width=a};
-Blockly.Field.prototype.getSize=function(){if(!this.isVisible())return new Blockly.utils.Size(0,0);this.isDirty_?(this.render_(),this.isDirty_=!1):this.visible_&&0==this.size_.width&&(console.warn("Deprecated use of setting size_.width to 0 to rerender a field. Set field.isDirty_ to true instead."),this.render_());return this.size_};
-Blockly.Field.prototype.getScaledBBox_=function(){var a=this.borderRect_.getBBox(),b=a.height*this.sourceBlock_.workspace.scale;a=a.width*this.sourceBlock_.workspace.scale;var c=this.getAbsoluteXY_();return{top:c.y,bottom:c.y+b,left:c.x,right:c.x+a}};
-Blockly.Field.prototype.getDisplayText_=function(){var a=this.getText();if(!a)return Blockly.Field.NBSP;a.length>this.maxDisplayLength&&(a=a.substring(0,this.maxDisplayLength-2)+"\u2026");a=a.replace(/\s/g,Blockly.Field.NBSP);this.sourceBlock_&&this.sourceBlock_.RTL&&(a+="\u200f");return a};Blockly.Field.prototype.getText=function(){if(this.getText_){var a=this.getText_.call(this);if(null!==a)return String(a)}return String(this.getValue())};
-Blockly.Field.prototype.setText=function(a){throw Error("setText method is deprecated");};Blockly.Field.prototype.markDirty=function(){this.isDirty_=!0};Blockly.Field.prototype.forceRerender=function(){this.isDirty_=!0;this.sourceBlock_&&this.sourceBlock_.rendered&&(this.sourceBlock_.render(),this.sourceBlock_.bumpNeighbours())};
-Blockly.Field.prototype.setValue=function(a){if(null!==a){var b=this.doClassValidation_(a);a=this.processValidation_(a,b);if(!(a instanceof Error)){if(b=this.getValidator())if(b=b.call(this,a),a=this.processValidation_(a,b),a instanceof Error)return;b=this.getValue();b!==a&&(this.sourceBlock_&&Blockly.Events.isEnabled()&&Blockly.Events.fire(new Blockly.Events.BlockChange(this.sourceBlock_,"field",this.name||null,b,a)),this.doValueUpdate_(a),this.isDirty_&&this.forceRerender())}}};
-Blockly.Field.prototype.processValidation_=function(a,b){if(null===b)return this.doValueInvalid_(a),this.isDirty_&&this.forceRerender(),Error();void 0!==b&&(a=b);return a};Blockly.Field.prototype.getValue=function(){return this.value_};Blockly.Field.prototype.doClassValidation_=function(a){return null===a||void 0===a?null:a=this.classValidator(a)};Blockly.Field.prototype.doValueUpdate_=function(a){this.value_=a;this.isDirty_=!0};Blockly.Field.prototype.doValueInvalid_=function(a){};
-Blockly.Field.prototype.onMouseDown_=function(a){this.sourceBlock_&&this.sourceBlock_.workspace&&(a=this.sourceBlock_.workspace.getGesture(a))&&a.setStartField(this)};Blockly.Field.prototype.setTooltip=function(a){var b=this.getClickTarget_();b?b.tooltip=a||""===a?a:this.sourceBlock_:this.tooltip_=a};Blockly.Field.prototype.getClickTarget_=function(){return this.clickTarget_||this.getSvgRoot()};Blockly.Field.prototype.getAbsoluteXY_=function(){return Blockly.utils.style.getPageOffset(this.borderRect_)};
-Blockly.Field.prototype.referencesVariables=function(){return!1};Blockly.Field.prototype.getParentInput=function(){for(var a=null,b=this.sourceBlock_,c=b.inputList,d=0;da||a>this.fieldRow.length)throw Error("index "+a+" out of bounds.");if(!(b||""==b&&c))return a;"string"==typeof b&&(b=new Blockly.FieldLabel(b));b.setSourceBlock(this.sourceBlock_);this.sourceBlock_.rendered&&b.init();b.name=c;b.prefixField&&(a=this.insertFieldAt(a,b.prefixField));this.fieldRow.splice(a,0,b);++a;b.suffixField&&(a=this.insertFieldAt(a,b.suffixField));this.sourceBlock_.rendered&&(this.sourceBlock_.render(),this.sourceBlock_.bumpNeighbours());
-return a};Blockly.Input.prototype.removeField=function(a){for(var b=0,c;c=this.fieldRow[b];b++)if(c.name===a){c.dispose();this.fieldRow.splice(b,1);this.sourceBlock_.rendered&&(this.sourceBlock_.render(),this.sourceBlock_.bumpNeighbours());return}throw Error('Field "%s" not found.',a);};Blockly.Input.prototype.isVisible=function(){return this.visible_};
-Blockly.Input.prototype.setVisible=function(a){var b=[];if(this.visible_==a)return b;for(var c=(this.visible_=a)?"block":"none",d=0,e;e=this.fieldRow[d];d++)e.setVisible(a);this.connection&&(a?b=this.connection.unhideAll():this.connection.hideAll(),d=this.connection.targetBlock())&&(d.getSvgRoot().style.display=c,a||(d.rendered=!1));return b};Blockly.Input.prototype.markDirty=function(){for(var a=0,b;b=this.fieldRow[a];a++)b.markDirty()};
-Blockly.Input.prototype.setCheck=function(a){if(!this.connection)throw Error("This input does not have a connection.");this.connection.setCheck(a);return this};Blockly.Input.prototype.setAlign=function(a){this.align=a;this.sourceBlock_.rendered&&this.sourceBlock_.render();return this};Blockly.Input.prototype.init=function(){if(this.sourceBlock_.workspace.rendered)for(var a=0;aa&&0<=b&&256>b&&0<=c&&256>c)?Blockly.utils.colour.rgbToHex(a,b,c):null};
-Blockly.utils.colour.rgbToHex=function(a,b,c){b=a<<16|b<<8|c;return 16>a?"#"+(16777216|b).toString(16).substr(1):"#"+b.toString(16)};Blockly.utils.colour.hexToRgb=function(a){a=parseInt(a.substr(1),16);return[a>>16,a>>8&255,a&255]};
-Blockly.utils.colour.hsvToHex=function(a,b,c){var d=0,e=0,f=0;if(0==b)f=e=d=c;else{var g=Math.floor(a/60),h=a/60-g;a=c*(1-b);var k=c*(1-b*h);b=c*(1-b*(1-h));switch(g){case 1:d=k;e=c;f=a;break;case 2:d=a;e=c;f=b;break;case 3:d=a;e=k;f=c;break;case 4:d=b;e=a;f=c;break;case 5:d=c;e=a;f=k;break;case 6:case 0:d=c,e=b,f=a}}return Blockly.utils.colour.rgbToHex(Math.floor(d),Math.floor(e),Math.floor(f))};
-Blockly.utils.colour.blend=function(a,b,c){a=Blockly.utils.colour.hexToRgb(Blockly.utils.colour.parse(a));b=Blockly.utils.colour.hexToRgb(Blockly.utils.colour.parse(b));return Blockly.utils.colour.rgbToHex(Math.round(b[0]+c*(a[0]-b[0])),Math.round(b[1]+c*(a[1]-b[1])),Math.round(b[2]+c*(a[2]-b[2])))};
-Blockly.utils.colour.names={aqua:"#00ffff",black:"#000000",blue:"#0000ff",fuchsia:"#ff00ff",gray:"#808080",green:"#008000",lime:"#00ff00",maroon:"#800000",navy:"#000080",olive:"#808000",purple:"#800080",red:"#ff0000",silver:"#c0c0c0",teal:"#008080",white:"#ffffff",yellow:"#ffff00"};Blockly.Block=function(a,b,c){if(Blockly.Generator&&"undefined"!=typeof Blockly.Generator.prototype[b])throw Error('Block prototypeName "'+b+'" conflicts with Blockly.Generator members.');this.id=c&&!a.getBlockById(c)?c:Blockly.utils.genUid();a.blockDB_[this.id]=this;this.previousConnection=this.nextConnection=this.outputConnection=null;this.inputList=[];this.inputsInline=void 0;this.disabled=!1;this.tooltip="";this.contextMenu=!0;this.parentBlock_=null;this.childBlocks_=[];this.editable_=this.movable_=
-this.deletable_=!0;this.collapsed_=this.isShadow_=!1;this.comment=null;this.commentModel={text:null,pinned:!1,size:new Blockly.utils.Size(160,80)};this.xy_=new Blockly.utils.Coordinate(0,0);this.workspace=a;this.isInFlyout=a.isFlyout;this.isInMutator=a.isMutator;this.RTL=a.RTL;this.isInsertionMarker_=!1;this.hat=void 0;if(b){this.type=b;c=Blockly.Blocks[b];if(!c||"object"!=typeof c)throw TypeError("Unknown block type: "+b);Blockly.utils.object.mixin(this,c)}a.addTopBlock(this);a.addTypedBlock(this);
-"function"==typeof this.init&&this.init();this.inputsInlineDefault=this.inputsInline;if(Blockly.Events.isEnabled()){(a=Blockly.Events.getGroup())||Blockly.Events.setGroup(!0);try{Blockly.Events.fire(new Blockly.Events.BlockCreate(this))}finally{a||Blockly.Events.setGroup(!1)}}"function"==typeof this.onchange&&this.setOnChange(this.onchange)};Blockly.Block.prototype.data=null;Blockly.Block.prototype.disposed=!1;Blockly.Block.prototype.hue_=null;Blockly.Block.prototype.colour_="#000000";
-Blockly.Block.prototype.colourSecondary_=null;Blockly.Block.prototype.colourTertiary_=null;Blockly.Block.prototype.styleName_=null;
-Blockly.Block.prototype.dispose=function(a){if(this.workspace){this.onchangeWrapper_&&this.workspace.removeChangeListener(this.onchangeWrapper_);Blockly.keyboardAccessibilityMode&&Blockly.navigation.moveCursorOnBlockDelete(this);this.unplug(a);Blockly.Events.isEnabled()&&Blockly.Events.fire(new Blockly.Events.BlockDelete(this));Blockly.Events.disable();try{this.workspace&&(this.workspace.removeTopBlock(this),this.workspace.removeTypedBlock(this),delete this.workspace.blockDB_[this.id],this.workspace=
-null);Blockly.selected==this&&(Blockly.selected=null);for(var b=this.childBlocks_.length-1;0<=b;b--)this.childBlocks_[b].dispose(!1);b=0;for(var c;c=this.inputList[b];b++)c.dispose();this.inputList.length=0;var d=this.getConnections_(!0);b=0;for(var e;e=d[b];b++)e.dispose()}finally{Blockly.Events.enable(),this.disposed=!0}}};Blockly.Block.prototype.initModel=function(){for(var a=0,b;b=this.inputList[a];a++)for(var c=0,d;d=b.fieldRow[c];c++)d.initModel&&d.initModel()};
-Blockly.Block.prototype.unplug=function(a){this.outputConnection?this.unplugFromRow_(a):this.previousConnection&&this.unplugFromStack_(a)};Blockly.Block.prototype.unplugFromRow_=function(a){var b=null;this.outputConnection.isConnected()&&(b=this.outputConnection.targetConnection,this.outputConnection.disconnect());if(b&&a&&(a=this.getOnlyValueConnection_())&&a.isConnected()&&!a.targetBlock().isShadow())if(a=a.targetConnection,a.disconnect(),a.checkType_(b))b.connect(a);else a.onFailedConnect(b)};
-Blockly.Block.prototype.getOnlyValueConnection_=function(){for(var a=null,b=0;b=c)this.hue_=c,this.colour_=Blockly.hueToHex(c);else if(c=Blockly.utils.colour.parse(b))this.colour_=c,this.hue_=null;else throw c='Invalid colour: "'+b+'"',a!=b&&(c+=' (from "'+a+'")'),Error(c);};
-Blockly.Block.prototype.setStyle=function(a){var b=this.workspace.getTheme().getBlockStyle(a);this.styleName_=a;if(b)this.colourSecondary_=b.colourSecondary,this.colourTertiary_=b.colourTertiary,this.hat=b.hat,this.setColour(b.colourPrimary);else throw Error("Invalid style name: "+a);};
-Blockly.Block.prototype.setOnChange=function(a){if(a&&"function"!=typeof a)throw Error("onchange must be a function.");this.onchangeWrapper_&&this.workspace.removeChangeListener(this.onchangeWrapper_);if(this.onchange=a)this.onchangeWrapper_=a.bind(this),this.workspace.addChangeListener(this.onchangeWrapper_)};Blockly.Block.prototype.getField=function(a){for(var b=0,c;c=this.inputList[b];b++)for(var d=0,e;e=c.fieldRow[d];d++)if(e.name==a)return e;return null};
-Blockly.Block.prototype.getVars=function(){for(var a=[],b=0,c;c=this.inputList[b];b++)for(var d=0,e;e=c.fieldRow[d];d++)e.referencesVariables()&&a.push(e.getValue());return a};Blockly.Block.prototype.getVarModels=function(){for(var a=[],b=0,c;c=this.inputList[b];b++)for(var d=0,e;e=c.fieldRow[d];d++)e.referencesVariables()&&(e=this.workspace.getVariableById(e.getValue()))&&a.push(e);return a};
-Blockly.Block.prototype.updateVarName=function(a){for(var b=0,c;c=this.inputList[b];b++)for(var d=0,e;e=c.fieldRow[d];d++)e.referencesVariables()&&a.getId()==e.getValue()&&e.refreshVariableName()};Blockly.Block.prototype.renameVarById=function(a,b){for(var c=0,d;d=this.inputList[c];c++)for(var e=0,f;f=d.fieldRow[e];e++)f.referencesVariables()&&a==f.getValue()&&f.setValue(b)};Blockly.Block.prototype.getFieldValue=function(a){return(a=this.getField(a))?a.getValue():null};
-Blockly.Block.prototype.setFieldValue=function(a,b){var c=this.getField(b);if(!c)throw Error('Field "'+b+'" not found.');c.setValue(a)};
-Blockly.Block.prototype.setPreviousStatement=function(a,b){if(a){void 0===b&&(b=null);if(!this.previousConnection){if(this.outputConnection)throw Error("Remove output connection prior to adding previous connection.");this.previousConnection=this.makeConnection_(Blockly.PREVIOUS_STATEMENT)}this.previousConnection.setCheck(b)}else if(this.previousConnection){if(this.previousConnection.isConnected())throw Error("Must disconnect previous statement before removing connection.");this.previousConnection.dispose();
-this.previousConnection=null}};Blockly.Block.prototype.setNextStatement=function(a,b){if(a)void 0===b&&(b=null),this.nextConnection||(this.nextConnection=this.makeConnection_(Blockly.NEXT_STATEMENT)),this.nextConnection.setCheck(b);else if(this.nextConnection){if(this.nextConnection.isConnected())throw Error("Must disconnect next statement before removing connection.");this.nextConnection.dispose();this.nextConnection=null}};
-Blockly.Block.prototype.setOutput=function(a,b){if(a){void 0===b&&(b=null);if(!this.outputConnection){if(this.previousConnection)throw Error("Remove previous connection prior to adding output connection.");this.outputConnection=this.makeConnection_(Blockly.OUTPUT_VALUE)}this.outputConnection.setCheck(b)}else if(this.outputConnection){if(this.outputConnection.isConnected())throw Error("Must disconnect output value before removing connection.");this.outputConnection.dispose();this.outputConnection=
-null}};Blockly.Block.prototype.setInputsInline=function(a){this.inputsInline!=a&&(Blockly.Events.fire(new Blockly.Events.BlockChange(this,"inline",null,this.inputsInline,a)),this.inputsInline=a)};
-Blockly.Block.prototype.getInputsInline=function(){if(void 0!=this.inputsInline)return this.inputsInline;for(var a=1;aa&&(c=c.substring(0,a-3)+"...");return c};
-Blockly.Block.prototype.appendValueInput=function(a){return this.appendInput_(Blockly.INPUT_VALUE,a)};Blockly.Block.prototype.appendStatementInput=function(a){return this.appendInput_(Blockly.NEXT_STATEMENT,a)};Blockly.Block.prototype.appendDummyInput=function(a){return this.appendInput_(Blockly.DUMMY_INPUT,a||"")};
-Blockly.Block.prototype.jsonInit=function(a){var b=a.type?'Block "'+a.type+'": ':"";if(a.output&&a.previousStatement)throw Error(b+"Must not have both an output and a previousStatement.");a.style&&a.style.hat&&(this.hat=a.style.hat,a.style=null);if(a.style&&a.colour)throw Error(b+"Must not have both a colour and a style.");a.style?this.jsonInitStyle_(a,b):this.jsonInitColour_(a,b);for(var c=0;void 0!==a["message"+c];)this.interpolate_(a["message"+c],a["args"+c]||[],a["lastDummyAlign"+c]),c++;void 0!==
-a.inputsInline&&this.setInputsInline(a.inputsInline);void 0!==a.output&&this.setOutput(!0,a.output);void 0!==a.previousStatement&&this.setPreviousStatement(!0,a.previousStatement);void 0!==a.nextStatement&&this.setNextStatement(!0,a.nextStatement);void 0!==a.tooltip&&(c=a.tooltip,c=Blockly.utils.replaceMessageReferences(c),this.setTooltip(c));void 0!==a.enableContextMenu&&(c=a.enableContextMenu,this.contextMenu=!!c);void 0!==a.helpUrl&&(c=a.helpUrl,c=Blockly.utils.replaceMessageReferences(c),this.setHelpUrl(c));
-"string"==typeof a.extensions&&(console.warn(b+"JSON attribute 'extensions' should be an array of strings. Found raw string in JSON for '"+a.type+"' block."),a.extensions=[a.extensions]);void 0!==a.mutator&&Blockly.Extensions.apply(a.mutator,this,!0);if(Array.isArray(a.extensions))for(a=a.extensions,b=0;b=h||h>b.length)throw Error('Block "'+this.type+'": Message index %'+h+" out of range.");if(e[h])throw Error('Block "'+this.type+'": Message index %'+h+" duplicated.");e[h]=!0;f++;a.push(b[h-1])}else(h=h.trim())&&a.push(h)}if(f!=b.length)throw Error('Block "'+this.type+'": Message does not reference all '+b.length+" arg(s).");
-a.length&&("string"==typeof a[a.length-1]||Blockly.utils.string.startsWith(a[a.length-1].type,"field_"))&&(g={type:"input_dummy"},c&&(g.align=c),a.push(g));c={LEFT:Blockly.ALIGN_LEFT,RIGHT:Blockly.ALIGN_RIGHT,CENTRE:Blockly.ALIGN_CENTRE};b=[];for(g=0;g=this.inputList.length)throw RangeError("Input index "+a+" out of bounds.");if(b>this.inputList.length)throw RangeError("Reference input "+b+" out of bounds.");var c=this.inputList[a];this.inputList.splice(a,1);ab||b>this.getChildCount())throw Error(Blockly.Component.Error.CHILD_INDEX_OUT_OF_BOUNDS);this.childIndex_[a.getId()]=a;if(a.getParent()==this){var d=this.children_.indexOf(a);-1a?b-1:a},this.highlightedIndex_)};
-Blockly.Menu.prototype.highlightHelper=function(a,b){var c=0>b?-1:b,d=this.getChildCount();c=a.call(this,c,d);for(var e=0;e<=d;){var f=this.getChildAt(c);if(f&&this.canHighlightItem(f))return this.setHighlightedIndex(c),!0;e++;c=a.call(this,c,d)}return!1};Blockly.Menu.prototype.canHighlightItem=function(a){return a.isEnabled()};Blockly.Menu.prototype.handleMouseOver_=function(a){(a=this.getMenuItem(a.target))&&a.isEnabled()&&this.getHighlighted()!==a&&(this.unhighlightCurrent(),this.setHighlighted(a))};
-Blockly.Menu.prototype.handleClick_=function(a){var b=this.getMenuItem(a.target);b&&b.handleClick(a)&&a.preventDefault()};Blockly.Menu.prototype.handleMouseEnter_=function(a){this.focus()};Blockly.Menu.prototype.handleMouseLeave_=function(a){this.getElement()&&(this.blur(),this.clearHighlighted())};Blockly.Menu.prototype.handleKeyEvent=function(a){return 0!=this.getChildCount()&&this.handleKeyEventInternal(a)?(a.preventDefault(),a.stopPropagation(),!0):!1};
-Blockly.Menu.prototype.handleKeyEventInternal=function(a){var b=this.getHighlighted();if(b&&"function"==typeof b.handleKeyEvent&&b.handleKeyEvent(a))return!0;if(a.shiftKey||a.ctrlKey||a.metaKey||a.altKey)return!1;switch(a.keyCode){case Blockly.utils.KeyCodes.ENTER:b&&b.performActionInternal(a);break;case Blockly.utils.KeyCodes.UP:this.highlightPrevious();break;case Blockly.utils.KeyCodes.DOWN:this.highlightNext();break;default:return!1}return!0};Blockly.MenuItem=function(a,b){Blockly.Component.call(this);this.setContentInternal(a);this.setValue(b);this.enabled_=!0};Blockly.utils.object.inherits(Blockly.MenuItem,Blockly.Component);
-Blockly.MenuItem.prototype.createDom=function(){var a=document.createElement("div");a.id=this.getId();this.setElementInternal(a);a.className="goog-menuitem goog-option "+(this.enabled_?"":"goog-menuitem-disabled ")+(this.checked_?"goog-option-selected ":"")+(this.isRightToLeft()?"goog-menuitem-rtl ":"");var b=this.getContentWrapperDom();a.appendChild(b);var c=this.getCheckboxDom();c&&b.appendChild(c);b.appendChild(this.getContentDom());Blockly.utils.aria.setRole(a,this.roleName_||(this.checkable_?
-Blockly.utils.aria.Role.MENUITEMCHECKBOX:Blockly.utils.aria.Role.MENUITEM));Blockly.utils.aria.setState(a,Blockly.utils.aria.State.SELECTED,this.checkable_&&this.checked_||!1)};Blockly.MenuItem.prototype.getCheckboxDom=function(){if(!this.checkable_)return null;var a=document.createElement("div");a.className="goog-menuitem-checkbox";return a};Blockly.MenuItem.prototype.getContentDom=function(){var a=this.content_;"string"===typeof a&&(a=document.createTextNode(a));return a};
-Blockly.MenuItem.prototype.getContentWrapperDom=function(){var a=document.createElement("div");a.className="goog-menuitem-content";return a};Blockly.MenuItem.prototype.setContentInternal=function(a){this.content_=a};Blockly.MenuItem.prototype.setValue=function(a){this.value_=a};Blockly.MenuItem.prototype.getValue=function(){return this.value_};Blockly.MenuItem.prototype.setRole=function(a){this.roleName_=a};Blockly.MenuItem.prototype.setCheckable=function(a){this.checkable_=a};
-Blockly.MenuItem.prototype.setChecked=function(a){if(this.checkable_){this.checked_=a;var b=this.getElement();b&&this.isEnabled()&&(a?(Blockly.utils.dom.addClass(b,"goog-option-selected"),Blockly.utils.aria.setState(b,Blockly.utils.aria.State.SELECTED,!0)):(Blockly.utils.dom.removeClass(b,"goog-option-selected"),Blockly.utils.aria.setState(b,Blockly.utils.aria.State.SELECTED,!1)))}};
-Blockly.MenuItem.prototype.setHighlighted=function(a){this.highlight_=a;var b=this.getElement();b&&this.isEnabled()&&(a?Blockly.utils.dom.addClass(b,"goog-menuitem-highlight"):Blockly.utils.dom.removeClass(b,"goog-menuitem-highlight"))};Blockly.MenuItem.prototype.isEnabled=function(){return this.enabled_};Blockly.MenuItem.prototype.setEnabled=function(a){this.enabled_=a;(a=this.getElement())&&(this.enabled_?Blockly.utils.dom.removeClass(a,"goog-menuitem-disabled"):Blockly.utils.dom.addClass(a,"goog-menuitem-disabled"))};
-Blockly.MenuItem.prototype.handleClick=function(a){this.isEnabled()&&(this.setHighlighted(!0),this.performActionInternal())};Blockly.MenuItem.prototype.performActionInternal=function(){this.checkable_&&this.setChecked(!this.checked_);this.actionHandler_&&this.actionHandler_.call(this.actionHandlerObj_,this)};Blockly.MenuItem.prototype.onAction=function(a,b){this.actionHandler_=a;this.actionHandlerObj_=b};Blockly.utils.uiMenu={};Blockly.utils.uiMenu.getSize=function(a){a=a.getElement();var b=Blockly.utils.style.getSize(a);b.height=a.scrollHeight;return b};Blockly.utils.uiMenu.adjustBBoxesForRTL=function(a,b,c){b.left+=c.width;b.right+=c.width;a.left+=c.width;a.right+=c.width};Blockly.ContextMenu={};Blockly.ContextMenu.currentBlock=null;Blockly.ContextMenu.eventWrapper_=null;Blockly.ContextMenu.show=function(a,b,c){Blockly.WidgetDiv.show(Blockly.ContextMenu,c,null);if(b.length){var d=Blockly.ContextMenu.populate_(b,c);Blockly.ContextMenu.position_(d,a,c);setTimeout(function(){d.getElement().focus()},1);Blockly.ContextMenu.currentBlock=null}else Blockly.ContextMenu.hide()};
-Blockly.ContextMenu.populate_=function(a,b){var c=new Blockly.Menu;c.setRightToLeft(b);for(var d=0,e;e=a[d];d++){var f=new Blockly.MenuItem(e.text);f.setRightToLeft(b);c.addChild(f,!0);f.setEnabled(e.enabled);if(e.enabled)f.onAction(function(){Blockly.ContextMenu.hide();this.callback()},e)}return c};
-Blockly.ContextMenu.position_=function(a,b,c){var d=Blockly.utils.getViewportBBox();b={top:b.clientY+d.top,bottom:b.clientY+d.top,left:b.clientX+d.left,right:b.clientX+d.left};Blockly.ContextMenu.createWidget_(a);var e=Blockly.utils.uiMenu.getSize(a);c&&Blockly.utils.uiMenu.adjustBBoxesForRTL(d,b,e);Blockly.WidgetDiv.positionWithAnchor(d,b,e,c);a.getElement().focus()};
-Blockly.ContextMenu.createWidget_=function(a){a.render(Blockly.WidgetDiv.DIV);var b=a.getElement();Blockly.utils.dom.addClass(b,"blocklyContextMenu");Blockly.bindEventWithChecks_(b,"contextmenu",null,Blockly.utils.noEvent);a.focus()};Blockly.ContextMenu.hide=function(){Blockly.WidgetDiv.hideIfOwner(Blockly.ContextMenu);Blockly.ContextMenu.currentBlock=null;Blockly.ContextMenu.eventWrapper_&&Blockly.unbindEvent_(Blockly.ContextMenu.eventWrapper_)};
-Blockly.ContextMenu.callbackFactory=function(a,b){return function(){Blockly.Events.disable();try{var c=Blockly.Xml.domToBlock(b,a.workspace),d=a.getRelativeToSurfaceXY();d.x=a.RTL?d.x-Blockly.SNAP_RADIUS:d.x+Blockly.SNAP_RADIUS;d.y+=2*Blockly.SNAP_RADIUS;c.moveBy(d.x,d.y)}finally{Blockly.Events.enable()}Blockly.Events.isEnabled()&&!c.isShadow()&&Blockly.Events.fire(new Blockly.Events.BlockCreate(c));c.select()}};
-Blockly.ContextMenu.blockDeleteOption=function(a){var b=a.getDescendants(!1).length,c=a.getNextBlock();c&&(b-=c.getDescendants(!1).length);return{text:1==b?Blockly.Msg.DELETE_BLOCK:Blockly.Msg.DELETE_X_BLOCKS.replace("%1",String(b)),enabled:!0,callback:function(){Blockly.Events.setGroup(!0);a.dispose(!0,!0);Blockly.Events.setGroup(!1)}}};Blockly.ContextMenu.blockHelpOption=function(a){return{enabled:!("function"==typeof a.helpUrl?!a.helpUrl():!a.helpUrl),text:Blockly.Msg.HELP,callback:function(){a.showHelp_()}}};
-Blockly.ContextMenu.blockDuplicateOption=function(a){var b=a.isDuplicatable();return{text:Blockly.Msg.DUPLICATE_BLOCK,enabled:b,callback:function(){Blockly.duplicate_(a)}}};Blockly.ContextMenu.blockCommentOption=function(a){var b={enabled:!Blockly.utils.userAgent.IE};a.comment?(b.text=Blockly.Msg.REMOVE_COMMENT,b.callback=function(){a.setCommentText(null)}):(b.text=Blockly.Msg.ADD_COMMENT,b.callback=function(){a.setCommentText("")});return b};
-Blockly.ContextMenu.commentDeleteOption=function(a){return{text:Blockly.Msg.REMOVE_COMMENT,enabled:!0,callback:function(){Blockly.Events.setGroup(!0);a.dispose(!0,!0);Blockly.Events.setGroup(!1)}}};Blockly.ContextMenu.commentDuplicateOption=function(a){return{text:Blockly.Msg.DUPLICATE_COMMENT,enabled:!0,callback:function(){Blockly.duplicate_(a)}}};
-Blockly.ContextMenu.workspaceCommentOption=function(a,b){if(!Blockly.WorkspaceCommentSvg)throw Error("Missing require for Blockly.WorkspaceCommentSvg");var c={enabled:!Blockly.utils.userAgent.IE};c.text=Blockly.Msg.ADD_COMMENT;c.callback=function(){var c=new Blockly.WorkspaceCommentSvg(a,Blockly.Msg.WORKSPACE_COMMENT_DEFAULT_TEXT,Blockly.WorkspaceCommentSvg.DEFAULT_SIZE,Blockly.WorkspaceCommentSvg.DEFAULT_SIZE),e=a.getInjectionDiv().getBoundingClientRect();e=new Blockly.utils.Coordinate(b.clientX-
-e.left,b.clientY-e.top);var f=a.getOriginOffsetInPixels();e=Blockly.utils.Coordinate.difference(e,f);e.scale(1/a.scale);c.moveBy(e.x,e.y);a.rendered&&(c.initSvg(),c.render(),c.select())};return c};Blockly.RenderedConnection=function(a,b){Blockly.RenderedConnection.superClass_.constructor.call(this,a,b);this.db_=a.workspace.connectionDBList[b];this.dbOpposite_=a.workspace.connectionDBList[Blockly.OPPOSITE_TYPE[b]];this.offsetInBlock_=new Blockly.utils.Coordinate(0,0);this.inDB_=!1;this.hidden_=!this.db_};Blockly.utils.object.inherits(Blockly.RenderedConnection,Blockly.Connection);
-Blockly.RenderedConnection.prototype.dispose=function(){Blockly.RenderedConnection.superClass_.dispose.call(this);this.inDB_&&this.db_.removeConnection_(this)};Blockly.RenderedConnection.prototype.distanceFrom=function(a){var b=this.x_-a.x_;a=this.y_-a.y_;return Math.sqrt(b*b+a*a)};
-Blockly.RenderedConnection.prototype.bumpAwayFrom_=function(a){if(!this.sourceBlock_.workspace.isDragging()){var b=this.sourceBlock_.getRootBlock();if(!b.isInFlyout){var c=!1;if(!b.isMovable()){b=a.getSourceBlock().getRootBlock();if(!b.isMovable())return;a=this;c=!0}var d=Blockly.selected==b;d||b.addSelect();var e=a.x_+Blockly.SNAP_RADIUS+Math.floor(Math.random()*Blockly.BUMP_RANDOMNESS)-this.x_,f=a.y_+Blockly.SNAP_RADIUS+Math.floor(Math.random()*Blockly.BUMP_RANDOMNESS)-this.y_;c&&(f=-f);b.RTL&&
-(e=a.x_-Blockly.SNAP_RADIUS-Math.floor(Math.random()*Blockly.BUMP_RANDOMNESS)-this.x_);b.moveBy(e,f);d||b.removeSelect()}}};Blockly.RenderedConnection.prototype.moveTo=function(a,b){this.inDB_&&this.db_.removeConnection_(this);this.x_=a;this.y_=b;this.hidden_||this.db_.addConnection(this)};Blockly.RenderedConnection.prototype.moveBy=function(a,b){this.moveTo(this.x_+a,this.y_+b)};Blockly.RenderedConnection.prototype.moveToOffset=function(a){this.moveTo(a.x+this.offsetInBlock_.x,a.y+this.offsetInBlock_.y)};
-Blockly.RenderedConnection.prototype.setOffsetInBlock=function(a,b){this.offsetInBlock_.x=a;this.offsetInBlock_.y=b};Blockly.RenderedConnection.prototype.getOffsetInBlock=function(){return this.offsetInBlock_};
-Blockly.RenderedConnection.prototype.tighten_=function(){var a=this.targetConnection.x_-this.x_,b=this.targetConnection.y_-this.y_;if(0!=a||0!=b){var c=this.targetBlock(),d=c.getSvgRoot();if(!d)throw Error("block is not rendered.");d=Blockly.utils.getRelativeXY(d);c.getSvgRoot().setAttribute("transform","translate("+(d.x-a)+","+(d.y-b)+")");c.moveConnections_(-a,-b)}};Blockly.RenderedConnection.prototype.closest=function(a,b){return this.dbOpposite_.searchForClosest(this,a,b)};
-Blockly.RenderedConnection.prototype.highlight=function(){var a=this.sourceBlock_.workspace.getRenderer().getConstants();a=this.type==Blockly.INPUT_VALUE||this.type==Blockly.OUTPUT_VALUE?Blockly.utils.svgPaths.moveBy(0,-5)+Blockly.utils.svgPaths.lineOnAxis("v",5)+a.PUZZLE_TAB.pathDown+Blockly.utils.svgPaths.lineOnAxis("v",5):Blockly.utils.svgPaths.moveBy(-5,0)+Blockly.utils.svgPaths.lineOnAxis("h",5)+a.NOTCH.pathLeft+Blockly.utils.svgPaths.lineOnAxis("h",5);var b=this.sourceBlock_.getRelativeToSurfaceXY();
-Blockly.Connection.highlightedPath_=Blockly.utils.dom.createSvgElement("path",{"class":"blocklyHighlightedConnectionPath",d:a,transform:"translate("+(this.x_-b.x)+","+(this.y_-b.y)+")"+(this.sourceBlock_.RTL?" scale(-1 1)":"")},this.sourceBlock_.getSvgRoot())};
-Blockly.RenderedConnection.prototype.unhideAll=function(){this.setHidden(!1);var a=[];if(this.type!=Blockly.INPUT_VALUE&&this.type!=Blockly.NEXT_STATEMENT)return a;var b=this.targetBlock();if(b){if(b.isCollapsed()){var c=[];b.outputConnection&&c.push(b.outputConnection);b.nextConnection&&c.push(b.nextConnection);b.previousConnection&&c.push(b.previousConnection)}else c=b.getConnections_(!0);for(var d=0;db?!1:Blockly.RenderedConnection.superClass_.isConnectionAllowed.call(this,a)};
-Blockly.RenderedConnection.prototype.onFailedConnect=function(a){this.bumpAwayFrom_(a)};Blockly.RenderedConnection.prototype.disconnectInternal_=function(a,b){Blockly.RenderedConnection.superClass_.disconnectInternal_.call(this,a,b);a.rendered&&a.render();b.rendered&&(b.updateDisabled(),b.render())};
-Blockly.RenderedConnection.prototype.respawnShadow_=function(){var a=this.getSourceBlock(),b=this.getShadowDom();if(a.workspace&&b&&Blockly.Events.recordUndo){Blockly.RenderedConnection.superClass_.respawnShadow_.call(this);b=this.targetBlock();if(!b)throw Error("Couldn't respawn the shadow block that should exist here.");b.initSvg();b.render(!1);a.rendered&&a.render()}};Blockly.RenderedConnection.prototype.neighbours_=function(a){return this.dbOpposite_.getNeighbours(this,a)};
-Blockly.RenderedConnection.prototype.connect_=function(a){Blockly.RenderedConnection.superClass_.connect_.call(this,a);var b=this.getSourceBlock();a=a.getSourceBlock();b.rendered&&b.updateDisabled();a.rendered&&a.updateDisabled();b.rendered&&a.rendered&&(this.type==Blockly.NEXT_STATEMENT||this.type==Blockly.PREVIOUS_STATEMENT?a.render():b.render())};
-Blockly.RenderedConnection.prototype.onCheckChanged_=function(){this.isConnected()&&!this.checkType_(this.targetConnection)&&((this.isSuperior()?this.targetBlock():this.sourceBlock_).unplug(),this.sourceBlock_.bumpNeighbours())};Blockly.utils.Rect=function(a,b,c,d){this.top=a;this.bottom=b;this.left=c;this.right=d};Blockly.utils.Rect.prototype.contains=function(a,b){return a>=this.left&&a<=this.right&&b>=this.top&&b<=this.bottom};Blockly.Icon=function(a){this.block_=a};Blockly.Icon.prototype.collapseHidden=!0;Blockly.Icon.prototype.SIZE=17;Blockly.Icon.prototype.bubble_=null;Blockly.Icon.prototype.iconXY_=null;
-Blockly.Icon.prototype.createIcon=function(){this.iconGroup_||(this.iconGroup_=Blockly.utils.dom.createSvgElement("g",{"class":"blocklyIconGroup"},null),this.block_.isInFlyout&&Blockly.utils.dom.addClass(this.iconGroup_,"blocklyIconGroupReadonly"),this.drawIcon_(this.iconGroup_),this.block_.getSvgRoot().appendChild(this.iconGroup_),Blockly.bindEventWithChecks_(this.iconGroup_,"mouseup",this,this.iconClick_),this.updateEditable())};
-Blockly.Icon.prototype.dispose=function(){Blockly.utils.dom.removeNode(this.iconGroup_);this.iconGroup_=null;this.setVisible(!1);this.block_=null};Blockly.Icon.prototype.updateEditable=function(){};Blockly.Icon.prototype.isVisible=function(){return!!this.bubble_};Blockly.Icon.prototype.iconClick_=function(a){this.block_.workspace.isDragging()||this.block_.isInFlyout||Blockly.utils.isRightButton(a)||this.setVisible(!this.isVisible())};
-Blockly.Icon.prototype.updateColour=function(){this.isVisible()&&this.bubble_.setColour(this.block_.getColour())};Blockly.Icon.prototype.setIconLocation=function(a){this.iconXY_=a;this.isVisible()&&this.bubble_.setAnchorLocation(a)};
-Blockly.Icon.prototype.computeIconLocation=function(){var a=this.block_.getRelativeToSurfaceXY(),b=Blockly.utils.getRelativeXY(this.iconGroup_);a=new Blockly.utils.Coordinate(a.x+b.x+this.SIZE/2,a.y+b.y+this.SIZE/2);Blockly.utils.Coordinate.equals(this.getIconLocation(),a)||this.setIconLocation(a)};Blockly.Icon.prototype.getIconLocation=function(){return this.iconXY_};
-Blockly.Icon.prototype.getCorrectedSize=function(){return new Blockly.utils.Size(Blockly.Icon.prototype.SIZE,Blockly.Icon.prototype.SIZE-2)};Blockly.Warning=function(a){Blockly.Warning.superClass_.constructor.call(this,a);this.createIcon();this.text_={}};Blockly.utils.object.inherits(Blockly.Warning,Blockly.Icon);Blockly.Warning.prototype.collapseHidden=!1;
-Blockly.Warning.prototype.drawIcon_=function(a){Blockly.utils.dom.createSvgElement("path",{"class":"blocklyIconShape",d:"M2,15Q-1,15 0.5,12L6.5,1.7Q8,-1 9.5,1.7L15.5,12Q17,15 14,15z"},a);Blockly.utils.dom.createSvgElement("path",{"class":"blocklyIconSymbol",d:"m7,4.8v3.16l0.27,2.27h1.46l0.27,-2.27v-3.16z"},a);Blockly.utils.dom.createSvgElement("rect",{"class":"blocklyIconSymbol",x:"7",y:"11",height:"2",width:"2"},a)};
-Blockly.Warning.textToDom_=function(a){var b=Blockly.utils.dom.createSvgElement("text",{"class":"blocklyText blocklyBubbleText",y:Blockly.Bubble.BORDER_WIDTH},null);a=a.split("\n");for(var c=0;c>>/g,d);d=document.createElement("style");c=document.createTextNode(c);d.appendChild(c);document.head.insertBefore(d,document.head.firstChild)}}};Blockly.Css.setCursor=function(a){console.warn("Deprecated call to Blockly.Css.setCursor. See https://github.com/google/blockly/issues/981 for context")};
-Blockly.Css.CONTENT=[".blocklySvg {","background-color: #fff;","outline: none;","overflow: hidden;","position: absolute;","display: block;","}",".blocklyWidgetDiv {","display: none;","position: absolute;","z-index: 99999;","}",".injectionDiv {","height: 100%;","position: relative;","overflow: hidden;","touch-action: none;","}",".blocklyNonSelectable {","user-select: none;","-ms-user-select: none;","-webkit-user-select: none;","}",".blocklyWsDragSurface {","display: none;","position: absolute;","top: 0;",
-"left: 0;","}",".blocklyWsDragSurface.blocklyOverflowVisible {","overflow: visible;","}",".blocklyBlockDragSurface {","display: none;","position: absolute;","top: 0;","left: 0;","right: 0;","bottom: 0;","overflow: visible !important;","z-index: 50;","}",".blocklyBlockCanvas.blocklyCanvasTransitioning,",".blocklyBubbleCanvas.blocklyCanvasTransitioning {","transition: transform .5s;","}",".blocklyTooltipDiv {","background-color: #ffffc7;","border: 1px solid #ddc;","box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15);",
-"color: #000;","display: none;","font-family: sans-serif;","font-size: 9pt;","opacity: .9;","padding: 2px;","position: absolute;","z-index: 100000;","}",".blocklyDropDownDiv {","position: fixed;","left: 0;","top: 0;","z-index: 1000;","display: none;","border: 1px solid;","border-radius: 2px;","padding: 4px;","box-shadow: 0px 0px 3px 1px rgba(0,0,0,.3);","}",".blocklyDropDownDiv.focused {","box-shadow: 0px 0px 6px 1px rgba(0,0,0,.3);","}",".blocklyDropDownContent {","max-height: 300px;","overflow: auto;",
-"overflow-x: hidden;","}",".blocklyDropDownArrow {","position: absolute;","left: 0;","top: 0;","width: 16px;","height: 16px;","z-index: -1;","background-color: inherit;","border-color: inherit;","}",".blocklyDropDownButton {","display: inline-block;","float: left;","padding: 0;","margin: 4px;","border-radius: 4px;","outline: none;","border: 1px solid;","transition: box-shadow .1s;","cursor: pointer;","}",".arrowTop {","border-top: 1px solid;","border-left: 1px solid;","border-top-left-radius: 4px;",
-"border-color: inherit;","}",".arrowBottom {","border-bottom: 1px solid;","border-right: 1px solid;","border-bottom-right-radius: 4px;","border-color: inherit;","}",".blocklyResizeSE {","cursor: se-resize;","fill: #aaa;","}",".blocklyResizeSW {","cursor: sw-resize;","fill: #aaa;","}",".blocklyResizeLine {","stroke: #515A5A;","stroke-width: 1;","}",".blocklyHighlightedConnectionPath {","fill: none;","stroke: #fc3;","stroke-width: 4px;","}",".blocklyPathLight {","fill: none;","stroke-linecap: round;",
-"stroke-width: 1;","}",".blocklySelected>.blocklyPath {","stroke: #fc3;","stroke-width: 3px;","}",".blocklySelected>.blocklyPathLight {","display: none;","}",".blocklyDraggable {",'cursor: url("<<>>/handopen.cur"), auto;',"cursor: grab;","cursor: -webkit-grab;","}",".blocklyDragging {",'cursor: url("<<>>/handclosed.cur"), auto;',"cursor: grabbing;","cursor: -webkit-grabbing;","}",".blocklyDraggable:active {",'cursor: url("<<>>/handclosed.cur"), auto;',"cursor: grabbing;","cursor: -webkit-grabbing;",
-"}",".blocklyBlockDragSurface .blocklyDraggable {",'cursor: url("<<>>/handclosed.cur"), auto;',"cursor: grabbing;","cursor: -webkit-grabbing;","}",".blocklyDragging.blocklyDraggingDelete {",'cursor: url("<<>>/handdelete.cur"), auto;',"}",".blocklyDragging>.blocklyPath,",".blocklyDragging>.blocklyPathLight {","fill-opacity: .8;","stroke-opacity: .8;","}",".blocklyDragging>.blocklyPathDark {","display: none;","}",".blocklyDisabled>.blocklyPath {","fill-opacity: .5;","stroke-opacity: .5;",
-"}",".blocklyDisabled>.blocklyPathLight,",".blocklyDisabled>.blocklyPathDark {","display: none;","}",".blocklyInsertionMarker>.blocklyPath,",".blocklyInsertionMarker>.blocklyPathLight,",".blocklyInsertionMarker>.blocklyPathDark {","fill-opacity: .2;","stroke: none","}",".blocklyReplaceable .blocklyPath {","fill-opacity: .5;","}",".blocklyReplaceable .blocklyPathLight,",".blocklyReplaceable .blocklyPathDark {","display: none;","}",".blocklyText {","cursor: default;","fill: #fff;","font-family: sans-serif;",
-"font-size: 11pt;","}",".blocklyMultilineText {","font-family: monospace;","}",".blocklyNonEditableText>text {","pointer-events: none;","}",".blocklyNonEditableText>rect,",".blocklyEditableText>rect {","fill: #fff;","fill-opacity: .6;","}",".blocklyNonEditableText>text,",".blocklyEditableText>text {","fill: #000;","}",".blocklyEditableText:hover>rect {","stroke: #fff;","stroke-width: 2;","}",".blocklyBubbleText {","fill: #000;","}",".blocklyFlyout {","position: absolute;","z-index: 20;","}",".blocklySvg text, .blocklyBlockDragSurface text {",
-"user-select: none;","-ms-user-select: none;","-webkit-user-select: none;","cursor: inherit;","}",".blocklyHidden {","display: none;","}",".blocklyFieldDropdown:not(.blocklyHidden) {","display: block;","}",".blocklyIconGroup {","cursor: default;","}",".blocklyIconGroup:not(:hover),",".blocklyIconGroupReadonly {","opacity: .6;","}",".blocklyIconShape {","fill: #00f;","stroke: #fff;","stroke-width: 1px;","}",".blocklyIconSymbol {","fill: #fff;","}",".blocklyMinimalBody {","margin: 0;","padding: 0;",
-"}",".blocklyCommentForeignObject {","position: relative;","z-index: 0;","}",".blocklyCommentRect {","fill: #E7DE8E;","stroke: #bcA903;","stroke-width: 1px","}",".blocklyCommentTarget {","fill: transparent;","stroke: #bcA903;","}",".blocklyCommentTargetFocused {","fill: none;","}",".blocklyCommentHandleTarget {","fill: none;","}",".blocklyCommentHandleTargetFocused {","fill: transparent;","}",".blocklyFocused>.blocklyCommentRect {","fill: #B9B272;","stroke: #B9B272;","}",".blocklySelected>.blocklyCommentTarget {",
-"stroke: #fc3;","stroke-width: 3px;","}",".blocklyCommentTextarea {","background-color: #fef49c;","border: 0;","outline: 0;","margin: 0;","padding: 3px;","resize: none;","display: block;","overflow: hidden;","}",".blocklyCommentDeleteIcon {","cursor: pointer;","fill: #000;","display: none","}",".blocklySelected > .blocklyCommentDeleteIcon {","display: block","}",".blocklyDeleteIconShape {","fill: #000;","stroke: #000;","stroke-width: 1px;","}",".blocklyDeleteIconShape.blocklyDeleteIconHighlighted {",
-"stroke: #fc3;","}",".blocklyHtmlInput {","border: none;","border-radius: 4px;","font-family: sans-serif;","height: 100%;","margin: 0;","outline: none;","padding: 0;","width: 100%;","text-align: center;","}",".blocklyHtmlInput::-ms-clear {","display: none;","}",".blocklyMainBackground {","stroke-width: 1;","stroke: #c6c6c6;","}",".blocklyMutatorBackground {","fill: #fff;","stroke: #ddd;","stroke-width: 1;","}",".blocklyFlyoutBackground {","fill: #ddd;","fill-opacity: .8;","}",".blocklyMainWorkspaceScrollbar {",
-"z-index: 20;","}",".blocklyFlyoutScrollbar {","z-index: 30;","}",".blocklyScrollbarHorizontal, .blocklyScrollbarVertical {","position: absolute;","outline: none;","}",".blocklyScrollbarBackground {","opacity: 0;","}",".blocklyScrollbarHandle {","fill: #ccc;","}",".blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,",".blocklyScrollbarHandle:hover {","fill: #bbb;","}",".blocklyFlyout .blocklyScrollbarHandle {","fill: #bbb;","}",".blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,",
-".blocklyFlyout .blocklyScrollbarHandle:hover {","fill: #aaa;","}",".blocklyInvalidInput {","background: #faa;","}",".blocklyContextMenu {","border-radius: 4px;","max-height: 100%;","}",".blocklyDropdownMenu {","border-radius: 2px;","padding: 0 !important;","}",".blocklyWidgetDiv .blocklyDropdownMenu .goog-menuitem,",".blocklyDropDownDiv .blocklyDropdownMenu .goog-menuitem {","padding-left: 28px;","}",".blocklyWidgetDiv .blocklyDropdownMenu .goog-menuitem.goog-menuitem-rtl,",".blocklyDropDownDiv .blocklyDropdownMenu .goog-menuitem.goog-menuitem-rtl {",
-"padding-left: 5px;","padding-right: 28px;","}",".blocklyVerticalCursor {","stroke-width: 3px;","fill: rgba(255,255,255,.5);","}",".blocklyWidgetDiv .goog-option-selected .goog-menuitem-checkbox,",".blocklyWidgetDiv .goog-option-selected .goog-menuitem-icon,",".blocklyDropDownDiv .goog-option-selected .goog-menuitem-checkbox,",".blocklyDropDownDiv .goog-option-selected .goog-menuitem-icon {","background: url(<<>>/sprites.png) no-repeat -48px -16px;","}",".blocklyWidgetDiv .goog-menu {","background: #fff;",
-"border-color: transparent;","border-style: solid;","border-width: 1px;","cursor: default;","font: normal 13px Arial, sans-serif;","margin: 0;","outline: none;","padding: 4px 0;","position: absolute;","overflow-y: auto;","overflow-x: hidden;","max-height: 100%;","z-index: 20000;","box-shadow: 0px 0px 3px 1px rgba(0,0,0,.3);","}",".blocklyWidgetDiv .goog-menu.focused {","box-shadow: 0px 0px 6px 1px rgba(0,0,0,.3);","}",".blocklyDropDownDiv .goog-menu {","cursor: default;",'font: normal 13px "Helvetica Neue", Helvetica, sans-serif;',
-"outline: none;","z-index: 20000;","}",".blocklyWidgetDiv .goog-menuitem,",".blocklyDropDownDiv .goog-menuitem {","color: #000;","font: normal 13px Arial, sans-serif;","list-style: none;","margin: 0;","min-width: 7em;","border: none;","padding: 6px 15px;","white-space: nowrap;","cursor: pointer;","}",".blocklyWidgetDiv .goog-menu-nocheckbox .goog-menuitem,",".blocklyWidgetDiv .goog-menu-noicon .goog-menuitem,",".blocklyDropDownDiv .goog-menu-nocheckbox .goog-menuitem,",".blocklyDropDownDiv .goog-menu-noicon .goog-menuitem {",
-"padding-left: 12px;","}",".blocklyWidgetDiv .goog-menuitem-content,",".blocklyDropDownDiv .goog-menuitem-content {","font: normal 13px Arial, sans-serif;","}",".blocklyWidgetDiv .goog-menuitem-content {","color: #000;","}",".blocklyDropDownDiv .goog-menuitem-content {","color: #000;","}",".blocklyWidgetDiv .goog-menuitem-disabled,",".blocklyDropDownDiv .goog-menuitem-disabled {","cursor: inherit;","}",".blocklyWidgetDiv .goog-menuitem-disabled .goog-menuitem-content,",".blocklyDropDownDiv .goog-menuitem-disabled .goog-menuitem-content {",
-"color: #ccc !important;","}",".blocklyWidgetDiv .goog-menuitem-disabled .goog-menuitem-icon,",".blocklyDropDownDiv .goog-menuitem-disabled .goog-menuitem-icon {","opacity: .3;","filter: alpha(opacity=30);","}",".blocklyWidgetDiv .goog-menuitem-highlight ,",".blocklyDropDownDiv .goog-menuitem-highlight {","background-color: rgba(0,0,0,.1);","}",".blocklyWidgetDiv .goog-menuitem-checkbox,",".blocklyWidgetDiv .goog-menuitem-icon,",".blocklyDropDownDiv .goog-menuitem-checkbox,",".blocklyDropDownDiv .goog-menuitem-icon {",
-"background-repeat: no-repeat;","height: 16px;","left: 6px;","position: absolute;","right: auto;","vertical-align: middle;","width: 16px;","}",".blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-checkbox,",".blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-icon,",".blocklyDropDownDiv .goog-menuitem-rtl .goog-menuitem-checkbox,",".blocklyDropDownDiv .goog-menuitem-rtl .goog-menuitem-icon {","left: auto;","right: 6px;","}",".blocklyWidgetDiv .goog-option-selected .goog-menuitem-checkbox,",".blocklyWidgetDiv .goog-option-selected .goog-menuitem-icon,",
-".blocklyDropDownDiv .goog-option-selected .goog-menuitem-checkbox,",".blocklyDropDownDiv .goog-option-selected .goog-menuitem-icon {","position: static;","float: left;","margin-left: -24px;","}",".blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-checkbox,",".blocklyWidgetDiv .goog-menuitem-rtl .goog-menuitem-icon,",".blocklyDropDownDiv .goog-menuitem-rtl .goog-menuitem-checkbox,",".blocklyDropDownDiv .goog-menuitem-rtl .goog-menuitem-icon {","float: right;","margin-right: -24px;","}"];
-Blockly.DropDownDiv=function(){};Blockly.DropDownDiv.DIV_=null;Blockly.DropDownDiv.boundsElement_=null;Blockly.DropDownDiv.owner_=null;Blockly.DropDownDiv.positionToField_=null;Blockly.DropDownDiv.ARROW_SIZE=16;Blockly.DropDownDiv.BORDER_SIZE=1;Blockly.DropDownDiv.ARROW_HORIZONTAL_PADDING=12;Blockly.DropDownDiv.PADDING_Y=16;Blockly.DropDownDiv.ANIMATION_TIME=.25;Blockly.DropDownDiv.DEFAULT_DROPDOWN_BORDER_COLOR="#dadce0";Blockly.DropDownDiv.DEFAULT_DROPDOWN_COLOR="#fff";
-Blockly.DropDownDiv.animateOutTimer_=null;Blockly.DropDownDiv.onHide_=null;
-Blockly.DropDownDiv.createDom=function(){if(!Blockly.DropDownDiv.DIV_){var a=document.createElement("div");a.className="blocklyDropDownDiv";a.style.backgroundColor=Blockly.DropDownDiv.DEFAULT_DROPDOWN_COLOR;a.style.borderColor=Blockly.DropDownDiv.DEFAULT_DROPDOWN_BORDER_COLOR;document.body.appendChild(a);Blockly.DropDownDiv.DIV_=a;var b=document.createElement("div");b.className="blocklyDropDownContent";a.appendChild(b);Blockly.DropDownDiv.content_=b;b=document.createElement("div");b.className="blocklyDropDownArrow";
-a.appendChild(b);Blockly.DropDownDiv.arrow_=b;Blockly.DropDownDiv.DIV_.style.opacity=0;Blockly.DropDownDiv.DIV_.style.transition="transform "+Blockly.DropDownDiv.ANIMATION_TIME+"s, opacity "+Blockly.DropDownDiv.ANIMATION_TIME+"s";a.addEventListener("focusin",function(){Blockly.utils.dom.addClass(a,"focused")});a.addEventListener("focusout",function(){Blockly.utils.dom.removeClass(a,"focused")})}};Blockly.DropDownDiv.setBoundsElement=function(a){Blockly.DropDownDiv.boundsElement_=a};
-Blockly.DropDownDiv.getContentDiv=function(){return Blockly.DropDownDiv.content_};Blockly.DropDownDiv.clearContent=function(){Blockly.DropDownDiv.content_.innerHTML="";Blockly.DropDownDiv.content_.style.width=""};Blockly.DropDownDiv.setColour=function(a,b){Blockly.DropDownDiv.DIV_.style.backgroundColor=a;Blockly.DropDownDiv.DIV_.style.borderColor=b};Blockly.DropDownDiv.setCategory=function(a){Blockly.DropDownDiv.DIV_.setAttribute("data-category",a)};
-Blockly.DropDownDiv.showPositionedByBlock=function(a,b,c,d){var e=b.workspace.scale,f=b.width,g=b.height;f*=e;g*=e;e=b.getSvgRoot().getBoundingClientRect();f=e.left+f/2;g=e.top+g;e=e.top;d&&(e+=d);Blockly.DropDownDiv.setBoundsElement(b.workspace.getParentSvg().parentNode);return Blockly.DropDownDiv.show(a,b.RTL,f,g,f,e,c)};
-Blockly.DropDownDiv.showPositionedByField=function(a,b,c){var d=a.getSvgRoot().getBoundingClientRect(),e=d.left+d.width/2,f=d.bottom;d=d.top;c&&(d+=c);c=a.getSourceBlock();Blockly.DropDownDiv.positionToField_=!0;Blockly.DropDownDiv.setBoundsElement(c.workspace.getParentSvg().parentNode);return Blockly.DropDownDiv.show(a,c.RTL,e,f,e,d,b)};
-Blockly.DropDownDiv.show=function(a,b,c,d,e,f,g){Blockly.DropDownDiv.owner_=a;Blockly.DropDownDiv.onHide_=g||null;a=Blockly.DropDownDiv.getPositionMetrics(c,d,e,f);a.arrowVisible?(Blockly.DropDownDiv.arrow_.style.display="",Blockly.DropDownDiv.arrow_.style.transform="translate("+a.arrowX+"px,"+a.arrowY+"px) rotate(45deg)",Blockly.DropDownDiv.arrow_.setAttribute("class",a.arrowAtTop?"blocklyDropDownArrow arrowTop":"blocklyDropDownArrow arrowBottom")):Blockly.DropDownDiv.arrow_.style.display="none";
-Blockly.DropDownDiv.DIV_.style.direction=b?"rtl":"ltr";Blockly.DropDownDiv.positionInternal_(a.initialX,a.initialY,a.finalX,a.finalY);return a.arrowAtTop};Blockly.DropDownDiv.getBoundsInfo_=function(){var a=Blockly.DropDownDiv.boundsElement_.getBoundingClientRect(),b=Blockly.utils.style.getSize(Blockly.DropDownDiv.boundsElement_);return{left:a.left,right:a.left+b.width,top:a.top,bottom:a.top+b.height,width:b.width,height:b.height}};
-Blockly.DropDownDiv.getPositionMetrics=function(a,b,c,d){var e=Blockly.DropDownDiv.getBoundsInfo_(),f=Blockly.utils.style.getSize(Blockly.DropDownDiv.DIV_);return b+f.heighte.top?Blockly.DropDownDiv.getPositionAboveMetrics(c,d,e,f):b+f.heightdocument.documentElement.clientTop?Blockly.DropDownDiv.getPositionAboveMetrics(c,d,
-e,f):Blockly.DropDownDiv.getPositionTopOfPageMetrics(a,e,f)};Blockly.DropDownDiv.getPositionBelowMetrics=function(a,b,c,d){a=Blockly.DropDownDiv.getPositionX(a,c.left,c.right,d.width);return{initialX:a.divX,initialY:b,finalX:a.divX,finalY:b+Blockly.DropDownDiv.PADDING_Y,arrowX:a.arrowX,arrowY:-(Blockly.DropDownDiv.ARROW_SIZE/2+Blockly.DropDownDiv.BORDER_SIZE),arrowAtTop:!0,arrowVisible:!0}};
-Blockly.DropDownDiv.getPositionAboveMetrics=function(a,b,c,d){a=Blockly.DropDownDiv.getPositionX(a,c.left,c.right,d.width);return{initialX:a.divX,initialY:b-d.height,finalX:a.divX,finalY:b-d.height-Blockly.DropDownDiv.PADDING_Y,arrowX:a.arrowX,arrowY:d.height-2*Blockly.DropDownDiv.BORDER_SIZE-Blockly.DropDownDiv.ARROW_SIZE/2,arrowAtTop:!1,arrowVisible:!0}};
-Blockly.DropDownDiv.getPositionTopOfPageMetrics=function(a,b,c){a=Blockly.DropDownDiv.getPositionX(a,b.left,b.right,c.width);return{initialX:a.divX,initialY:0,finalX:a.divX,finalY:0,arrowVisible:!1}};Blockly.DropDownDiv.getPositionX=function(a,b,c,d){var e=a;a=Blockly.utils.math.clamp(b,a-d/2,c-d);e-=Blockly.DropDownDiv.ARROW_SIZE/2;b=Blockly.DropDownDiv.ARROW_HORIZONTAL_PADDING;d=Blockly.utils.math.clamp(b,e-a,d-b-Blockly.DropDownDiv.ARROW_SIZE);return{arrowX:d,divX:a}};
-Blockly.DropDownDiv.isVisible=function(){return!!Blockly.DropDownDiv.owner_};Blockly.DropDownDiv.hideIfOwner=function(a,b){return Blockly.DropDownDiv.owner_===a?(b?Blockly.DropDownDiv.hideWithoutAnimation():Blockly.DropDownDiv.hide(),!0):!1};
-Blockly.DropDownDiv.hide=function(){var a=Blockly.DropDownDiv.DIV_;a.style.transform="translate(0, 0)";a.style.opacity=0;Blockly.DropDownDiv.animateOutTimer_=setTimeout(function(){Blockly.DropDownDiv.hideWithoutAnimation()},1E3*Blockly.DropDownDiv.ANIMATION_TIME);Blockly.DropDownDiv.onHide_&&(Blockly.DropDownDiv.onHide_(),Blockly.DropDownDiv.onHide_=null)};
-Blockly.DropDownDiv.hideWithoutAnimation=function(){if(Blockly.DropDownDiv.isVisible()){Blockly.DropDownDiv.animateOutTimer_&&clearTimeout(Blockly.DropDownDiv.animateOutTimer_);var a=Blockly.DropDownDiv.DIV_;a.style.transform="";a.style.left="";a.style.top="";a.style.opacity=0;a.style.display="none";a.style.backgroundColor=Blockly.DropDownDiv.DEFAULT_DROPDOWN_COLOR;a.style.borderColor=Blockly.DropDownDiv.DEFAULT_DROPDOWN_BORDER_COLOR;Blockly.DropDownDiv.onHide_&&(Blockly.DropDownDiv.onHide_(),Blockly.DropDownDiv.onHide_=
-null);Blockly.DropDownDiv.clearContent();Blockly.DropDownDiv.owner_=null}};Blockly.DropDownDiv.positionInternal_=function(a,b,c,d){a=Math.floor(a);b=Math.floor(b);c=Math.floor(c);d=Math.floor(d);var e=Blockly.DropDownDiv.DIV_;e.style.left=a+"px";e.style.top=b+"px";e.style.display="block";e.style.opacity=1;e.style.transform="translate("+(c-a)+"px,"+(d-b)+"px)"};
-Blockly.DropDownDiv.repositionForWindowResize=function(){if(Blockly.DropDownDiv.owner_){var a=Blockly.DropDownDiv.owner_.getSourceBlock(),b=a.workspace.scale,c=Blockly.DropDownDiv.positionToField_?Blockly.DropDownDiv.owner_.size_.width:a.width,d=Blockly.DropDownDiv.positionToField_?Blockly.DropDownDiv.owner_.size_.height:a.height;c*=b;d*=b;a=Blockly.DropDownDiv.positionToField_?Blockly.DropDownDiv.owner_.fieldGroup_.getBoundingClientRect():a.getSvgRoot().getBoundingClientRect();c=a.left+c/2;d=Blockly.DropDownDiv.getPositionMetrics(c,
-a.top+d,c,a.top);Blockly.DropDownDiv.positionInternal_(d.initialX,d.initialY,d.finalX,d.finalY)}else Blockly.DropDownDiv.hide()};Blockly.Grid=function(a,b){this.gridPattern_=a;this.spacing_=b.spacing;this.length_=b.length;this.line2_=(this.line1_=a.firstChild)&&this.line1_.nextSibling;this.snapToGrid_=b.snap};Blockly.Grid.prototype.scale_=1;Blockly.Grid.prototype.dispose=function(){this.gridPattern_=null};Blockly.Grid.prototype.shouldSnap=function(){return this.snapToGrid_};Blockly.Grid.prototype.getSpacing=function(){return this.spacing_};Blockly.Grid.prototype.getPatternId=function(){return this.gridPattern_.id};
-Blockly.Grid.prototype.update=function(a){this.scale_=a;var b=this.spacing_*a||100;this.gridPattern_.setAttribute("width",b);this.gridPattern_.setAttribute("height",b);b=Math.floor(this.spacing_/2)+.5;var c=b-this.length_/2,d=b+this.length_/2;b*=a;c*=a;d*=a;this.setLineAttributes_(this.line1_,a,c,d,b,b);this.setLineAttributes_(this.line2_,a,b,b,c,d)};
-Blockly.Grid.prototype.setLineAttributes_=function(a,b,c,d,e,f){a&&(a.setAttribute("stroke-width",b),a.setAttribute("x1",c),a.setAttribute("y1",e),a.setAttribute("x2",d),a.setAttribute("y2",f))};Blockly.Grid.prototype.moveTo=function(a,b){this.gridPattern_.setAttribute("x",a);this.gridPattern_.setAttribute("y",b);(Blockly.utils.userAgent.IE||Blockly.utils.userAgent.EDGE)&&this.update(this.scale_)};
-Blockly.Grid.createDom=function(a,b,c){a=Blockly.utils.dom.createSvgElement("pattern",{id:"blocklyGridPattern"+a,patternUnits:"userSpaceOnUse"},c);0 document.");}else a=null;return a};Blockly.WorkspaceDragSurfaceSvg=function(a){this.container_=a;this.createDom()};Blockly.WorkspaceDragSurfaceSvg.prototype.SVG_=null;Blockly.WorkspaceDragSurfaceSvg.prototype.dragGroup_=null;Blockly.WorkspaceDragSurfaceSvg.prototype.container_=null;
-Blockly.WorkspaceDragSurfaceSvg.prototype.createDom=function(){this.SVG_||(this.SVG_=Blockly.utils.dom.createSvgElement("svg",{xmlns:Blockly.utils.dom.SVG_NS,"xmlns:html":Blockly.utils.dom.HTML_NS,"xmlns:xlink":Blockly.utils.dom.XLINK_NS,version:"1.1","class":"blocklyWsDragSurface blocklyOverflowVisible"},null),this.container_.appendChild(this.SVG_))};
-Blockly.WorkspaceDragSurfaceSvg.prototype.translateSurface=function(a,b){var c=a.toFixed(0),d=b.toFixed(0);this.SVG_.style.display="block";Blockly.utils.dom.setCssTransform(this.SVG_,"translate3d("+c+"px, "+d+"px, 0px)")};Blockly.WorkspaceDragSurfaceSvg.prototype.getSurfaceTranslation=function(){return Blockly.utils.getRelativeXY(this.SVG_)};
-Blockly.WorkspaceDragSurfaceSvg.prototype.clearAndHide=function(a){if(!a)throw Error("Couldn't clear and hide the drag surface: missing new surface.");var b=this.SVG_.childNodes[0],c=this.SVG_.childNodes[1];if(!(b&&c&&Blockly.utils.dom.hasClass(b,"blocklyBlockCanvas")&&Blockly.utils.dom.hasClass(c,"blocklyBubbleCanvas")))throw Error("Couldn't clear and hide the drag surface. A node was missing.");null!=this.previousSibling_?Blockly.utils.dom.insertAfter(b,this.previousSibling_):a.insertBefore(b,a.firstChild);
-Blockly.utils.dom.insertAfter(c,b);this.SVG_.style.display="none";if(this.SVG_.childNodes.length)throw Error("Drag surface was not cleared.");Blockly.utils.dom.setCssTransform(this.SVG_,"");this.previousSibling_=null};
-Blockly.WorkspaceDragSurfaceSvg.prototype.setContentsAndShow=function(a,b,c,d,e,f){if(this.SVG_.childNodes.length)throw Error("Already dragging a block.");this.previousSibling_=c;a.setAttribute("transform","translate(0, 0) scale("+f+")");b.setAttribute("transform","translate(0, 0) scale("+f+")");this.SVG_.setAttribute("width",d);this.SVG_.setAttribute("height",e);this.SVG_.appendChild(a);this.SVG_.appendChild(b);this.SVG_.style.display="block"};Blockly.blockRendering.rendererMap_={};Blockly.blockRendering.useDebugger=!1;Blockly.blockRendering.register=function(a,b){if(Blockly.blockRendering.rendererMap_[a])throw Error("Renderer has already been registered.");Blockly.blockRendering.rendererMap_[a]=b};Blockly.blockRendering.unregister=function(a){Blockly.blockRendering.rendererMap_[a]?delete Blockly.blockRendering.rendererMap_[a]:console.warn('No renderer mapping for name "'+a+'" found to unregister')};
-Blockly.blockRendering.startDebugger=function(){Blockly.blockRendering.useDebugger=!0};Blockly.blockRendering.stopDebugger=function(){Blockly.blockRendering.useDebugger=!1};Blockly.blockRendering.init=function(a){if(!Blockly.blockRendering.rendererMap_[a])throw Error("Renderer not registered: ",a);var b=function(){b.superClass_.constructor.call(this)};Blockly.utils.object.inherits(b,Blockly.blockRendering.rendererMap_[a]);a=new b;a.init();return a};Blockly.ConnectionDB=function(){this.connections_=[]};Blockly.ConnectionDB.prototype.addConnection=function(a){if(a.inDB_)throw Error("Connection already in database.");if(!a.getSourceBlock().isInFlyout){var b=this.findPositionForConnection_(a);this.connections_.splice(b,0,a);a.inDB_=!0}};
-Blockly.ConnectionDB.prototype.findConnection=function(a){if(!this.connections_.length)return-1;var b=this.findPositionForConnection_(a);if(b>=this.connections_.length)return-1;for(var c=a.y_,d=b;0<=d&&this.connections_[d].y_==c;){if(this.connections_[d]==a)return d;d--}for(;ba.y_)c=d;else{b=d;break}}return b};
-Blockly.ConnectionDB.prototype.removeConnection_=function(a){if(!a.inDB_)throw Error("Connection not in database.");var b=this.findConnection(a);if(-1==b)throw Error("Unable to find connection in connectionDB.");a.inDB_=!1;this.connections_.splice(b,1)};
-Blockly.ConnectionDB.prototype.getNeighbours=function(a,b){function c(a){var c=e-d[a].x_,g=f-d[a].y_;Math.sqrt(c*c+g*g)<=b&&l.push(d[a]);return gthis.previousScale_){var c=b-this.previousScale_;c=0Object.keys(this.cachedPoints_).length&&(this.cachedPoints_={},this.previousScale_=0)};
-Blockly.TouchGesture.prototype.getTouchPoint=function(a){return this.startWorkspace_?new Blockly.utils.Coordinate(a.pageX?a.pageX:a.changedTouches[0].pageX,a.pageY?a.pageY:a.changedTouches[0].pageY):null};Blockly.WorkspaceAudio=function(a){this.parentWorkspace_=a;this.SOUNDS_=Object.create(null)};Blockly.WorkspaceAudio.prototype.lastSound_=null;Blockly.WorkspaceAudio.prototype.dispose=function(){this.SOUNDS_=this.parentWorkspace_=null};
-Blockly.WorkspaceAudio.prototype.load=function(a,b){if(a.length){try{var c=new Blockly.utils.global.Audio}catch(h){return}for(var d,e=0;e=this.remainingCapacity()||(this.currentGesture_&&this.currentGesture_.cancel(),"comment"==a.tagName.toLowerCase()?this.pasteWorkspaceComment_(a):this.pasteBlock_(a))};
-Blockly.WorkspaceSvg.prototype.pasteBlock_=function(a){Blockly.Events.disable();try{var b=Blockly.Xml.domToBlock(a,this),c=this.getMarker().getCurNode();if(Blockly.keyboardAccessibilityMode&&c){Blockly.navigation.insertBlock(b,c.getLocation());return}var d=parseInt(a.getAttribute("x"),10),e=parseInt(a.getAttribute("y"),10);if(!isNaN(d)&&!isNaN(e)){this.RTL&&(d=-d);do{a=!1;var f=this.getAllBlocks(!1);c=0;for(var g;g=f[c];c++){var h=g.getRelativeToSurfaceXY();if(1>=Math.abs(d-h.x)&&1>=Math.abs(e-h.y)){a=
-!0;break}}if(!a){var k=b.getConnections_(!1);c=0;for(var l;l=k[c];c++)if(l.closest(Blockly.SNAP_RADIUS,new Blockly.utils.Coordinate(d,e)).connection){a=!0;break}}a&&(d=this.RTL?d-Blockly.SNAP_RADIUS:d+Blockly.SNAP_RADIUS,e+=2*Blockly.SNAP_RADIUS)}while(a);b.moveBy(d,e)}}finally{Blockly.Events.enable()}Blockly.Events.isEnabled()&&!b.isShadow()&&Blockly.Events.fire(new Blockly.Events.BlockCreate(b));b.select()};
-Blockly.WorkspaceSvg.prototype.pasteWorkspaceComment_=function(a){Blockly.Events.disable();try{var b=Blockly.WorkspaceCommentSvg.fromXml(a,this),c=parseInt(a.getAttribute("x"),10),d=parseInt(a.getAttribute("y"),10);isNaN(c)||isNaN(d)||(this.RTL&&(c=-c),b.moveBy(c+50,d+50))}finally{Blockly.Events.enable()}Blockly.Events.isEnabled();b.select()};
-Blockly.WorkspaceSvg.prototype.refreshToolboxSelection=function(){var a=this.isFlyout?this.targetWorkspace:this;a&&!a.currentGesture_&&a.toolbox_&&a.toolbox_.flyout_&&a.toolbox_.refreshSelection()};Blockly.WorkspaceSvg.prototype.renameVariableById=function(a,b){Blockly.WorkspaceSvg.superClass_.renameVariableById.call(this,a,b);this.refreshToolboxSelection()};Blockly.WorkspaceSvg.prototype.deleteVariableById=function(a){Blockly.WorkspaceSvg.superClass_.deleteVariableById.call(this,a);this.refreshToolboxSelection()};
-Blockly.WorkspaceSvg.prototype.createVariable=function(a,b,c){a=Blockly.WorkspaceSvg.superClass_.createVariable.call(this,a,b,c);this.refreshToolboxSelection();return a};Blockly.WorkspaceSvg.prototype.recordDeleteAreas=function(){this.deleteAreaTrash_=this.trashcan&&this.svgGroup_.parentNode?this.trashcan.getClientRect():null;this.deleteAreaToolbox_=this.flyout_?this.flyout_.getClientRect():this.toolbox_?this.toolbox_.getClientRect():null};
-Blockly.WorkspaceSvg.prototype.isDeleteArea=function(a){return this.deleteAreaTrash_&&this.deleteAreaTrash_.contains(a.clientX,a.clientY)?Blockly.DELETE_AREA_TRASH:this.deleteAreaToolbox_&&this.deleteAreaToolbox_.contains(a.clientX,a.clientY)?Blockly.DELETE_AREA_TOOLBOX:Blockly.DELETE_AREA_NONE};Blockly.WorkspaceSvg.prototype.onMouseDown_=function(a){var b=this.getGesture(a);b&&b.handleWsStart(a,this)};
-Blockly.WorkspaceSvg.prototype.startDrag=function(a,b){var c=Blockly.utils.mouseToSvg(a,this.getParentSvg(),this.getInverseScreenCTM());c.x/=this.scale;c.y/=this.scale;this.dragDeltaXY_=Blockly.utils.Coordinate.difference(b,c)};Blockly.WorkspaceSvg.prototype.moveDrag=function(a){a=Blockly.utils.mouseToSvg(a,this.getParentSvg(),this.getInverseScreenCTM());a.x/=this.scale;a.y/=this.scale;return Blockly.utils.Coordinate.sum(this.dragDeltaXY_,a)};
-Blockly.WorkspaceSvg.prototype.isDragging=function(){return null!=this.currentGesture_&&this.currentGesture_.isDragging()};Blockly.WorkspaceSvg.prototype.isDraggable=function(){return this.options.moveOptions&&this.options.moveOptions.drag};
-Blockly.WorkspaceSvg.prototype.isContentBounded=function(){return this.options.moveOptions&&this.options.moveOptions.scrollbars||this.options.moveOptions&&this.options.moveOptions.wheel||this.options.moveOptions&&this.options.moveOptions.drag||this.options.zoomOptions&&this.options.zoomOptions.controls||this.options.zoomOptions&&this.options.zoomOptions.wheel};
-Blockly.WorkspaceSvg.prototype.isMovable=function(){return this.options.moveOptions&&this.options.moveOptions.scrollbars||this.options.moveOptions&&this.options.moveOptions.wheel||this.options.moveOptions&&this.options.moveOptions.drag||this.options.zoomOptions&&this.options.zoomOptions.wheel};
-Blockly.WorkspaceSvg.prototype.onMouseWheel_=function(a){if(Blockly.Gesture.inProgress())a.preventDefault(),a.stopPropagation();else{var b=this.options.zoomOptions&&this.options.zoomOptions.wheel,c=this.options.moveOptions&&this.options.moveOptions.wheel;if(b||c){var d=Blockly.utils.getScrollDeltaPixels(a);!b||!a.ctrlKey&&c?(b=this.scrollX-d.x,c=this.scrollY-d.y,a.shiftKey&&!d.x&&(b=this.scrollX-d.y,c=this.scrollY),this.scroll(b,c)):(d=-d.y/50,b=Blockly.utils.mouseToSvg(a,this.getParentSvg(),this.getInverseScreenCTM()),
-this.zoom(b.x,b.y,d));a.preventDefault()}}};Blockly.WorkspaceSvg.prototype.getBlocksBoundingBox=function(){var a=this.getTopBlocks(!1),b=this.getTopComments(!1);a=a.concat(b);if(!a.length)return new Blockly.utils.Rect(0,0,0,0);b=a[0].getBoundingRectangle();for(var c=1;cb.bottom&&(b.bottom=d.bottom);d.leftb.right&&(b.right=d.right)}return b};
-Blockly.WorkspaceSvg.prototype.cleanUp=function(){this.setResizesEnabled(!1);Blockly.Events.setGroup(!0);for(var a=this.getTopBlocks(!0),b=0,c=0,d;d=a[c];c++)if(d.isMovable()){var e=d.getRelativeToSurfaceXY();d.moveBy(-e.x,b-e.y);d.snapToGrid();b=d.getRelativeToSurfaceXY().y+d.getHeightWidth().height+Blockly.BlockSvg.MIN_BLOCK_Y}Blockly.Events.setGroup(!1);this.setResizesEnabled(!0)};
-Blockly.WorkspaceSvg.prototype.showContextMenu_=function(a){function b(a){if(a.isDeletable())p=p.concat(a.getDescendants(!1));else{a=a.getChildren(!1);for(var c=0;cp.length?c():Blockly.confirm(Blockly.Msg.DELETE_ALL_BLOCKS.replace("%1",p.length),function(a){a&&
-c()})}};d.push(h);this.configureContextMenu&&this.configureContextMenu(d);Blockly.ContextMenu.show(a,d,this.RTL)}};
-Blockly.WorkspaceSvg.prototype.updateToolbox=function(a){if(a=Blockly.Options.parseToolboxTree(a)){if(!this.options.languageTree)throw Error("Existing toolbox is null.  Can't create new toolbox.");if(a.getElementsByTagName("category").length){if(!this.toolbox_)throw Error("Existing toolbox has no categories.  Can't change mode.");this.options.languageTree=a;this.toolbox_.renderTree(a)}else{if(!this.flyout_)throw Error("Existing toolbox has categories.  Can't change mode.");this.options.languageTree=
-a;this.flyout_.show(a.childNodes)}}else if(this.options.languageTree)throw Error("Can't nullify an existing toolbox.");};Blockly.WorkspaceSvg.prototype.markFocused=function(){this.options.parentWorkspace?this.options.parentWorkspace.markFocused():(Blockly.mainWorkspace=this,this.setBrowserFocus())};Blockly.WorkspaceSvg.prototype.setBrowserFocus=function(){document.activeElement&&document.activeElement.blur();try{this.getParentSvg().focus()}catch(a){try{this.getParentSvg().parentNode.setActive()}catch(b){this.getParentSvg().parentNode.focus()}}};
-Blockly.WorkspaceSvg.prototype.zoom=function(a,b,c){if(!this.isFlyout&&!this.isMutator){c=Math.pow(this.options.zoomOptions.scaleSpeed,c);var d=this.scale*c;if(this.scale!=d){d>this.options.zoomOptions.maxScale?c=this.options.zoomOptions.maxScale/this.scale:dthis.options.zoomOptions.maxScale?a=this.options.zoomOptions.maxScale:this.options.zoomOptions.minScale&&ab.viewBottom||b.contentLeftb.viewRight){c=null;a&&(c=Blockly.Events.getGroup(),Blockly.Events.setGroup(a.group));switch(a.type){case Blockly.Events.BLOCK_CREATE:case Blockly.Events.BLOCK_MOVE:var f=e.getBlockById(a.blockId);f=f.getRootBlock();break;case Blockly.Events.COMMENT_CREATE:case Blockly.Events.COMMENT_MOVE:f=e.getCommentById(a.commentId)}if(f){d=
-f.getBoundingRectangle();d.height=d.bottom-d.top;d.width=d.right-d.left;var m=b.viewTop,n=b.viewBottom-d.height;n=Math.max(m,n);m=Blockly.utils.math.clamp(m,d.top,n)-d.top;n=b.viewLeft;var p=b.viewRight-d.width;b.RTL?n=Math.min(p,n):p=Math.max(n,p);b=Blockly.utils.math.clamp(n,d.left,p)-d.left;f.moveBy(b,m)}a&&(a.group||console.log("WARNING: Moved object in bounds but there was no event group. This may break undo."),null!==c&&Blockly.Events.setGroup(c))}}});Blockly.svgResize(e);Blockly.WidgetDiv.createDom();
-Blockly.DropDownDiv.createDom();Blockly.Tooltip.createDom();return e};
-Blockly.init_=function(a){var b=a.options,c=a.getParentSvg();Blockly.bindEventWithChecks_(c.parentNode,"contextmenu",null,function(a){Blockly.utils.isTargetInput(a)||a.preventDefault()});c=Blockly.bindEventWithChecks_(window,"resize",null,function(){Blockly.hideChaff(!0);Blockly.svgResize(a)});a.setResizeHandlerWrapper(c);Blockly.inject.bindDocumentEvents_();b.languageTree&&(a.toolbox_?a.toolbox_.init(a):a.flyout_&&(a.flyout_.init(a),a.flyout_.show(b.languageTree.childNodes),a.flyout_.scrollToStart()));
-c=Blockly.Scrollbar.scrollbarThickness;b.hasTrashcan&&(c=a.trashcan.init(c));b.zoomOptions&&b.zoomOptions.controls&&a.zoomControls_.init(c);b.moveOptions&&b.moveOptions.scrollbars?(a.scrollbar=new Blockly.ScrollbarPair(a),a.scrollbar.resize()):a.setMetrics({x:.5,y:.5});b.hasSounds&&Blockly.inject.loadSounds_(b.pathToMedia,a)};
-Blockly.inject.bindDocumentEvents_=function(){Blockly.documentEventsBound_||(Blockly.bindEventWithChecks_(document,"scroll",null,function(){for(var a=Blockly.Workspace.getAll(),b=0,c;c=a[b];b++)c.updateInverseScreenCTM&&c.updateInverseScreenCTM()}),Blockly.bindEventWithChecks_(document,"keydown",null,Blockly.onKeyDown_),Blockly.bindEvent_(document,"touchend",null,Blockly.longStop_),Blockly.bindEvent_(document,"touchcancel",null,Blockly.longStop_),Blockly.utils.userAgent.IPAD&&Blockly.bindEventWithChecks_(window,
-"orientationchange",document,function(){Blockly.svgResize(Blockly.getMainWorkspace())}));Blockly.documentEventsBound_=!0};
-Blockly.inject.loadSounds_=function(a,b){var c=b.getAudioManager();c.load([a+"click.mp3",a+"click.wav",a+"click.ogg"],"click");c.load([a+"disconnect.wav",a+"disconnect.mp3",a+"disconnect.ogg"],"disconnect");c.load([a+"delete.mp3",a+"delete.ogg",a+"delete.wav"],"delete");var d=[],e=function(){for(;d.length;)Blockly.unbindEvent_(d.pop());c.preload()};d.push(Blockly.bindEventWithChecks_(document,"mousemove",null,e,!0));d.push(Blockly.bindEventWithChecks_(document,"touchstart",null,e,!0))};Blockly.Action=function(a,b){this.name=a;this.desc=b};Blockly.ASTNode=function(a,b,c){if(!b)throw Error("Cannot create a node without a location.");this.type_=a;this.isConnection_=Blockly.ASTNode.isConnectionType_(a);this.location_=b;this.processParams_(c||null)};Blockly.ASTNode.types={FIELD:"field",BLOCK:"block",INPUT:"input",OUTPUT:"output",NEXT:"next",PREVIOUS:"previous",STACK:"stack",WORKSPACE:"workspace"};Blockly.ASTNode.DEFAULT_OFFSET_Y=-20;Blockly.ASTNode.isConnectionType_=function(a){switch(a){case Blockly.ASTNode.types.PREVIOUS:case Blockly.ASTNode.types.NEXT:case Blockly.ASTNode.types.INPUT:case Blockly.ASTNode.types.OUTPUT:return!0}return!1};
-Blockly.ASTNode.createFieldNode=function(a){return new Blockly.ASTNode(Blockly.ASTNode.types.FIELD,a)};
-Blockly.ASTNode.createConnectionNode=function(a){return a?a.type==Blockly.INPUT_VALUE||a.type==Blockly.NEXT_STATEMENT&&a.getParentInput()?Blockly.ASTNode.createInputNode(a.getParentInput()):a.type==Blockly.NEXT_STATEMENT?new Blockly.ASTNode(Blockly.ASTNode.types.NEXT,a):a.type==Blockly.OUTPUT_VALUE?new Blockly.ASTNode(Blockly.ASTNode.types.OUTPUT,a):a.type==Blockly.PREVIOUS_STATEMENT?new Blockly.ASTNode(Blockly.ASTNode.types.PREVIOUS,a):null:null};
-Blockly.ASTNode.createInputNode=function(a){return a?new Blockly.ASTNode(Blockly.ASTNode.types.INPUT,a.connection):null};Blockly.ASTNode.createBlockNode=function(a){return new Blockly.ASTNode(Blockly.ASTNode.types.BLOCK,a)};Blockly.ASTNode.createStackNode=function(a){return new Blockly.ASTNode(Blockly.ASTNode.types.STACK,a)};Blockly.ASTNode.createWorkspaceNode=function(a,b){return new Blockly.ASTNode(Blockly.ASTNode.types.WORKSPACE,a,{wsCoordinate:b})};
-Blockly.ASTNode.prototype.processParams_=function(a){a&&a.wsCoordinate&&(this.wsCoordinate_=a.wsCoordinate)};Blockly.ASTNode.prototype.getLocation=function(){return this.location_};Blockly.ASTNode.prototype.getType=function(){return this.type_};Blockly.ASTNode.prototype.getWsCoordinate=function(){return this.wsCoordinate_};Blockly.ASTNode.prototype.isConnection=function(){return this.isConnection_};
-Blockly.ASTNode.prototype.findPreviousEditableField_=function(a,b,c){b=b.fieldRow;a=b.indexOf(a);for(c=(c?b.length:a)-1;a=b[c];c--)if(a.EDITABLE)return b=a,Blockly.ASTNode.createFieldNode(b);return null};Blockly.ASTNode.prototype.findNextForInput_=function(){var a=this.location_.getParentInput(),b=a.getSourceBlock();a=b.inputList.indexOf(a)+1;for(var c;c=b.inputList[a];a++){for(var d=c.fieldRow,e=0,f;f=d[e];e++)if(f.EDITABLE)return Blockly.ASTNode.createFieldNode(f);if(c.connection)return Blockly.ASTNode.createInputNode(c)}return null};
-Blockly.ASTNode.prototype.findNextForField_=function(){var a=this.location_,b=a.getParentInput(),c=a.getSourceBlock(),d=c.inputList.indexOf(b);for(a=b.fieldRow.indexOf(a)+1;b=c.inputList[d];d++){for(var e=b.fieldRow;a1'),d.appendChild(c),b.push(d));if(Blockly.Blocks.variables_get){a.sort(Blockly.VariableModel.compareByName);c=0;for(var e;e=a[c];c++)d=Blockly.utils.xml.createElement("block"),d.setAttribute("type","variables_get"),d.setAttribute("gap",8),d.appendChild(Blockly.Variables.generateVariableFieldDom(e)),b.push(d)}}return b};
-Blockly.Variables.generateUniqueName=function(a){a=a.getAllVariables();var b="";if(a.length)for(var c=1,d=0,e="ijkmnopqrstuvwxyzabcdefgh".charAt(d);!b;){for(var f=!1,g=0;ge?Blockly.WidgetDiv.positionInternal_(a,0,c.height+e):Blockly.WidgetDiv.positionInternal_(a,e,c.height)};
-Blockly.WidgetDiv.calculateX_=function(a,b,c,d){if(d)return b=Math.max(b.right-c.width,a.left),Math.min(b,a.right-c.width);b=Math.min(b.left,a.right-c.width);return Math.max(b,a.left)};Blockly.WidgetDiv.calculateY_=function(a,b,c){return b.bottom+c.height>=a.bottom?b.top-c.height:b.bottom};Blockly.VERSION="3.20191014.4";Blockly.mainWorkspace=null;Blockly.selected=null;Blockly.cursor=null;Blockly.keyboardAccessibilityMode=!1;Blockly.draggingConnections_=[];Blockly.clipboardXml_=null;Blockly.clipboardSource_=null;Blockly.clipboardTypeCounts_=null;Blockly.cache3dSupported_=null;Blockly.svgSize=function(a){return{width:a.cachedWidth_,height:a.cachedHeight_}};Blockly.resizeSvgContents=function(a){a.resizeContents()};
-Blockly.svgResize=function(a){for(;a.options.parentWorkspace;)a=a.options.parentWorkspace;var b=a.getParentSvg(),c=b.parentNode;if(c){var d=c.offsetWidth;c=c.offsetHeight;b.cachedWidth_!=d&&(b.setAttribute("width",d+"px"),b.cachedWidth_=d);b.cachedHeight_!=c&&(b.setAttribute("height",c+"px"),b.cachedHeight_=c);a.resize()}};
-Blockly.onKeyDown_=function(a){var b=Blockly.mainWorkspace;if(!(Blockly.utils.isTargetInput(a)||b.rendered&&!b.isVisible()))if(b.options.readOnly)Blockly.navigation.onKeyPress(a);else{var c=!1;if(a.keyCode==Blockly.utils.KeyCodes.ESC)Blockly.hideChaff(),Blockly.navigation.onBlocklyAction(Blockly.navigation.ACTION_EXIT);else{if(Blockly.navigation.onKeyPress(a))return;if(a.keyCode==Blockly.utils.KeyCodes.BACKSPACE||a.keyCode==Blockly.utils.KeyCodes.DELETE){a.preventDefault();if(Blockly.Gesture.inProgress())return;
-Blockly.selected&&Blockly.selected.isDeletable()&&(c=!0)}else if(a.altKey||a.ctrlKey||a.metaKey){if(Blockly.Gesture.inProgress())return;Blockly.selected&&Blockly.selected.isDeletable()&&Blockly.selected.isMovable()&&(a.keyCode==Blockly.utils.KeyCodes.C?(Blockly.hideChaff(),Blockly.copy_(Blockly.selected)):a.keyCode!=Blockly.utils.KeyCodes.X||Blockly.selected.workspace.isFlyout||(Blockly.copy_(Blockly.selected),c=!0));a.keyCode==Blockly.utils.KeyCodes.V?Blockly.clipboardXml_&&(a=Blockly.clipboardSource_,
-a.isFlyout&&(a=a.targetWorkspace),Blockly.clipboardTypeCounts_&&a.isCapacityAvailable(Blockly.clipboardTypeCounts_)&&(Blockly.Events.setGroup(!0),a.paste(Blockly.clipboardXml_),Blockly.Events.setGroup(!1))):a.keyCode==Blockly.utils.KeyCodes.Z&&(Blockly.hideChaff(),b.undo(a.shiftKey))}}c&&!Blockly.selected.workspace.isFlyout&&(Blockly.Events.setGroup(!0),Blockly.hideChaff(),Blockly.selected.dispose(!0,!0),Blockly.Events.setGroup(!1))}};
-Blockly.copy_=function(a){if(a.isComment)var b=a.toXmlWithXY();else{b=Blockly.Xml.blockToDom(a,!0);Blockly.Xml.deleteNext(b);var c=a.getRelativeToSurfaceXY();b.setAttribute("x",a.RTL?-c.x:c.x);b.setAttribute("y",c.y)}Blockly.clipboardXml_=b;Blockly.clipboardSource_=a.workspace;Blockly.clipboardTypeCounts_=a.isComment?null:Blockly.utils.getBlockTypeCounts(a,!0)};
-Blockly.duplicate_=function(a){var b=Blockly.clipboardXml_,c=Blockly.clipboardSource_;Blockly.copy_(a);a.workspace.paste(Blockly.clipboardXml_);Blockly.clipboardXml_=b;Blockly.clipboardSource_=c};Blockly.onContextMenu_=function(a){Blockly.utils.isTargetInput(a)||a.preventDefault()};
-Blockly.hideChaff=function(a){Blockly.Tooltip.hide();Blockly.WidgetDiv.hide();Blockly.DropDownDiv.hideWithoutAnimation();a||(a=Blockly.getMainWorkspace(),a.trashcan&&a.trashcan.flyout_&&a.trashcan.flyout_.hide(),a.toolbox_&&a.toolbox_.flyout_&&a.toolbox_.flyout_.autoClose&&a.toolbox_.clearSelection())};Blockly.getMainWorkspace=function(){return Blockly.mainWorkspace};Blockly.alert=function(a,b){alert(a);b&&b()};Blockly.confirm=function(a,b){b(confirm(a))};
-Blockly.prompt=function(a,b,c){c(prompt(a,b))};Blockly.jsonInitFactory_=function(a){return function(){this.jsonInit(a)}};
-Blockly.defineBlocksWithJsonArray=function(a){for(var b=0;ba&&(a=this.computeDepth_(),this.setDepth_(a));return a};Blockly.tree.BaseNode.prototype.computeDepth_=function(){var a=this.getParent();return a?a.getDepth()+1:0};Blockly.tree.BaseNode.prototype.setDepth_=function(a){if(a!=this.depth_){this.depth_=a;var b=this.getRowElement();if(b){var c=this.getPixelIndent_()+"px";this.isRightToLeft()?b.style.paddingRight=c:b.style.paddingLeft=c}this.forEachChild(function(b){b.setDepth_(a+1)})}};
-Blockly.tree.BaseNode.prototype.contains=function(a){for(;a;){if(a==this)return!0;a=a.getParent()}return!1};Blockly.tree.BaseNode.prototype.getChildren=function(){var a=[];this.forEachChild(function(b){a.push(b)});return a};Blockly.tree.BaseNode.prototype.getFirstChild=function(){return this.getChildAt(0)};Blockly.tree.BaseNode.prototype.getLastChild=function(){return this.getChildAt(this.getChildCount()-1)};Blockly.tree.BaseNode.prototype.getPreviousSibling=function(){return this.previousSibling_};
-Blockly.tree.BaseNode.prototype.getNextSibling=function(){return this.nextSibling_};Blockly.tree.BaseNode.prototype.isLastSibling=function(){return!this.nextSibling_};Blockly.tree.BaseNode.prototype.isSelected=function(){return this.selected_};Blockly.tree.BaseNode.prototype.select=function(){var a=this.getTree();a&&a.setSelectedItem(this)};Blockly.tree.BaseNode.prototype.selectFirst=function(){var a=this.getTree();a&&this.firstChild_&&a.setSelectedItem(this.firstChild_)};
-Blockly.tree.BaseNode.prototype.setSelectedInternal=function(a){if(this.selected_!=a){this.selected_=a;this.updateRow();var b=this.getElement();b&&(Blockly.utils.aria.setState(b,Blockly.utils.aria.State.SELECTED,a),a&&(a=this.getTree().getElement(),Blockly.utils.aria.setState(a,Blockly.utils.aria.State.ACTIVEDESCENDANT,this.getId())))}};Blockly.tree.BaseNode.prototype.getExpanded=function(){return this.expanded_};Blockly.tree.BaseNode.prototype.setExpandedInternal=function(a){this.expanded_=a};
-Blockly.tree.BaseNode.prototype.setExpanded=function(a){var b=a!=this.expanded_,c;this.expanded_=a;var d=this.getTree(),e=this.getElement();if(this.hasChildren()){if(!a&&d&&this.contains(d.getSelectedItem())&&this.select(),e){if(c=this.getChildrenElement())Blockly.utils.style.setElementShown(c,a),Blockly.utils.aria.setState(e,Blockly.utils.aria.State.EXPANDED,a),a&&this.isInDocument()&&!c.hasChildNodes()&&(this.forEachChild(function(a){c.appendChild(a.toDom())}),this.forEachChild(function(a){a.enterDocument()}));
-this.updateExpandIcon()}}else(c=this.getChildrenElement())&&Blockly.utils.style.setElementShown(c,!1);e&&this.updateIcon_();b&&(a?this.doNodeExpanded():this.doNodeCollapsed())};Blockly.tree.BaseNode.prototype.doNodeExpanded=function(){};Blockly.tree.BaseNode.prototype.doNodeCollapsed=function(){};Blockly.tree.BaseNode.prototype.toggle=function(){this.setExpanded(!this.getExpanded())};Blockly.tree.BaseNode.prototype.isUserCollapsible=function(){return this.isUserCollapsible_};
-Blockly.tree.BaseNode.prototype.toDom=function(){var a=this.getExpanded()&&this.hasChildren(),b=document.createElement("div");b.style.backgroundPosition=this.getBackgroundPosition();a||(b.style.display="none");a&&this.forEachChild(function(a){b.appendChild(a.toDom())});a=document.createElement("div");a.id=this.getId();a.appendChild(this.getRowDom());a.appendChild(b);return a};Blockly.tree.BaseNode.prototype.getPixelIndent_=function(){return Math.max(0,(this.getDepth()-1)*this.config_.indentWidth)};
-Blockly.tree.BaseNode.prototype.getRowDom=function(){var a=document.createElement("div");a.className=this.getRowClassName();a.style["padding-"+(this.isRightToLeft()?"right":"left")]=this.getPixelIndent_()+"px";a.appendChild(this.getIconDom());a.appendChild(this.getLabelDom());return a};Blockly.tree.BaseNode.prototype.getRowClassName=function(){var a="";this.isSelected()&&(a=" "+(this.config_.cssSelectedRow||""));return this.config_.cssTreeRow+a};
-Blockly.tree.BaseNode.prototype.getLabelDom=function(){var a=document.createElement("span");a.className=this.config_.cssItemLabel||"";a.textContent=this.getText();return a};Blockly.tree.BaseNode.prototype.getIconDom=function(){var a=document.createElement("span");a.style.display="inline-block";a.className=this.getCalculatedIconClass();return a};Blockly.tree.BaseNode.prototype.getCalculatedIconClass=function(){throw Error("unimplemented abstract method");};
-Blockly.tree.BaseNode.prototype.getBackgroundPosition=function(){return(this.isLastSibling()?"-100":(this.getDepth()-1)*this.config_.indentWidth)+"px 0"};Blockly.tree.BaseNode.prototype.getElement=function(){var a=Blockly.tree.BaseNode.superClass_.getElement.call(this);a||(a=document.getElementById(this.getId()),this.setElementInternal(a));return a};Blockly.tree.BaseNode.prototype.getRowElement=function(){var a=this.getElement();return a?a.firstChild:null};
-Blockly.tree.BaseNode.prototype.getIconElement=function(){var a=this.getRowElement();return a?a.firstChild:null};Blockly.tree.BaseNode.prototype.getLabelElement=function(){var a=this.getRowElement();return a&&a.lastChild?a.lastChild.previousSibling:null};Blockly.tree.BaseNode.prototype.getChildrenElement=function(){var a=this.getElement();return a?a.lastChild:null};Blockly.tree.BaseNode.prototype.getIconClass=function(){return this.iconClass_};
-Blockly.tree.BaseNode.prototype.getExpandedIconClass=function(){return this.expandedIconClass_};Blockly.tree.BaseNode.prototype.setText=function(a){this.content_=a};Blockly.tree.BaseNode.prototype.getText=function(){return this.content_};Blockly.tree.BaseNode.prototype.updateRow=function(){var a=this.getRowElement();a&&(a.className=this.getRowClassName())};Blockly.tree.BaseNode.prototype.updateExpandIcon=function(){var a=this.getChildrenElement();a&&(a.style.backgroundPosition=this.getBackgroundPosition())};
-Blockly.tree.BaseNode.prototype.updateIcon_=function(){this.getIconElement().className=this.getCalculatedIconClass()};Blockly.tree.BaseNode.prototype.onMouseDown=function(a){"expand"==a.target.getAttribute("type")&&this.hasChildren()?this.isUserCollapsible_&&this.toggle():(this.select(),this.updateRow())};Blockly.tree.BaseNode.prototype.onClick_=function(a){a.preventDefault()};
-Blockly.tree.BaseNode.prototype.onKeyDown=function(a){var b=!0;switch(a.keyCode){case Blockly.utils.KeyCodes.RIGHT:if(a.altKey)break;b=this.selectChild();break;case Blockly.utils.KeyCodes.LEFT:if(a.altKey)break;b=this.selectParent();break;case Blockly.utils.KeyCodes.DOWN:b=this.selectNext();break;case Blockly.utils.KeyCodes.UP:b=this.selectPrevious();break;default:b=!1}b&&a.preventDefault();return b};
-Blockly.tree.BaseNode.prototype.selectNext=function(){var a=this.getNextShownNode();a&&a.select();return!0};Blockly.tree.BaseNode.prototype.selectPrevious=function(){var a=this.getPreviousShownNode();a&&a.select();return!0};Blockly.tree.BaseNode.prototype.selectParent=function(){if(this.hasChildren()&&this.getExpanded()&&this.isUserCollapsible_)this.setExpanded(!1);else{var a=this.getParent(),b=this.getTree();a&&a!=b&&a.select()}return!0};
-Blockly.tree.BaseNode.prototype.selectChild=function(){return this.hasChildren()?(this.getExpanded()?this.getFirstChild().select():this.setExpanded(!0),!0):!1};Blockly.tree.BaseNode.prototype.getLastShownDescendant=function(){return this.getExpanded()&&this.hasChildren()?this.getLastChild().getLastShownDescendant():this};
-Blockly.tree.BaseNode.prototype.getNextShownNode=function(){if(this.hasChildren()&&this.getExpanded())return this.getFirstChild();for(var a=this,b;a!=this.getTree();){b=a.getNextSibling();if(null!=b)return b;a=a.getParent()}return null};Blockly.tree.BaseNode.prototype.getPreviousShownNode=function(){var a=this.getPreviousSibling();if(null!=a)return a.getLastShownDescendant();a=this.getParent();var b=this.getTree();return a==b||this==b?null:a};Blockly.tree.BaseNode.prototype.getConfig=function(){return this.config_};
-Blockly.tree.BaseNode.prototype.setTreeInternal=function(a){this.tree!=a&&(this.tree=a,this.forEachChild(function(b){b.setTreeInternal(a)}))};Blockly.tree.TreeNode=function(a,b,c){this.toolbox_=a;Blockly.tree.BaseNode.call(this,b,c)};Blockly.utils.object.inherits(Blockly.tree.TreeNode,Blockly.tree.BaseNode);Blockly.tree.TreeNode.prototype.getTree=function(){if(this.tree)return this.tree;var a=this.getParent();return a&&(a=a.getTree())?(this.setTreeInternal(a),a):null};
-Blockly.tree.TreeNode.prototype.getCalculatedIconClass=function(){var a=this.getExpanded(),b=this.getExpandedIconClass();if(a&&b)return b;b=this.getIconClass();if(!a&&b)return b;b=this.getConfig();if(this.hasChildren()){if(a&&b.cssExpandedFolderIcon)return b.cssTreeIcon+" "+b.cssExpandedFolderIcon;if(!a&&b.cssCollapsedFolderIcon)return b.cssTreeIcon+" "+b.cssCollapsedFolderIcon}else if(b.cssFileIcon)return b.cssTreeIcon+" "+b.cssFileIcon;return""};
-Blockly.tree.TreeNode.prototype.onClick_=function(a){this.hasChildren()&&this.isUserCollapsible()?(this.toggle(),this.select()):this.isSelected()?this.getTree().setSelectedItem(null):this.select();this.updateRow()};Blockly.tree.TreeNode.prototype.onMouseDown=function(a){};
-Blockly.tree.TreeNode.prototype.onKeyDown=function(a){if(this.tree.toolbox_.horizontalLayout_){var b={},c=Blockly.utils.KeyCodes.DOWN,d=Blockly.utils.KeyCodes.UP;b[Blockly.utils.KeyCodes.RIGHT]=this.isRightToLeft()?d:c;b[Blockly.utils.KeyCodes.LEFT]=this.isRightToLeft()?c:d;b[Blockly.utils.KeyCodes.UP]=Blockly.utils.KeyCodes.LEFT;b[Blockly.utils.KeyCodes.DOWN]=Blockly.utils.KeyCodes.RIGHT;Object.defineProperties(a,{keyCode:{value:b[a.keyCode]||a.keyCode}})}return Blockly.tree.TreeNode.superClass_.onKeyDown.call(this,
-a)};Blockly.tree.TreeNode.prototype.onSizeChanged=function(a){this.onSizeChanged_=a};Blockly.tree.TreeNode.prototype.resizeToolbox_=function(){this.onSizeChanged_&&this.onSizeChanged_.call(this.toolbox_)};Blockly.tree.TreeNode.prototype.doNodeExpanded=Blockly.tree.TreeNode.prototype.resizeToolbox_;Blockly.tree.TreeNode.prototype.doNodeCollapsed=Blockly.tree.TreeNode.prototype.resizeToolbox_;Blockly.tree.TreeControl=function(a,b){this.toolbox_=a;Blockly.tree.BaseNode.call(this,"",b);this.setExpandedInternal(!0);this.setSelectedInternal(!0);this.selectedItem_=this};Blockly.utils.object.inherits(Blockly.tree.TreeControl,Blockly.tree.BaseNode);Blockly.tree.TreeControl.prototype.getTree=function(){return this};Blockly.tree.TreeControl.prototype.getToolbox=function(){return this.toolbox_};Blockly.tree.TreeControl.prototype.getDepth=function(){return 0};
-Blockly.tree.TreeControl.prototype.handleFocus_=function(a){this.focused_=!0;a=this.getElement();Blockly.utils.dom.addClass(a,"focused");this.selectedItem_&&this.selectedItem_.select()};Blockly.tree.TreeControl.prototype.handleBlur_=function(a){this.focused_=!1;a=this.getElement();Blockly.utils.dom.removeClass(a,"focused")};Blockly.tree.TreeControl.prototype.hasFocus=function(){return this.focused_};Blockly.tree.TreeControl.prototype.getExpanded=function(){return!0};
-Blockly.tree.TreeControl.prototype.setExpanded=function(a){this.setExpandedInternal(a)};Blockly.tree.TreeControl.prototype.getIconElement=function(){var a=this.getRowElement();return a?a.firstChild:null};Blockly.tree.TreeControl.prototype.updateExpandIcon=function(){};Blockly.tree.TreeControl.prototype.getRowClassName=function(){return Blockly.tree.TreeControl.superClass_.getRowClassName.call(this)+" "+this.getConfig().cssHideRoot};
-Blockly.tree.TreeControl.prototype.getCalculatedIconClass=function(){var a=this.getExpanded(),b=this.getExpandedIconClass();if(a&&b)return b;b=this.getIconClass();if(!a&&b)return b;b=this.getConfig();return a&&b.cssExpandedRootIcon?b.cssTreeIcon+" "+b.cssExpandedRootIcon:""};
-Blockly.tree.TreeControl.prototype.setSelectedItem=function(a){if(a!=this.selectedItem_&&(!this.onBeforeSelected_||this.onBeforeSelected_.call(this.toolbox_,a))){var b=this.getSelectedItem();this.selectedItem_&&this.selectedItem_.setSelectedInternal(!1);(this.selectedItem_=a)&&a.setSelectedInternal(!0);this.onAfterSelected_&&this.onAfterSelected_.call(this.toolbox_,b,a)}};Blockly.tree.TreeControl.prototype.onBeforeSelected=function(a){this.onBeforeSelected_=a};
-Blockly.tree.TreeControl.prototype.onAfterSelected=function(a){this.onAfterSelected_=a};Blockly.tree.TreeControl.prototype.getSelectedItem=function(){return this.selectedItem_};Blockly.tree.TreeControl.prototype.initAccessibility=function(){Blockly.tree.TreeControl.superClass_.initAccessibility.call(this);var a=this.getElement();Blockly.utils.aria.setRole(a,Blockly.utils.aria.Role.TREE);Blockly.utils.aria.setState(a,Blockly.utils.aria.State.LABELLEDBY,this.getLabelElement().id)};
-Blockly.tree.TreeControl.prototype.enterDocument=function(){Blockly.tree.TreeControl.superClass_.enterDocument.call(this);var a=this.getElement();a.className=this.getConfig().cssRoot;a.setAttribute("hideFocus","true");this.attachEvents_();this.initAccessibility()};Blockly.tree.TreeControl.prototype.exitDocument=function(){Blockly.tree.TreeControl.superClass_.exitDocument.call(this);this.detachEvents_()};
-Blockly.tree.TreeControl.prototype.attachEvents_=function(){var a=this.getElement();a.tabIndex=0;this.onFocusWrapper_=Blockly.bindEvent_(a,"focus",this,this.handleFocus_);this.onBlurWrapper_=Blockly.bindEvent_(a,"blur",this,this.handleBlur_);this.onClickWrapper_=Blockly.bindEventWithChecks_(a,"click",this,this.handleMouseEvent_);this.onKeydownWrapper_=Blockly.bindEvent_(a,"keydown",this,this.handleKeyEvent_)};
-Blockly.tree.TreeControl.prototype.detachEvents_=function(){Blockly.unbindEvent_(this.onFocusWrapper_);Blockly.unbindEvent_(this.onBlurWrapper_);Blockly.unbindEvent_(this.onClickWrapper_);Blockly.unbindEvent_(this.onKeydownWrapper_)};Blockly.tree.TreeControl.prototype.handleMouseEvent_=function(a){var b=this.getNodeFromEvent_(a);if(b)switch(a.type){case "mousedown":b.onMouseDown(a);break;case "click":b.onClick_(a)}};
-Blockly.tree.TreeControl.prototype.handleKeyEvent_=function(a){var b=!1;if(b=this.selectedItem_&&this.selectedItem_.onKeyDown(a)||b)Blockly.utils.style.scrollIntoContainerView(this.selectedItem_.getElement(),this.getElement().parentNode),a.preventDefault();return b};Blockly.tree.TreeControl.prototype.getNodeFromEvent_=function(a){for(var b=a.target;null!=b;){if(a=Blockly.tree.BaseNode.allNodes[b.id])return a;if(b==this.getElement())break;b=b.parentNode}return null};
-Blockly.tree.TreeControl.prototype.createNode=function(a){return new Blockly.tree.TreeNode(this.toolbox_,a||"",this.getConfig())};Blockly.FieldTextInput=function(a,b,c){this.spellcheck_=!0;null==a&&(a="");Blockly.FieldTextInput.superClass_.constructor.call(this,a,b,c)};Blockly.utils.object.inherits(Blockly.FieldTextInput,Blockly.Field);Blockly.FieldTextInput.fromJson=function(a){var b=Blockly.utils.replaceMessageReferences(a.text);return new Blockly.FieldTextInput(b,void 0,a)};Blockly.FieldTextInput.prototype.SERIALIZABLE=!0;Blockly.FieldTextInput.FONTSIZE=11;Blockly.FieldTextInput.BORDERRADIUS=4;
-Blockly.FieldTextInput.prototype.CURSOR="text";Blockly.FieldTextInput.prototype.configure_=function(a){Blockly.FieldTextInput.superClass_.configure_.call(this,a);"boolean"==typeof a.spellcheck&&(this.spellcheck_=a.spellcheck)};Blockly.FieldTextInput.prototype.doClassValidation_=function(a){return null===a||void 0===a?null:String(a)};
-Blockly.FieldTextInput.prototype.doValueInvalid_=function(a){this.isBeingEdited_&&(this.isTextValid_=!1,a=this.value_,this.value_=this.htmlInput_.untypedDefaultValue_,this.sourceBlock_&&Blockly.Events.isEnabled()&&Blockly.Events.fire(new Blockly.Events.BlockChange(this.sourceBlock_,"field",this.name||null,a,this.value_)))};Blockly.FieldTextInput.prototype.doValueUpdate_=function(a){this.isTextValid_=!0;this.value_=a;this.isBeingEdited_||(this.isDirty_=!0)};
-Blockly.FieldTextInput.prototype.render_=function(){Blockly.FieldTextInput.superClass_.render_.call(this);this.isBeingEdited_&&(this.sourceBlock_.RTL?setTimeout(this.resizeEditor_.bind(this),0):this.resizeEditor_(),this.isTextValid_?(Blockly.utils.dom.removeClass(this.htmlInput_,"blocklyInvalidInput"),Blockly.utils.aria.setState(this.htmlInput_,"invalid",!1)):(Blockly.utils.dom.addClass(this.htmlInput_,"blocklyInvalidInput"),Blockly.utils.aria.setState(this.htmlInput_,"invalid",!0)))};
-Blockly.FieldTextInput.prototype.setSpellcheck=function(a){a!=this.spellcheck_&&(this.spellcheck_=a,this.htmlInput_&&this.htmlInput_.setAttribute("spellcheck",this.spellcheck_))};Blockly.FieldTextInput.prototype.showEditor_=function(a){this.workspace_=this.sourceBlock_.workspace;a=a||!1;!a&&(Blockly.utils.userAgent.MOBILE||Blockly.utils.userAgent.ANDROID||Blockly.utils.userAgent.IPAD)?this.showPromptEditor_():this.showInlineEditor_(a)};
-Blockly.FieldTextInput.prototype.showPromptEditor_=function(){var a=this;Blockly.prompt(Blockly.Msg.CHANGE_VALUE_TITLE,this.getText(),function(b){a.setValue(b)})};Blockly.FieldTextInput.prototype.showInlineEditor_=function(a){Blockly.WidgetDiv.show(this,this.sourceBlock_.RTL,this.widgetDispose_.bind(this));this.htmlInput_=this.widgetCreate_();this.isBeingEdited_=!0;a||(this.htmlInput_.focus(),this.htmlInput_.select())};
-Blockly.FieldTextInput.prototype.widgetCreate_=function(){var a=Blockly.WidgetDiv.DIV,b=document.createElement("input");b.className="blocklyHtmlInput";b.setAttribute("spellcheck",this.spellcheck_);var c=Blockly.FieldTextInput.FONTSIZE*this.workspace_.scale+"pt";a.style.fontSize=c;b.style.fontSize=c;b.style.borderRadius=Blockly.FieldTextInput.BORDERRADIUS*this.workspace_.scale+"px";a.appendChild(b);b.value=b.defaultValue=this.getEditorText_(this.value_);b.untypedDefaultValue_=this.value_;b.oldValue_=
-null;Blockly.utils.userAgent.GECKO?setTimeout(this.resizeEditor_.bind(this),0):this.resizeEditor_();this.bindInputEvents_(b);return b};Blockly.FieldTextInput.prototype.widgetDispose_=function(){this.isBeingEdited_=!1;this.isTextValid_=!0;this.forceRerender();if(this.onFinishEditing_)this.onFinishEditing_(this.value_);this.unbindInputEvents_();var a=Blockly.WidgetDiv.DIV.style;a.width="auto";a.height="auto";a.fontSize=""};
-Blockly.FieldTextInput.prototype.bindInputEvents_=function(a){this.onKeyDownWrapper_=Blockly.bindEventWithChecks_(a,"keydown",this,this.onHtmlInputKeyDown_);this.onKeyInputWrapper_=Blockly.bindEventWithChecks_(a,"input",this,this.onHtmlInputChange_)};Blockly.FieldTextInput.prototype.unbindInputEvents_=function(){Blockly.unbindEvent_(this.onKeyDownWrapper_);Blockly.unbindEvent_(this.onKeyInputWrapper_)};
-Blockly.FieldTextInput.prototype.onHtmlInputKeyDown_=function(a){a.keyCode==Blockly.utils.KeyCodes.ENTER?(Blockly.WidgetDiv.hide(),Blockly.DropDownDiv.hideWithoutAnimation()):a.keyCode==Blockly.utils.KeyCodes.ESC?(this.htmlInput_.value=this.htmlInput_.defaultValue,Blockly.WidgetDiv.hide(),Blockly.DropDownDiv.hideWithoutAnimation()):a.keyCode==Blockly.utils.KeyCodes.TAB&&(Blockly.WidgetDiv.hide(),Blockly.DropDownDiv.hideWithoutAnimation(),this.sourceBlock_.tab(this,!a.shiftKey),a.preventDefault())};
-Blockly.FieldTextInput.prototype.onHtmlInputChange_=function(a){a=this.htmlInput_.value;a!==this.htmlInput_.oldValue_&&(this.htmlInput_.oldValue_=a,Blockly.Events.setGroup(!0),a=this.getValueFromEditorText_(a),this.setValue(a),this.forceRerender(),Blockly.Events.setGroup(!1))};Blockly.FieldTextInput.prototype.setEditorValue_=function(a){this.isDirty_=!0;this.isBeingEdited_&&(this.htmlInput_.value=this.getEditorText_(a));this.setValue(a)};
-Blockly.FieldTextInput.prototype.resizeEditor_=function(){var a=Blockly.WidgetDiv.DIV,b=this.getScaledBBox_();a.style.width=b.right-b.left+"px";a.style.height=b.bottom-b.top+"px";b=new Blockly.utils.Coordinate(this.sourceBlock_.RTL?b.right-a.offsetWidth:b.left,b.top);b.y+=1;Blockly.utils.userAgent.GECKO&&Blockly.WidgetDiv.DIV.style.top&&(--b.x,--b.y);Blockly.utils.userAgent.WEBKIT&&(b.y-=3);a.style.left=b.x+"px";a.style.top=b.y+"px"};
-Blockly.FieldTextInput.numberValidator=function(a){console.warn("Blockly.FieldTextInput.numberValidator is deprecated. Use Blockly.FieldNumber instead.");if(null===a)return null;a=String(a);a=a.replace(/O/ig,"0");a=a.replace(/,/g,"");a=Number(a||0);return isNaN(a)?null:String(a)};Blockly.FieldTextInput.nonnegativeIntegerValidator=function(a){(a=Blockly.FieldTextInput.numberValidator(a))&&(a=String(Math.max(0,Math.floor(a))));return a};Blockly.FieldTextInput.prototype.isTabNavigable=function(){return!0};
-Blockly.FieldTextInput.prototype.getText_=function(){return this.isBeingEdited_&&this.htmlInput_?this.htmlInput_.value:null};Blockly.FieldTextInput.prototype.getEditorText_=function(a){return String(a)};Blockly.FieldTextInput.prototype.getValueFromEditorText_=function(a){return a};Blockly.fieldRegistry.register("field_input",Blockly.FieldTextInput);Blockly.FieldAngle=function(a,b,c){this.clockwise_=Blockly.FieldAngle.CLOCKWISE;this.offset_=Blockly.FieldAngle.OFFSET;this.wrap_=Blockly.FieldAngle.WRAP;this.round_=Blockly.FieldAngle.ROUND;Blockly.FieldAngle.superClass_.constructor.call(this,a||0,b,c)};Blockly.utils.object.inherits(Blockly.FieldAngle,Blockly.FieldTextInput);Blockly.FieldAngle.fromJson=function(a){return new Blockly.FieldAngle(a.angle,void 0,a)};Blockly.FieldAngle.prototype.SERIALIZABLE=!0;Blockly.FieldAngle.ROUND=15;
-Blockly.FieldAngle.HALF=50;Blockly.FieldAngle.CLOCKWISE=!1;Blockly.FieldAngle.OFFSET=0;Blockly.FieldAngle.WRAP=360;Blockly.FieldAngle.RADIUS=Blockly.FieldAngle.HALF-1;
-Blockly.FieldAngle.prototype.configure_=function(a){Blockly.FieldAngle.superClass_.configure_.call(this,a);switch(a.mode){case "compass":this.clockwise_=!0;this.offset_=90;break;case "protractor":this.clockwise_=!1,this.offset_=0}var b=a.clockwise;"boolean"==typeof b&&(this.clockwise_=b);b=a.offset;null!=b&&(b=Number(b),isNaN(b)||(this.offset_=b));b=a.wrap;null!=b&&(b=Number(b),isNaN(b)||(this.wrap_=b));a=a.round;null!=a&&(a=Number(a),isNaN(a)||(this.round_=a))};
-Blockly.FieldAngle.prototype.initView=function(){Blockly.FieldAngle.superClass_.initView.call(this);this.symbol_=Blockly.utils.dom.createSvgElement("tspan",{},null);this.symbol_.appendChild(document.createTextNode("\u00b0"));this.textElement_.appendChild(this.symbol_)};Blockly.FieldAngle.prototype.render_=function(){Blockly.FieldAngle.superClass_.render_.call(this);this.updateGraph_()};
-Blockly.FieldAngle.prototype.showEditor_=function(){Blockly.FieldAngle.superClass_.showEditor_.call(this,Blockly.utils.userAgent.MOBILE||Blockly.utils.userAgent.ANDROID||Blockly.utils.userAgent.IPAD);var a=this.dropdownCreate_();Blockly.DropDownDiv.getContentDiv().appendChild(a);a=this.sourceBlock_.getColourBorder();a=a.colourBorder||a.colourLight;Blockly.DropDownDiv.setColour(this.sourceBlock_.getColour(),a);Blockly.DropDownDiv.showPositionedByField(this,this.dropdownDispose_.bind(this));this.updateGraph_()};
-Blockly.FieldAngle.prototype.dropdownCreate_=function(){var a=Blockly.utils.dom.createSvgElement("svg",{xmlns:Blockly.utils.dom.SVG_NS,"xmlns:html":Blockly.utils.dom.HTML_NS,"xmlns:xlink":Blockly.utils.dom.XLINK_NS,version:"1.1",height:2*Blockly.FieldAngle.HALF+"px",width:2*Blockly.FieldAngle.HALF+"px",style:"touch-action: none"},null),b=Blockly.utils.dom.createSvgElement("circle",{cx:Blockly.FieldAngle.HALF,cy:Blockly.FieldAngle.HALF,r:Blockly.FieldAngle.RADIUS,"class":"blocklyAngleCircle"},a);this.gauge_=
-Blockly.utils.dom.createSvgElement("path",{"class":"blocklyAngleGauge"},a);this.line_=Blockly.utils.dom.createSvgElement("line",{x1:Blockly.FieldAngle.HALF,y1:Blockly.FieldAngle.HALF,"class":"blocklyAngleLine"},a);for(var c=0;360>c;c+=15)Blockly.utils.dom.createSvgElement("line",{x1:Blockly.FieldAngle.HALF+Blockly.FieldAngle.RADIUS,y1:Blockly.FieldAngle.HALF,x2:Blockly.FieldAngle.HALF+Blockly.FieldAngle.RADIUS-(0==c%45?10:5),y2:Blockly.FieldAngle.HALF,"class":"blocklyAngleMarks",transform:"rotate("+
-c+","+Blockly.FieldAngle.HALF+","+Blockly.FieldAngle.HALF+")"},a);this.clickWrapper_=Blockly.bindEventWithChecks_(a,"click",this,this.hide_);this.clickSurfaceWrapper_=Blockly.bindEventWithChecks_(b,"click",this,this.onMouseMove,!0,!0);this.moveSurfaceWrapper_=Blockly.bindEventWithChecks_(b,"mousemove",this,this.onMouseMove,!0,!0);return a};Blockly.FieldAngle.prototype.dropdownDispose_=function(){Blockly.unbindEvent_(this.clickWrapper_);Blockly.unbindEvent_(this.clickSurfaceWrapper_);Blockly.unbindEvent_(this.moveSurfaceWrapper_)};
-Blockly.FieldAngle.prototype.hide_=function(){Blockly.DropDownDiv.hideIfOwner(this);Blockly.WidgetDiv.hide()};Blockly.FieldAngle.prototype.onMouseMove=function(a){var b=this.gauge_.ownerSVGElement.getBoundingClientRect(),c=a.clientX-b.left-Blockly.FieldAngle.HALF;a=a.clientY-b.top-Blockly.FieldAngle.HALF;b=Math.atan(-a/c);isNaN(b)||(b=Blockly.utils.math.toDegrees(b),0>c?b+=180:0a&&(a+=360);a>this.wrap_&&(a-=360);return a};Blockly.Css.register(".blocklyAngleCircle {,stroke: #444;,stroke-width: 1;,fill: #ddd;,fill-opacity: .8;,},.blocklyAngleMarks {,stroke: #444;,stroke-width: 1;,},.blocklyAngleGauge {,fill: #f88;,fill-opacity: .8;,pointer-events: none;,},.blocklyAngleLine {,stroke: #f00;,stroke-width: 2;,stroke-linecap: round;,pointer-events: none;,}".split(","));
-Blockly.fieldRegistry.register("field_angle",Blockly.FieldAngle);Blockly.FieldCheckbox=function(a,b,c){this.checkChar_=null;null==a&&(a="FALSE");Blockly.FieldCheckbox.superClass_.constructor.call(this,a,b,c);this.size_.width=Blockly.FieldCheckbox.WIDTH};Blockly.utils.object.inherits(Blockly.FieldCheckbox,Blockly.Field);Blockly.FieldCheckbox.fromJson=function(a){return new Blockly.FieldCheckbox(a.checked,void 0,a)};Blockly.FieldCheckbox.WIDTH=15;Blockly.FieldCheckbox.CHECK_CHAR="\u2713";Blockly.FieldCheckbox.CHECK_X_OFFSET=Blockly.Field.DEFAULT_TEXT_OFFSET-3;
-Blockly.FieldCheckbox.CHECK_Y_OFFSET=14;Blockly.FieldCheckbox.prototype.SERIALIZABLE=!0;Blockly.FieldCheckbox.prototype.CURSOR="default";Blockly.FieldCheckbox.prototype.isDirty_=!1;Blockly.FieldCheckbox.prototype.configure_=function(a){Blockly.FieldCheckbox.superClass_.configure_.call(this,a);a.checkCharacter&&(this.checkChar_=a.checkCharacter)};
-Blockly.FieldCheckbox.prototype.initView=function(){Blockly.FieldCheckbox.superClass_.initView.call(this);this.textElement_.setAttribute("x",Blockly.FieldCheckbox.CHECK_X_OFFSET);this.textElement_.setAttribute("y",Blockly.FieldCheckbox.CHECK_Y_OFFSET);Blockly.utils.dom.addClass(this.textElement_,"blocklyCheckbox");this.textContent_.nodeValue=this.checkChar_||Blockly.FieldCheckbox.CHECK_CHAR;this.textElement_.style.display=this.value_?"block":"none"};
-Blockly.FieldCheckbox.prototype.setCheckCharacter=function(a){this.checkChar_=a;this.textContent_&&(this.textContent_.nodeValue=a||Blockly.FieldCheckbox.CHECK_CHAR)};Blockly.FieldCheckbox.prototype.showEditor_=function(){this.setValue(!this.value_)};Blockly.FieldCheckbox.prototype.doClassValidation_=function(a){return!0===a||"TRUE"===a?"TRUE":!1===a||"FALSE"===a?"FALSE":null};
-Blockly.FieldCheckbox.prototype.doValueUpdate_=function(a){this.value_=this.convertValueToBool_(a);this.textElement_&&(this.textElement_.style.display=this.value_?"block":"none")};Blockly.FieldCheckbox.prototype.getValue=function(){return this.value_?"TRUE":"FALSE"};Blockly.FieldCheckbox.prototype.getValueBoolean=function(){return this.value_};Blockly.FieldCheckbox.prototype.getText=function(){return String(this.convertValueToBool_(this.value_))};
-Blockly.FieldCheckbox.prototype.convertValueToBool_=function(a){return"string"==typeof a?"TRUE"==a:!!a};Blockly.fieldRegistry.register("field_checkbox",Blockly.FieldCheckbox);Blockly.FieldColour=function(a,b,c){Blockly.FieldColour.superClass_.constructor.call(this,a||Blockly.FieldColour.COLOURS[0],b,c);this.size_=new Blockly.utils.Size(Blockly.FieldColour.DEFAULT_WIDTH,Blockly.FieldColour.DEFAULT_HEIGHT)};Blockly.utils.object.inherits(Blockly.FieldColour,Blockly.Field);Blockly.FieldColour.fromJson=function(a){return new Blockly.FieldColour(a.colour,void 0,a)};Blockly.FieldColour.DEFAULT_WIDTH=26;Blockly.FieldColour.DEFAULT_HEIGHT=Blockly.Field.BORDER_RECT_DEFAULT_HEIGHT;
-Blockly.FieldColour.prototype.SERIALIZABLE=!0;Blockly.FieldColour.prototype.CURSOR="default";Blockly.FieldColour.prototype.isDirty_=!1;Blockly.FieldColour.prototype.colours_=null;Blockly.FieldColour.prototype.titles_=null;Blockly.FieldColour.prototype.columns_=0;Blockly.FieldColour.prototype.configure_=function(a){Blockly.FieldColour.superClass_.configure_.call(this,a);a.colourOptions&&(this.colours_=a.colourOptions,this.titles_=a.colourTitles);a.columns&&(this.columns_=a.columns)};
-Blockly.FieldColour.prototype.initView=function(){this.createBorderRect_();this.borderRect_.style.fillOpacity=1;this.borderRect_.style.fill=this.value_};Blockly.FieldColour.prototype.doClassValidation_=function(a){return"string"!=typeof a?null:Blockly.utils.colour.parse(a)};Blockly.FieldColour.prototype.doValueUpdate_=function(a){this.value_=a;this.borderRect_&&(this.borderRect_.style.fill=a)};
-Blockly.FieldColour.prototype.getText=function(){var a=this.value_;/^#(.)\1(.)\2(.)\3$/.test(a)&&(a="#"+a[1]+a[3]+a[5]);return a};Blockly.FieldColour.COLOURS="#ffffff #cccccc #c0c0c0 #999999 #666666 #333333 #000000 #ffcccc #ff6666 #ff0000 #cc0000 #990000 #660000 #330000 #ffcc99 #ff9966 #ff9900 #ff6600 #cc6600 #993300 #663300 #ffff99 #ffff66 #ffcc66 #ffcc33 #cc9933 #996633 #663333 #ffffcc #ffff33 #ffff00 #ffcc00 #999900 #666600 #333300 #99ff99 #66ff99 #33ff33 #33cc00 #009900 #006600 #003300 #99ffff #33ffff #66cccc #00cccc #339999 #336666 #003333 #ccffff #66ffff #33ccff #3366ff #3333ff #000099 #000066 #ccccff #9999ff #6666cc #6633ff #6600cc #333399 #330099 #ffccff #ff99ff #cc66cc #cc33cc #993399 #663366 #330033".split(" ");
-Blockly.FieldColour.TITLES=[];Blockly.FieldColour.COLUMNS=7;Blockly.FieldColour.prototype.setColours=function(a,b){this.colours_=a;b&&(this.titles_=b);return this};Blockly.FieldColour.prototype.setColumns=function(a){this.columns_=a;return this};Blockly.FieldColour.prototype.showEditor_=function(){this.picker_=this.dropdownCreate_();Blockly.DropDownDiv.getContentDiv().appendChild(this.picker_);Blockly.DropDownDiv.showPositionedByField(this,this.dropdownDispose_.bind(this));this.picker_.focus()};
-Blockly.FieldColour.prototype.onClick_=function(a){a=(a=a.target)&&a.label;null!==a&&(this.setValue(a),Blockly.DropDownDiv.hideIfOwner(this))};
-Blockly.FieldColour.prototype.onKeyDown_=function(a){var b=!1;if(a.keyCode===Blockly.utils.KeyCodes.UP)this.moveHighlightBy_(0,-1),b=!0;else if(a.keyCode===Blockly.utils.KeyCodes.DOWN)this.moveHighlightBy_(0,1),b=!0;else if(a.keyCode===Blockly.utils.KeyCodes.LEFT)this.moveHighlightBy_(-1,0),b=!0;else if(a.keyCode===Blockly.utils.KeyCodes.RIGHT)this.moveHighlightBy_(1,0),b=!0;else if(a.keyCode===Blockly.utils.KeyCodes.ENTER){if(b=this.getHighlighted_())b=b&&b.label,null!==b&&this.setValue(b);Blockly.DropDownDiv.hideWithoutAnimation();
-b=!0}b&&a.stopPropagation()};Blockly.FieldColour.prototype.onBlocklyAction=function(a){if(this.picker_){if(a===Blockly.navigation.ACTION_PREVIOUS)return this.moveHighlightBy_(0,-1),!0;if(a===Blockly.navigation.ACTION_NEXT)return this.moveHighlightBy_(0,1),!0;if(a===Blockly.navigation.ACTION_OUT)return this.moveHighlightBy_(-1,0),!0;if(a===Blockly.navigation.ACTION_IN)return this.moveHighlightBy_(1,0),!0}return Blockly.FieldColour.superClass_.onBlocklyAction.call(this,a)};
-Blockly.FieldColour.prototype.moveHighlightBy_=function(a,b){var c=this.colours_||Blockly.FieldColour.COLOURS,d=this.columns_||Blockly.FieldColour.COLUMNS,e=this.highlightedIndex_%d,f=Math.floor(this.highlightedIndex_/d);e+=a;f+=b;0>a?0>e&&0e&&(e=0):0d-1&&fd-1&&e--:0>b?0>f&&(f=0):0Math.floor(c.length/d)-1&&(f=Math.floor(c.length/d)-1);this.setHighlightedCell_(this.picker_.childNodes[f].childNodes[e],f*d+e)};
-Blockly.FieldColour.prototype.onMouseMove_=function(a){var b=(a=a.target)&&a.getAttribute("data-index");null!==b&&b!==this.highlightedIndex_&&this.setHighlightedCell_(a,Number(b))};Blockly.FieldColour.prototype.onMouseEnter_=function(){this.picker_.focus()};Blockly.FieldColour.prototype.onMouseLeave_=function(){this.picker_.blur();var a=this.getHighlighted_();a&&Blockly.utils.dom.removeClass(a,"blocklyColourHighlighted")};
-Blockly.FieldColour.prototype.getHighlighted_=function(){var a=this.columns_||Blockly.FieldColour.COLUMNS,b=this.picker_.childNodes[Math.floor(this.highlightedIndex_/a)];return b?b.childNodes[this.highlightedIndex_%a]:null};
-Blockly.FieldColour.prototype.setHighlightedCell_=function(a,b){var c=this.getHighlighted_();c&&Blockly.utils.dom.removeClass(c,"blocklyColourHighlighted");Blockly.utils.dom.addClass(a,"blocklyColourHighlighted");this.highlightedIndex_=b;Blockly.utils.aria.setState(this.picker_,Blockly.utils.aria.State.ACTIVEDESCENDANT,a.getAttribute("id"))};
-Blockly.FieldColour.prototype.dropdownCreate_=function(){var a=this.columns_||Blockly.FieldColour.COLUMNS,b=this.colours_||Blockly.FieldColour.COLOURS,c=this.titles_||Blockly.FieldColour.TITLES,d=this.getValue(),e=document.createElement("table");e.className="blocklyColourTable";e.tabIndex=0;e.dir="ltr";Blockly.utils.aria.setRole(e,Blockly.utils.aria.Role.GRID);Blockly.utils.aria.setState(e,Blockly.utils.aria.State.EXPANDED,!0);Blockly.utils.aria.setState(e,"rowcount",Math.floor(b.length/a));Blockly.utils.aria.setState(e,
-"colcount",a);for(var f,g=0;gtr>td {","border: .5px solid #888;","box-sizing: border-box;","cursor: pointer;","display: inline-block;","height: 20px;","padding: 0;","width: 20px;","}",".blocklyColourTable>tr>td.blocklyColourHighlighted {","border-color: #eee;","box-shadow: 2px 2px 7px 2px rgba(0,0,0,.3);","position: relative;","}",".blocklyColourSelected, .blocklyColourSelected:hover {",
-"border-color: #eee !important;","outline: 1px solid #333;","position: relative;","}"]);Blockly.fieldRegistry.register("field_colour",Blockly.FieldColour);Blockly.FieldDropdown=function(a,b,c){"function"!=typeof a&&Blockly.FieldDropdown.validateOptions_(a);this.menuGenerator_=a;this.generatedOptions_=null;this.selectedIndex_=0;this.trimOptions_();a=this.getOptions(!1)[0];Blockly.FieldDropdown.superClass_.constructor.call(this,a[1],b,c);this.selectedMenuItem_=this.imageElement_=null};Blockly.utils.object.inherits(Blockly.FieldDropdown,Blockly.Field);Blockly.FieldDropdown.fromJson=function(a){return new Blockly.FieldDropdown(a.options,void 0,a)};
-Blockly.FieldDropdown.prototype.SERIALIZABLE=!0;Blockly.FieldDropdown.CHECKMARK_OVERHANG=25;Blockly.FieldDropdown.MAX_MENU_HEIGHT_VH=.45;Blockly.FieldDropdown.IMAGE_Y_OFFSET=5;Blockly.FieldDropdown.IMAGE_Y_PADDING=2*Blockly.FieldDropdown.IMAGE_Y_OFFSET;Blockly.FieldDropdown.ARROW_CHAR=Blockly.utils.userAgent.ANDROID?"\u25bc":"\u25be";Blockly.FieldDropdown.prototype.CURSOR="default";
-Blockly.FieldDropdown.prototype.initView=function(){Blockly.FieldDropdown.superClass_.initView.call(this);this.imageElement_=Blockly.utils.dom.createSvgElement("image",{y:Blockly.FieldDropdown.IMAGE_Y_OFFSET},this.fieldGroup_);this.arrow_=Blockly.utils.dom.createSvgElement("tspan",{},this.textElement_);this.arrow_.appendChild(document.createTextNode(this.sourceBlock_.RTL?Blockly.FieldDropdown.ARROW_CHAR+" ":" "+Blockly.FieldDropdown.ARROW_CHAR));this.sourceBlock_.RTL?this.textElement_.insertBefore(this.arrow_,
-this.textContent_):this.textElement_.appendChild(this.arrow_)};Blockly.FieldDropdown.prototype.showEditor_=function(){this.menu_=this.dropdownCreate_();this.menu_.render(Blockly.DropDownDiv.getContentDiv());Blockly.utils.dom.addClass(this.menu_.getElement(),"blocklyDropdownMenu");Blockly.DropDownDiv.showPositionedByField(this,this.dropdownDispose_.bind(this));this.menu_.focus();this.selectedMenuItem_&&Blockly.utils.style.scrollIntoContainerView(this.selectedMenuItem_.getElement(),this.menu_.getElement())};
-Blockly.FieldDropdown.prototype.dropdownCreate_=function(){var a=new Blockly.Menu;a.setRightToLeft(this.sourceBlock_.RTL);a.setRole("listbox");var b=this.getOptions(!1);this.selectedMenuItem_=null;for(var c=0;ca.length)){b=[];for(c=0;cthis.selectedIndex_)return null;var a=this.getOptions(!0)[this.selectedIndex_][0];return"object"==typeof a?a.alt:a};
-Blockly.FieldDropdown.validateOptions_=function(a){if(!Array.isArray(a))throw TypeError("FieldDropdown options must be an array.");if(!a.length)throw TypeError("FieldDropdown options must not be an empty array.");for(var b=!1,c=0;c{setTimeout(fireNow$$module$build$src$core$events$utils,0)})}catch(b){setTimeout(fireNow$$module$build$src$core$events$utils,0)}FIRE_QUEUE$$module$build$src$core$events$utils.push(a)}};
+fireNow$$module$build$src$core$events$utils=function(){var a=filter$$module$build$src$core$events$utils(FIRE_QUEUE$$module$build$src$core$events$utils,!0);FIRE_QUEUE$$module$build$src$core$events$utils.length=0;for(let c=0,d;d=a[c];c++)if(d.workspaceId){var b=getWorkspaceById$$module$build$src$core$common(d.workspaceId);b&&b.fireChangeListener(d)}a=new Set(a.map(c=>c.workspaceId));for(const c of a){if(!c)continue;a=getWorkspaceById$$module$build$src$core$common(c);if(!a)continue;a=a.getUndoStack();
+let d=void 0;for(b=a.length;0>>/g,a),content$$module$build$src$core$css="",a=document.createElement("style"),a.id="blockly-common-style",b=document.createTextNode(b),a.appendChild(b),document.head.insertBefore(a,document.head.firstChild)))};
+warn$$module$build$src$core$utils$deprecation=function(a,b,c,d){a=a+" was deprecated in "+b+" and will be deleted in "+c+".";d&&(a+="\nUse "+d+" instead.");console.warn(a)};createSvgElement$$module$build$src$core$utils$dom=function(a,b,c){a=document.createElementNS(SVG_NS$$module$build$src$core$utils$dom,`${a}`);for(const d in b)a.setAttribute(d,`${b[d]}`);c&&c.appendChild(a);return a};
+addClass$$module$build$src$core$utils$dom=function(a,b){b=b.split(" ");if(b.every(c=>a.classList.contains(c)))return!1;a.classList.add(...b);return!0};removeClasses$$module$build$src$core$utils$dom=function(a,b){a.classList.remove(...b.split(" "))};removeClass$$module$build$src$core$utils$dom=function(a,b){b=b.split(" ");if(b.every(c=>!a.classList.contains(c)))return!1;a.classList.remove(...b);return!0};hasClass$$module$build$src$core$utils$dom=function(a,b){return a.classList.contains(b)};
+removeNode$$module$build$src$core$utils$dom=function(a){return a&&a.parentNode?a.parentNode.removeChild(a):null};insertAfter$$module$build$src$core$utils$dom=function(a,b){const c=b.nextSibling;b=b.parentNode;if(!b)throw Error("Reference node has no parent.");c?b.insertBefore(a,c):b.appendChild(a)};
+containsNode$$module$build$src$core$utils$dom=function(a,b){warn$$module$build$src$core$utils$deprecation("Blockly.utils.dom.containsNode","version 10","version 11",'Use native "contains" DOM method');return a.contains(b)};setCssTransform$$module$build$src$core$utils$dom=function(a,b){a.style.transform=b;a.style["-webkit-transform"]=b};
+startTextWidthCache$$module$build$src$core$utils$dom=function(){cacheReference$$module$build$src$core$utils$dom++;cacheWidths$$module$build$src$core$utils$dom||(cacheWidths$$module$build$src$core$utils$dom=Object.create(null))};stopTextWidthCache$$module$build$src$core$utils$dom=function(){cacheReference$$module$build$src$core$utils$dom--;cacheReference$$module$build$src$core$utils$dom||(cacheWidths$$module$build$src$core$utils$dom=null)};
+getTextWidth$$module$build$src$core$utils$dom=function(a){const b=a.textContent+"\n"+a.className.baseVal;let c;if(cacheWidths$$module$build$src$core$utils$dom&&(c=cacheWidths$$module$build$src$core$utils$dom[b]))return c;try{c=a.getComputedTextLength()}catch(d){return 8*a.textContent.length}cacheWidths$$module$build$src$core$utils$dom&&(cacheWidths$$module$build$src$core$utils$dom[b]=c);return c};
+getFastTextWidth$$module$build$src$core$utils$dom=function(a,b,c,d){return getFastTextWidthWithSizeString$$module$build$src$core$utils$dom(a,b+"pt",c,d)};
+getFastTextWidthWithSizeString$$module$build$src$core$utils$dom=function(a,b,c,d){const e=a.textContent;a=e+"\n"+a.className.baseVal;var f;if(cacheWidths$$module$build$src$core$utils$dom&&(f=cacheWidths$$module$build$src$core$utils$dom[a]))return f;canvasContext$$module$build$src$core$utils$dom||(f=document.createElement("canvas"),f.className="blocklyComputeCanvas",document.body.appendChild(f),canvasContext$$module$build$src$core$utils$dom=f.getContext("2d"));canvasContext$$module$build$src$core$utils$dom.font=
+c+" "+b+" "+d;f=e?canvasContext$$module$build$src$core$utils$dom.measureText(e).width:0;cacheWidths$$module$build$src$core$utils$dom&&(cacheWidths$$module$build$src$core$utils$dom[a]=f);return f};
+measureFontMetrics$$module$build$src$core$utils$dom=function(a,b,c,d){const e=document.createElement("span");e.style.font=c+" "+b+" "+d;e.textContent=a;a=document.createElement("div");a.style.width="1px";a.style.height="0";b=document.createElement("div");b.style.display="flex";b.style.position="fixed";b.style.top="0";b.style.left="0";b.appendChild(e);b.appendChild(a);document.body.appendChild(b);c={height:0,baseline:0};try{b.style.alignItems="baseline",c.baseline=a.offsetTop-e.offsetTop,b.style.alignItems=
+"flex-end",c.height=a.offsetTop-e.offsetTop}finally{document.body.removeChild(b)}return c};getSize$$module$build$src$core$utils$style=function(a){return TEST_ONLY$$module$build$src$core$utils$style.getSizeInternal(a)};
+getSizeInternal$$module$build$src$core$utils$style=function(a){if("none"!==getComputedStyle$$module$build$src$core$utils$style(a,"display"))return getSizeWithDisplay$$module$build$src$core$utils$style(a);const b=a.style,c=b.display,d=b.visibility,e=b.position;b.visibility="hidden";b.position="absolute";b.display="inline";const f=a.offsetWidth;a=a.offsetHeight;b.display=c;b.position=e;b.visibility=d;return new Size$$module$build$src$core$utils$size(f,a)};
+getSizeWithDisplay$$module$build$src$core$utils$style=function(a){return new Size$$module$build$src$core$utils$size(a.offsetWidth,a.offsetHeight)};getComputedStyle$$module$build$src$core$utils$style=function(a,b){a=window.getComputedStyle(a);return a[b]||a.getPropertyValue(b)};
+getPageOffset$$module$build$src$core$utils$style=function(a){const b=new Coordinate$$module$build$src$core$utils$coordinate(0,0);a=a.getBoundingClientRect();var c=document.documentElement;c=new Coordinate$$module$build$src$core$utils$coordinate(window.pageXOffset||c.scrollLeft,window.pageYOffset||c.scrollTop);b.x=a.left+c.x;b.y=a.top+c.y;return b};
+getViewportPageOffset$$module$build$src$core$utils$style=function(){const a=document.body,b=document.documentElement;return new Coordinate$$module$build$src$core$utils$coordinate(a.scrollLeft||b.scrollLeft,a.scrollTop||b.scrollTop)};
+getBorderBox$$module$build$src$core$utils$style=function(a){const b=parseFloat(getComputedStyle$$module$build$src$core$utils$style(a,"borderLeftWidth")),c=parseFloat(getComputedStyle$$module$build$src$core$utils$style(a,"borderRightWidth")),d=parseFloat(getComputedStyle$$module$build$src$core$utils$style(a,"borderTopWidth"));a=parseFloat(getComputedStyle$$module$build$src$core$utils$style(a,"borderBottomWidth"));return new Rect$$module$build$src$core$utils$rect(d,a,b,c)};
+scrollIntoContainerView$$module$build$src$core$utils$style=function(a,b,c){a=getContainerOffsetToScrollInto$$module$build$src$core$utils$style(a,b,c);b.scrollLeft=a.x;b.scrollTop=a.y};
+getContainerOffsetToScrollInto$$module$build$src$core$utils$style=function(a,b,c){var d=getPageOffset$$module$build$src$core$utils$style(a),e=getPageOffset$$module$build$src$core$utils$style(b),f=getBorderBox$$module$build$src$core$utils$style(b);const g=d.x-e.x-f.left;d=d.y-e.y-f.top;e=getSizeWithDisplay$$module$build$src$core$utils$style(a);a=b.clientWidth-e.width;e=b.clientHeight-e.height;f=b.scrollLeft;b=b.scrollTop;c?(f+=g-a/2,b+=d-e/2):(f+=Math.min(g,Math.max(g-a,0)),b+=Math.min(d,Math.max(d-
+e,0)));return new Coordinate$$module$build$src$core$utils$coordinate(f,b)};
+getRelativeXY$$module$build$src$core$utils$svg_math=function(a){const b=new Coordinate$$module$build$src$core$utils$coordinate(0,0);var c=a.x&&a.getAttribute("x");const d=a.y&&a.getAttribute("y");c&&(b.x=parseInt(c));d&&(b.y=parseInt(d));if(c=(c=a.getAttribute("transform"))&&c.match(XY_REGEX$$module$build$src$core$utils$svg_math))b.x+=Number(c[1]),c[3]&&(b.y+=Number(c[3]));(a=a.getAttribute("style"))&&-1`&#${b.charCodeAt(0)};`)};
+convertToolboxDefToJson$$module$build$src$core$utils$toolbox=function(a){if(!a)return null;if(a instanceof Element||"string"===typeof a)a=parseToolboxTree$$module$build$src$core$utils$toolbox(a),a=convertToToolboxJson$$module$build$src$core$utils$toolbox(a);validateToolbox$$module$build$src$core$utils$toolbox(a);return a};
+validateToolbox$$module$build$src$core$utils$toolbox=function(a){const b=a.kind;a=a.contents;if(b&&b!==FLYOUT_TOOLBOX_KIND$$module$build$src$core$utils$toolbox&&b!==CATEGORY_TOOLBOX_KIND$$module$build$src$core$utils$toolbox)throw Error("Invalid toolbox kind "+b+". Please supply either "+FLYOUT_TOOLBOX_KIND$$module$build$src$core$utils$toolbox+" or "+CATEGORY_TOOLBOX_KIND$$module$build$src$core$utils$toolbox);if(!a)throw Error("Toolbox must have a contents attribute.");};
+convertFlyoutDefToJsonArray$$module$build$src$core$utils$toolbox=function(a){return a?a.contents?a.contents:Array.isArray(a)&&0 document.");}else a instanceof Element&&(b=a);return b};
+getStartPositionRect$$module$build$src$core$positionable_helpers=function(a,b,c,d,e,f){const g=f.scrollbar&&f.scrollbar.canScrollVertically();a.horizontal===horizontalPosition$$module$build$src$core$positionable_helpers.LEFT?(c=e.absoluteMetrics.left+c,g&&f.RTL&&(c+=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness)):(c=e.absoluteMetrics.left+e.viewMetrics.width-b.width-c,g&&!f.RTL&&(c-=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness));a.vertical===verticalPosition$$module$build$src$core$positionable_helpers.TOP?
+a=e.absoluteMetrics.top+d:(a=e.absoluteMetrics.top+e.viewMetrics.height-b.height-d,f.scrollbar&&f.scrollbar.canScrollHorizontally()&&(a-=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness));return new Rect$$module$build$src$core$utils$rect(a,a+b.height,c,c+b.width)};
+getCornerOppositeToolbox$$module$build$src$core$positionable_helpers=function(a,b){return{horizontal:b.toolboxMetrics.position===Position$$module$build$src$core$utils$toolbox.LEFT||a.horizontalLayout&&!a.RTL?horizontalPosition$$module$build$src$core$positionable_helpers.RIGHT:horizontalPosition$$module$build$src$core$positionable_helpers.LEFT,vertical:b.toolboxMetrics.position===Position$$module$build$src$core$utils$toolbox.BOTTOM?verticalPosition$$module$build$src$core$positionable_helpers.TOP:verticalPosition$$module$build$src$core$positionable_helpers.BOTTOM}};
+bumpPositionRect$$module$build$src$core$positionable_helpers=function(a,b,c,d){const e=a.left,f=a.right-a.left,g=a.bottom-a.top;for(let h=0;h1'),d.appendChild(c),b.push(d));if(Blocks$$module$build$src$core$blocks.variables_get){a.sort(VariableModel$$module$build$src$core$variable_model.compareByName);
+for(let e=0,f;f=a[e];e++)c=$.createElement$$module$build$src$core$utils$xml("block"),c.setAttribute("type","variables_get"),c.setAttribute("gap","8"),c.appendChild(generateVariableFieldDom$$module$build$src$core$variables(f)),b.push(c)}}return b};generateUniqueName$$module$build$src$core$variables=function(a){return TEST_ONLY$$module$build$src$core$variables.generateUniqueNameInternal(a)};
+generateUniqueNameInternal$$module$build$src$core$variables=function(a){return generateUniqueNameFromOptions$$module$build$src$core$variables(VAR_LETTER_OPTIONS$$module$build$src$core$variables.charAt(0),a.getAllVariableNames())};
+generateUniqueNameFromOptions$$module$build$src$core$variables=function(a,b){if(!b.length)return a;const c=VAR_LETTER_OPTIONS$$module$build$src$core$variables;let d="",e=c.indexOf(a);for(;;){let f=!1;for(let g=0;gf.getVariableModel().name);if(d&&(c=d.some(f=>f.toLowerCase()===a),d=d.some(f=>f.toLowerCase()===b),c&&d))return e.getName()}return null};
+checkForConflictingParamWithLegacyProcedures$$module$build$src$core$variables=function(a,b,c){a=a.toLowerCase();b=b.toLowerCase();c=c.getAllBlocks(!1);for(const e of c){if(!isLegacyProcedureDefBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks(e))continue;c=e.getProcedureDef();var d=c[1];const f=d.some(g=>g.toLowerCase()===a);d=d.some(g=>g.toLowerCase()===b);if(f&&d)return c[0]}return null};
+generateVariableFieldDom$$module$build$src$core$variables=function(a){const b=$.createElement$$module$build$src$core$utils$xml("field");b.setAttribute("name","VAR");b.setAttribute("id",a.getId());b.setAttribute("variabletype",a.type);a=$.createTextNode$$module$build$src$core$utils$xml(a.name);b.appendChild(a);return b};
+$.getOrCreateVariablePackage$$module$build$src$core$variables=function(a,b,c,d){let e=$.getVariable$$module$build$src$core$variables(a,b,c,d);e||(e=createVariable$$module$build$src$core$variables(a,b,c,d));return e};
+$.getVariable$$module$build$src$core$variables=function(a,b,c,d){const e=a.getPotentialVariableMap();let f=null;if(b&&(f=a.getVariableById(b),!f&&e&&(f=e.getVariableById(b)),f))return f;if(c){if(void 0===d)throw Error("Tried to look up a variable by name without a type");f=a.getVariable(c,d);!f&&e&&(f=e.getVariable(c,d))}return f};
+createVariable$$module$build$src$core$variables=function(a,b,c,d){const e=a.getPotentialVariableMap();c||(c=generateUniqueName$$module$build$src$core$variables(a.isFlyout?a.targetWorkspace:a));return e?e.createVariable(c,d,b):a.createVariable(c,d,b)};getAddedVariables$$module$build$src$core$variables=function(a,b){a=a.getAllVariables();const c=[];if(b.length!==a.length)for(let d=0;d{afterRendersResolver$$module$build$src$core$render_management=b;animationRequestId$$module$build$src$core$render_management=
+window.requestAnimationFrame(()=>{doRenders$$module$build$src$core$render_management();b()})}));return afterRendersPromise$$module$build$src$core$render_management};finishQueuedRenders$$module$build$src$core$render_management=function(){return afterRendersPromise$$module$build$src$core$render_management?afterRendersPromise$$module$build$src$core$render_management:Promise.resolve()};
+triggerQueuedRenders$$module$build$src$core$render_management=function(){window.cancelAnimationFrame(animationRequestId$$module$build$src$core$render_management);doRenders$$module$build$src$core$render_management();afterRendersResolver$$module$build$src$core$render_management&&afterRendersResolver$$module$build$src$core$render_management()};alwaysImmediatelyRender$$module$build$src$core$render_management=function(){return JavaFx$$module$build$src$core$utils$useragent};
+queueBlock$$module$build$src$core$render_management=function(a){dirtyBlocks$$module$build$src$core$render_management.add(a);const b=a.getParent();b?queueBlock$$module$build$src$core$render_management(b):rootBlocks$$module$build$src$core$render_management.add(a)};
+doRenders$$module$build$src$core$render_management=function(){const a=new Set([...rootBlocks$$module$build$src$core$render_management].map(b=>b.workspace));for(const b of rootBlocks$$module$build$src$core$render_management){if(b.isDisposed())continue;if(b.getParent())continue;renderBlock$$module$build$src$core$render_management(b);const c=b.getRelativeToSurfaceXY();updateConnectionLocations$$module$build$src$core$render_management(b,c);updateIconLocations$$module$build$src$core$render_management(b,
+c)}for(const b of a)b.resizeContents();rootBlocks$$module$build$src$core$render_management.clear();dirtyBlocks$$module$build$src$core$render_management=new Set;afterRendersPromise$$module$build$src$core$render_management=null};renderBlock$$module$build$src$core$render_management=function(a){if(dirtyBlocks$$module$build$src$core$render_management.has(a)){for(const b of a.getChildren(!1))renderBlock$$module$build$src$core$render_management(b);a.renderEfficiently()}};
+updateConnectionLocations$$module$build$src$core$render_management=function(a,b){for(const c of a.getConnections_(!1)){a=c.moveToOffset(b);const d=c.targetBlock();c.isSuperior()&&d&&(a||dirtyBlocks$$module$build$src$core$render_management.has(d))&&updateConnectionLocations$$module$build$src$core$render_management(d,Coordinate$$module$build$src$core$utils$coordinate.sum(b,d.relativeCoords))}};
+updateIconLocations$$module$build$src$core$render_management=function(a,b){if(a.getIcons){for(const c of a.getIcons())c.onLocationChange(b);for(const c of a.getChildren(!1))updateIconLocations$$module$build$src$core$render_management(c,Coordinate$$module$build$src$core$utils$coordinate.sum(b,c.relativeCoords))}};
+workspaceToDom$$module$build$src$core$xml=function(a,b){const c=$.createElement$$module$build$src$core$utils$xml("xml");var d=variablesToDom$$module$build$src$core$xml($.allUsedVarModels$$module$build$src$core$variables(a));d.hasChildNodes()&&c.appendChild(d);d=a.getTopComments(!0);for(let e=0;e/g,"<$1$2>")};
+domToPrettyText$$module$build$src$core$xml=function(a){a=domToText$$module$build$src$core$xml(a).split("<");let b="";for(let c=1;c"!==d.slice(-2)&&(b+="  ")}a=a.join("\n");a=a.replace(/(<(\w+)\b[^>]*>[^\n]*)\n *<\/\2>/g,"$1");return a.replace(/^\n/,"")};
+clearWorkspaceAndLoadFromXml$$module$build$src$core$xml=function(a,b){b.setResizesEnabled(!1);b.clear();a=$.domToWorkspace$$module$build$src$core$xml(a,b);b.setResizesEnabled(!0);return a};
+$.domToWorkspace$$module$build$src$core$xml=function(a,b){let c=0;b.RTL&&(c=b.getWidth());const d=[];startTextWidthCache$$module$build$src$core$utils$dom();const e=$.getGroup$$module$build$src$core$events$utils();e||$.setGroup$$module$build$src$core$events$utils(!0);b.setResizesEnabled&&b.setResizesEnabled(!1);let f=!0;try{for(let g=0,h;h=a.childNodes[g];g++){const k=h.nodeName.toLowerCase(),l=h;if("block"===k||"shadow"===k&&!getRecordUndo$$module$build$src$core$events$utils()){const m=domToBlockInternal$$module$build$src$core$xml(l,
+b);d.push(m.id);let n;const p=parseInt(null!=(n=l.getAttribute("x"))?n:"10",10);let q;const r=parseInt(null!=(q=l.getAttribute("y"))?q:"10",10);isNaN(p)||isNaN(r)||m.moveBy(b.RTL?c-p:p,r,["create"]);f=!1}else{if("shadow"===k)throw TypeError("Shadow block cannot be a top-level block.");if("comment"===k)b.rendered?WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.fromXmlRendered(l,b,c):WorkspaceComment$$module$build$src$core$workspace_comment.fromXml(l,b);else if("variables"===k){if(f)domToVariables$$module$build$src$core$xml(l,
+b);else throw Error("'variables' tag must exist once before block and shadow tag elements in the workspace XML, but it was found in another location.");f=!1}}}}finally{$.setGroup$$module$build$src$core$events$utils(e),b.setResizesEnabled&&b.setResizesEnabled(!0),b.rendered&&triggerQueuedRenders$$module$build$src$core$render_management(),stopTextWidthCache$$module$build$src$core$utils$dom()}fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(FINISHED_LOADING$$module$build$src$core$events$utils))(b));
+return d};
+appendDomToWorkspace$$module$build$src$core$xml=function(a,b){if(!b.getBlocksBoundingBox)return $.domToWorkspace$$module$build$src$core$xml(a,b);var c=b.getBlocksBoundingBox();a=$.domToWorkspace$$module$build$src$core$xml(a,b);if(c&&c.top!==c.bottom){var d=c.bottom;c=b.RTL?c.right:c.left;var e=Infinity;let f=-Infinity,g=Infinity;for(let h=0;hf&&(f=k.x)}d=d-g+10;c=b.RTL?c-f:c-e;for(e=0;el.setBubbleVisible(f),
+1)}};applyDataTagNodes$$module$build$src$core$xml=function(a,b){for(let c=0;c{h.disposed||h.setConnectionTracking(!0)},1)}return g};
+appendPrivate$$module$build$src$core$serialization$blocks=function(a,b,{parentConnection:c,isShadow:d=!1}={}){if(!a.type)throw new MissingBlockType$$module$build$src$core$serialization$exceptions(a);const e=b.newBlock(a.type,a.id);e.setShadow(d);loadCoords$$module$build$src$core$serialization$blocks(e,a);loadAttributes$$module$build$src$core$serialization$blocks(e,a);loadExtraState$$module$build$src$core$serialization$blocks(e,a);tryToConnectParent$$module$build$src$core$serialization$blocks(c,e,
+a);loadIcons$$module$build$src$core$serialization$blocks(e,a);loadFields$$module$build$src$core$serialization$blocks(e,a);loadInputBlocks$$module$build$src$core$serialization$blocks(e,a);loadNextBlocks$$module$build$src$core$serialization$blocks(e,a);initBlock$$module$build$src$core$serialization$blocks(e,b.rendered);return e};loadCoords$$module$build$src$core$serialization$blocks=function(a,b){let c=void 0===b.x?0:b.x;b=void 0===b.y?0:b.y;const d=a.workspace;c=d.RTL?d.getWidth()-c:c;a.moveBy(c,b)};
+loadAttributes$$module$build$src$core$serialization$blocks=function(a,b){b.collapsed&&a.setCollapsed(!0);!1===b.deletable&&a.setDeletable(!1);!1===b.movable&&a.setMovable(!1);!1===b.editable&&a.setEditable(!1);!1===b.enabled&&a.setEnabled(!1);void 0!==b.inline&&a.setInputsInline(b.inline);void 0!==b.data&&(a.data=b.data)};loadExtraState$$module$build$src$core$serialization$blocks=function(a,b){b.extraState&&(a.loadExtraState?a.loadExtraState(b.extraState):a.domToMutation&&a.domToMutation($.textToDom$$module$build$src$core$utils$xml(b.extraState)))};
+tryToConnectParent$$module$build$src$core$serialization$blocks=function(a,b,c){if(a){if(a.getSourceBlock().isShadow()&&!b.isShadow())throw new RealChildOfShadow$$module$build$src$core$serialization$exceptions(c);if(a.type===$.inputTypes$$module$build$src$core$inputs$input_types.VALUE){var d=b.outputConnection;if(!d)throw new MissingConnection$$module$build$src$core$serialization$exceptions("output",b,c);}else if(d=b.previousConnection,!d)throw new MissingConnection$$module$build$src$core$serialization$exceptions("previous",
+b,c);if(!a.connect(d)){const e=b.workspace.connectionChecker;throw new BadConnectionCheck$$module$build$src$core$serialization$exceptions(e.getErrorMessage(e.canConnectWithReason(d,a,!1),d,a),a.type===$.inputTypes$$module$build$src$core$inputs$input_types.VALUE?"output connection":"previous connection",b,c);}}};
+loadIcons$$module$build$src$core$serialization$blocks=function(a,b){if(b.icons){var c=Object.keys(b.icons);for(const e of c){c=b.icons[e];var d=a.getIcon(e);if(!d){d=getClass$$module$build$src$core$registry(Type$$module$build$src$core$registry.ICON,e,!1);if(!d)throw new UnregisteredIcon$$module$build$src$core$serialization$exceptions(e,a,b);d=new d(a);a.addIcon(d)}isSerializable$$module$build$src$core$interfaces$i_serializable(d)&&d.loadState(c)}}};
+loadFields$$module$build$src$core$serialization$blocks=function(a,b){if(b.fields){var c=Object.keys(b.fields);for(let d=0;dg.id!=a.id).map(g=>g.getRelativeToSurfaceXY());for(;blockOverlapsOtherExactly$$module$build$src$core$clipboard$block_paster(Coordinate$$module$build$src$core$utils$coordinate.sum(d,e),f)||blockIsInSnapRadius$$module$build$src$core$clipboard$block_paster(a,
+e,c);)b.RTL?e.translate(-c,2*c):e.translate(c,2*c);a.moveTo(Coordinate$$module$build$src$core$utils$coordinate.sum(d,e))};blockOverlapsOtherExactly$$module$build$src$core$clipboard$block_paster=function(a,b){return b.some(c=>1>=Math.abs(c.x-a.x)&&1>=Math.abs(c.y-a.y))};blockIsInSnapRadius$$module$build$src$core$clipboard$block_paster=function(a,b,c){return a.getConnections_(!1).some(d=>!!d.closest(c,b).connection)};
+copy$$module$build$src$core$clipboard=function(a){warn$$module$build$src$core$utils$deprecation("Blockly.clipboard.copy","v11","v12","myCopyable.toCopyData()");return TEST_ONLY$$module$build$src$core$clipboard.copyInternal(a)};copyInternal$$module$build$src$core$clipboard=function(a){const b=a.toCopyData();stashedCopyData$$module$build$src$core$clipboard=b;let c;stashedWorkspace$$module$build$src$core$clipboard=null!=(c=a.workspace)?c:null;return b};
+paste$$module$build$src$core$clipboard=function(a,b,c){return a&&b?pasteFromData$$module$build$src$core$clipboard(a,b,c):stashedCopyData$$module$build$src$core$clipboard&&stashedWorkspace$$module$build$src$core$clipboard?pasteFromData$$module$build$src$core$clipboard(stashedCopyData$$module$build$src$core$clipboard,stashedWorkspace$$module$build$src$core$clipboard):null};
+pasteFromData$$module$build$src$core$clipboard=function(a,b,c){let d;b=null!=(d=b.getRootWorkspace())?d:b;let e,f;return null!=(f=null==(e=getObject$$module$build$src$core$registry(Type$$module$build$src$core$registry.PASTER,a.paster,!1))?void 0:e.paste(a,b,c))?f:null};duplicate$$module$build$src$core$clipboard=function(a){warn$$module$build$src$core$utils$deprecation("Blockly.clipboard.duplicate","v11","v12","Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)");return TEST_ONLY$$module$build$src$core$clipboard.duplicateInternal(a)};
+duplicateInternal$$module$build$src$core$clipboard=function(a){const b=a.toCopyData();return b?paste$$module$build$src$core$clipboard(b,a.workspace):null};setRole$$module$build$src$core$utils$aria=function(a,b){a.setAttribute(ROLE_ATTRIBUTE$$module$build$src$core$utils$aria,b)};setState$$module$build$src$core$utils$aria=function(a,b,c){Array.isArray(c)&&(c=c.join(" "));a.setAttribute(ARIA_PREFIX$$module$build$src$core$utils$aria+b,`${c}`)};getDiv$$module$build$src$core$widgetdiv=function(){return containerDiv$$module$build$src$core$widgetdiv};
+testOnly_setDiv$$module$build$src$core$widgetdiv=function(a){containerDiv$$module$build$src$core$widgetdiv=a};createDom$$module$build$src$core$widgetdiv=function(){containerDiv$$module$build$src$core$widgetdiv||(containerDiv$$module$build$src$core$widgetdiv=document.createElement("div"),containerDiv$$module$build$src$core$widgetdiv.className="blocklyWidgetDiv",(getParentContainer$$module$build$src$core$common()||document.body).appendChild(containerDiv$$module$build$src$core$widgetdiv))};
+show$$module$build$src$core$widgetdiv=function(a,b,c){hide$$module$build$src$core$widgetdiv();owner$$module$build$src$core$widgetdiv=a;dispose$$module$build$src$core$widgetdiv=c;if(a=containerDiv$$module$build$src$core$widgetdiv)a.style.direction=b?"rtl":"ltr",a.style.display="block",b=getMainWorkspace$$module$build$src$core$common(),rendererClassName$$module$build$src$core$widgetdiv=b.getRenderer().getClassName(),themeClassName$$module$build$src$core$widgetdiv=b.getTheme().getClassName(),rendererClassName$$module$build$src$core$widgetdiv&&
+addClass$$module$build$src$core$utils$dom(a,rendererClassName$$module$build$src$core$widgetdiv),themeClassName$$module$build$src$core$widgetdiv&&addClass$$module$build$src$core$utils$dom(a,themeClassName$$module$build$src$core$widgetdiv)};
+hide$$module$build$src$core$widgetdiv=function(){if(isVisible$$module$build$src$core$widgetdiv()){owner$$module$build$src$core$widgetdiv=null;var a=containerDiv$$module$build$src$core$widgetdiv;a&&(a.style.display="none",a.style.left="",a.style.top="",dispose$$module$build$src$core$widgetdiv&&dispose$$module$build$src$core$widgetdiv(),dispose$$module$build$src$core$widgetdiv=null,a.textContent="",rendererClassName$$module$build$src$core$widgetdiv&&(removeClass$$module$build$src$core$utils$dom(a,rendererClassName$$module$build$src$core$widgetdiv),
+rendererClassName$$module$build$src$core$widgetdiv=""),themeClassName$$module$build$src$core$widgetdiv&&(removeClass$$module$build$src$core$utils$dom(a,themeClassName$$module$build$src$core$widgetdiv),themeClassName$$module$build$src$core$widgetdiv=""),getMainWorkspace$$module$build$src$core$common().markFocused())}};isVisible$$module$build$src$core$widgetdiv=function(){return!!owner$$module$build$src$core$widgetdiv};
+hideIfOwner$$module$build$src$core$widgetdiv=function(a){owner$$module$build$src$core$widgetdiv===a&&hide$$module$build$src$core$widgetdiv()};positionInternal$$module$build$src$core$widgetdiv=function(a,b,c){containerDiv$$module$build$src$core$widgetdiv.style.left=a+"px";containerDiv$$module$build$src$core$widgetdiv.style.top=b+"px";containerDiv$$module$build$src$core$widgetdiv.style.height=c+"px"};
+positionWithAnchor$$module$build$src$core$widgetdiv=function(a,b,c,d){const e=calculateY$$module$build$src$core$widgetdiv(a,b,c);a=calculateX$$module$build$src$core$widgetdiv(a,b,c,d);0>e?positionInternal$$module$build$src$core$widgetdiv(a,0,c.height+e):positionInternal$$module$build$src$core$widgetdiv(a,e,c.height)};calculateX$$module$build$src$core$widgetdiv=function(a,b,c,d){return d?Math.min(Math.max(b.right-c.width,a.left),a.right-c.width):Math.max(Math.min(b.left,a.right-c.width),a.left)};
+calculateY$$module$build$src$core$widgetdiv=function(a,b,c){return b.bottom+c.height>=a.bottom?b.top-c.height:b.bottom};isRepositionable$$module$build$src$core$widgetdiv=function(a){return!(null==a||!a.repositionForWindowResize)};repositionForWindowResize$$module$build$src$core$widgetdiv=function(){isRepositionable$$module$build$src$core$widgetdiv(owner$$module$build$src$core$widgetdiv)&&owner$$module$build$src$core$widgetdiv.repositionForWindowResize()||hide$$module$build$src$core$widgetdiv()};
+getCurrentBlock$$module$build$src$core$contextmenu=function(){return currentBlock$$module$build$src$core$contextmenu};setCurrentBlock$$module$build$src$core$contextmenu=function(a){currentBlock$$module$build$src$core$contextmenu=a};
+show$$module$build$src$core$contextmenu=function(a,b,c){show$$module$build$src$core$widgetdiv(dummyOwner$$module$build$src$core$contextmenu,c,dispose$$module$build$src$core$contextmenu);if(b.length){var d=populate_$$module$build$src$core$contextmenu(b,c);menu_$$module$build$src$core$contextmenu=d;position_$$module$build$src$core$contextmenu(d,a,c);setTimeout(function(){d.focus()},1);currentBlock$$module$build$src$core$contextmenu=null}else hide$$module$build$src$core$contextmenu()};
+populate_$$module$build$src$core$contextmenu=function(a,b){const c=new Menu$$module$build$src$core$menu;c.setRole(Role$$module$build$src$core$utils$aria.MENU);for(let d=0;d{setTimeout(()=>{e.callback(e.scope)},
+0)})},{})}return c};position_$$module$build$src$core$contextmenu=function(a,b,c){const d=getViewportBBox$$module$build$src$core$utils$svg_math();b=new Rect$$module$build$src$core$utils$rect(b.clientY+d.top,b.clientY+d.top,b.clientX+d.left,b.clientX+d.left);createWidget_$$module$build$src$core$contextmenu(a);const e=a.getSize();c&&(b.left+=e.width,b.right+=e.width,d.left+=e.width,d.right+=e.width);positionWithAnchor$$module$build$src$core$widgetdiv(d,b,e,c);a.focus()};
+createWidget_$$module$build$src$core$contextmenu=function(a){var b=getDiv$$module$build$src$core$widgetdiv();if(!b)throw Error("Attempting to create a context menu when widget div is null");b=a.render(b);addClass$$module$build$src$core$utils$dom(b,"blocklyContextMenu");conditionalBind$$module$build$src$core$browser_events(b,"contextmenu",null,haltPropagation$$module$build$src$core$contextmenu);a.focus()};haltPropagation$$module$build$src$core$contextmenu=function(a){a.preventDefault();a.stopPropagation()};
+hide$$module$build$src$core$contextmenu=function(){hideIfOwner$$module$build$src$core$widgetdiv(dummyOwner$$module$build$src$core$contextmenu);currentBlock$$module$build$src$core$contextmenu=null};dispose$$module$build$src$core$contextmenu=function(){menu_$$module$build$src$core$contextmenu&&(menu_$$module$build$src$core$contextmenu.dispose(),menu_$$module$build$src$core$contextmenu=null)};
+$.callbackFactory$$module$build$src$core$contextmenu=function(a,b){return()=>{$.disable$$module$build$src$core$events$utils();let c;try{c=b instanceof Element?domToBlockInternal$$module$build$src$core$xml(b,a.workspace):appendInternal$$module$build$src$core$serialization$blocks(b,a.workspace);const d=a.getRelativeToSurfaceXY();d.x=a.RTL?d.x-$.config$$module$build$src$core$config.snapRadius:d.x+$.config$$module$build$src$core$config.snapRadius;d.y+=2*$.config$$module$build$src$core$config.snapRadius;
+c.moveBy(d.x,d.y)}finally{$.enable$$module$build$src$core$events$utils()}isEnabled$$module$build$src$core$events$utils()&&!c.isShadow()&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CREATE$$module$build$src$core$events$utils))(c));c.select();return c}};
+commentDeleteOption$$module$build$src$core$contextmenu=function(a){return{text:$.Msg$$module$build$src$core$msg.REMOVE_COMMENT,enabled:!0,callback:function(){$.setGroup$$module$build$src$core$events$utils(!0);a.dispose();$.setGroup$$module$build$src$core$events$utils(!1)}}};
+commentDuplicateOption$$module$build$src$core$contextmenu=function(a){return{text:$.Msg$$module$build$src$core$msg.DUPLICATE_COMMENT,enabled:!0,callback:function(){const b=a.toCopyData();b&&paste$$module$build$src$core$clipboard(b,a.workspace)}}};
+workspaceCommentOption$$module$build$src$core$contextmenu=function(a,b){const c={enabled:!0};c.text=$.Msg$$module$build$src$core$msg.ADD_COMMENT;c.callback=function(){const d=new WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg(a,$.Msg$$module$build$src$core$msg.WORKSPACE_COMMENT_DEFAULT_TEXT,WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.DEFAULT_SIZE,WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.DEFAULT_SIZE);var e=a.getInjectionDiv().getBoundingClientRect();
+e=new Coordinate$$module$build$src$core$utils$coordinate(b.clientX-e.left,b.clientY-e.top);const f=a.getOriginOffsetInPixels();e=Coordinate$$module$build$src$core$utils$coordinate.difference(e,f);e.scale(1/a.scale);d.moveBy(e.x,e.y);a.rendered&&(d.initSvg(),d.render(),d.select())};return c};toRadians$$module$build$src$core$utils$math=function(a){return a*Math.PI/180};toDegrees$$module$build$src$core$utils$math=function(a){return 180*a/Math.PI};
+clamp$$module$build$src$core$utils$math=function(a,b,c){if(cc)){var d=b.getSvgXY(a.getSvgRoot());a.outputConnection?(d.x+=(a.RTL?3:-3)*c,d.y+=13*c):a.previousConnection&&(d.x+=(a.RTL?-23:23)*c,d.y+=3*c);var e=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CIRCLE,{cx:d.x,cy:d.y,r:0,fill:"none",stroke:"#888","stroke-width":10},b.getParentSvg());a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.ANIMATE,
+{id:"animationCircle",begin:"indefinite",attributeName:"r",dur:"150ms",from:0,to:25*c},e);b=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.ANIMATE,{id:"animationOpacity",begin:"indefinite",attributeName:"opacity",dur:"150ms",from:1,to:0},e);a.beginElement();b.beginElement();setTimeout(()=>void removeNode$$module$build$src$core$utils$dom(e),150)}};
+disconnectUiEffect$$module$build$src$core$block_animations=function(a){disconnectUiStop$$module$build$src$core$block_animations();a.workspace.getAudioManager().play("disconnect");if(!(1>a.workspace.scale)){var b=a.getHeightWidth().height;b=Math.atan(10/b)/Math.PI*180;a.RTL||(b*=-1);wobblingBlock$$module$build$src$core$block_animations=a;disconnectUiStep$$module$build$src$core$block_animations(a,b,new Date)}};
+disconnectUiStep$$module$build$src$core$block_animations=function(a,b,c){const d=((new Date).getTime()-c.getTime())/200;let e="";1>=d&&(e=`skewX(${Math.round(Math.sin(d*Math.PI*3)*(1-d)*b)})`,disconnectPid$$module$build$src$core$block_animations=setTimeout(disconnectUiStep$$module$build$src$core$block_animations,10,a,b,c));a.getSvgRoot().setAttribute("transform",`${a.getTranslation()} ${e}`)};
+disconnectUiStop$$module$build$src$core$block_animations=function(){wobblingBlock$$module$build$src$core$block_animations&&(disconnectPid$$module$build$src$core$block_animations&&(clearTimeout(disconnectPid$$module$build$src$core$block_animations),disconnectPid$$module$build$src$core$block_animations=null),wobblingBlock$$module$build$src$core$block_animations.getSvgRoot().setAttribute("transform",wobblingBlock$$module$build$src$core$block_animations.getTranslation()),wobblingBlock$$module$build$src$core$block_animations=
+null)};startsWith$$module$build$src$core$utils$string=function(a,b){warn$$module$build$src$core$utils$deprecation("Blockly.utils.string.startsWith()","April 2022","April 2023","Use built-in string.startsWith");return a.startsWith(b)};shortestStringLength$$module$build$src$core$utils$string=function(a){return a.length?a.reduce(function(b,c){return b.lengthb&&(b=c[d].length);var e=-Infinity;let f,g=1;do{d=e;f=a;a=[];e=c.length/g;let h=1;for(let k=0;kd);return f};
+wrapScore$$module$build$src$core$utils$string=function(a,b,c){const d=[0],e=[];for(var f=0;fd&&(d=h,e=g)}return e?wrapMutate$$module$build$src$core$utils$string(a,e,c):b};
+wrapToText$$module$build$src$core$utils$string=function(a,b){const c=[];for(let d=0;dRADIUS_OK$$module$build$src$core$tooltip&&hide$$module$build$src$core$tooltip()}else poisonedElement$$module$build$src$core$tooltip!==element$$module$build$src$core$tooltip&&
+(clearTimeout(showPid$$module$build$src$core$tooltip),lastX$$module$build$src$core$tooltip=a.pageX,lastY$$module$build$src$core$tooltip=a.pageY,showPid$$module$build$src$core$tooltip=setTimeout(show$$module$build$src$core$tooltip,HOVER_MS$$module$build$src$core$tooltip))};dispose$$module$build$src$core$tooltip=function(){poisonedElement$$module$build$src$core$tooltip=element$$module$build$src$core$tooltip=null;hide$$module$build$src$core$tooltip()};
+hide$$module$build$src$core$tooltip=function(){visible$$module$build$src$core$tooltip&&(visible$$module$build$src$core$tooltip=!1,containerDiv$$module$build$src$core$tooltip&&(containerDiv$$module$build$src$core$tooltip.style.display="none"));showPid$$module$build$src$core$tooltip&&(clearTimeout(showPid$$module$build$src$core$tooltip),showPid$$module$build$src$core$tooltip=0)};
+block$$module$build$src$core$tooltip=function(){hide$$module$build$src$core$tooltip();blocked$$module$build$src$core$tooltip=!0};unblock$$module$build$src$core$tooltip=function(){blocked$$module$build$src$core$tooltip=!1};
+renderContent$$module$build$src$core$tooltip=function(){containerDiv$$module$build$src$core$tooltip&&element$$module$build$src$core$tooltip&&("function"===typeof customTooltip$$module$build$src$core$tooltip?customTooltip$$module$build$src$core$tooltip(containerDiv$$module$build$src$core$tooltip,element$$module$build$src$core$tooltip):renderDefaultContent$$module$build$src$core$tooltip())};
+renderDefaultContent$$module$build$src$core$tooltip=function(){var a=getTooltipOfObject$$module$build$src$core$tooltip(element$$module$build$src$core$tooltip);a=$.wrap$$module$build$src$core$utils$string(a,LIMIT$$module$build$src$core$tooltip);a=a.split("\n");for(let b=0;bc+window.scrollY&&(e-=containerDiv$$module$build$src$core$tooltip.offsetHeight+
+2*OFFSET_Y$$module$build$src$core$tooltip);a?d=Math.max(MARGINS$$module$build$src$core$tooltip-window.scrollX,d):d+containerDiv$$module$build$src$core$tooltip.offsetWidth>b+window.scrollX-2*MARGINS$$module$build$src$core$tooltip&&(d=b-containerDiv$$module$build$src$core$tooltip.offsetWidth-2*MARGINS$$module$build$src$core$tooltip);return{x:d,y:e}};
+show$$module$build$src$core$tooltip=function(){if(!blocked$$module$build$src$core$tooltip&&(poisonedElement$$module$build$src$core$tooltip=element$$module$build$src$core$tooltip,containerDiv$$module$build$src$core$tooltip)){containerDiv$$module$build$src$core$tooltip.textContent="";renderContent$$module$build$src$core$tooltip();var a=element$$module$build$src$core$tooltip.RTL;containerDiv$$module$build$src$core$tooltip.style.direction=a?"rtl":"ltr";containerDiv$$module$build$src$core$tooltip.style.display=
+"block";visible$$module$build$src$core$tooltip=!0;var {x:b,y:c}=getPosition$$module$build$src$core$tooltip(a);containerDiv$$module$build$src$core$tooltip.style.left=b+"px";containerDiv$$module$build$src$core$tooltip.style.top=c+"px"}};deepMerge$$module$build$src$core$utils$object=function(a,b){for(const c in b)a[c]=null!==b[c]&&"object"===typeof b[c]?deepMerge$$module$build$src$core$utils$object(a[c]||Object.create(null),b[c]):b[c];return a};
+hasBubble$$module$build$src$core$interfaces$i_has_bubble=function(a){return void 0!==a.bubbleIsVisible&&void 0!==a.setBubbleVisible};getHsvSaturation$$module$build$src$core$utils$colour=function(){return hsvSaturation$$module$build$src$core$utils$colour};setHsvSaturation$$module$build$src$core$utils$colour=function(a){hsvSaturation$$module$build$src$core$utils$colour=a};getHsvValue$$module$build$src$core$utils$colour=function(){return hsvValue$$module$build$src$core$utils$colour};
+setHsvValue$$module$build$src$core$utils$colour=function(a){hsvValue$$module$build$src$core$utils$colour=a};
+parse$$module$build$src$core$utils$colour=function(a){a=`${a}`.toLowerCase().trim();var b=names$$module$build$src$core$utils$colour[a];if(b)return b;b="0x"===a.substring(0,2)?"#"+a.substring(2):a;b="#"===b[0]?b:"#"+b;if(/^#[0-9a-f]{6}$/.test(b))return b;if(/^#[0-9a-f]{3}$/.test(b))return["#",b[1],b[1],b[2],b[2],b[3],b[3]].join("");var c=a.match(/^(?:rgb)?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);return c&&(a=Number(c[1]),b=Number(c[2]),c=Number(c[3]),0<=a&&256>a&&0<=b&&256>b&&0<=c&&256>c)?rgbToHex$$module$build$src$core$utils$colour(a,
+b,c):null};rgbToHex$$module$build$src$core$utils$colour=function(a,b,c){b=a<<16|b<<8|c;return 16>a?"#"+(16777216|b).toString(16).substr(1):"#"+b.toString(16)};hexToRgb$$module$build$src$core$utils$colour=function(a){a=parse$$module$build$src$core$utils$colour(a);if(!a)return[0,0,0];a=parseInt(a.substr(1),16);return[a>>16,a>>8&255,a&255]};
+hsvToHex$$module$build$src$core$utils$colour=function(a,b,c){let d=0,e=0,f=0;if(0===b)f=e=d=c;else{const g=Math.floor(a/60),h=a/60-g;a=c*(1-b);const k=c*(1-b*h);b=c*(1-b*(1-h));switch(g){case 1:d=k;e=c;f=a;break;case 2:d=a;e=c;f=b;break;case 3:d=a;e=k;f=c;break;case 4:d=b;e=a;f=c;break;case 5:d=c;e=a;f=k;break;case 6:case 0:d=c,e=b,f=a}}return rgbToHex$$module$build$src$core$utils$colour(Math.floor(d),Math.floor(e),Math.floor(f))};
+blend$$module$build$src$core$utils$colour=function(a,b,c){a=parse$$module$build$src$core$utils$colour(a);if(!a)return null;b=parse$$module$build$src$core$utils$colour(b);if(!b)return null;a=hexToRgb$$module$build$src$core$utils$colour(a);b=hexToRgb$$module$build$src$core$utils$colour(b);return rgbToHex$$module$build$src$core$utils$colour(Math.round(b[0]+c*(a[0]-b[0])),Math.round(b[1]+c*(a[1]-b[1])),Math.round(b[2]+c*(a[2]-b[2])))};
+hueToHex$$module$build$src$core$utils$colour=function(a){return hsvToHex$$module$build$src$core$utils$colour(a,hsvSaturation$$module$build$src$core$utils$colour,255*hsvValue$$module$build$src$core$utils$colour)};
+tokenizeInterpolationInternal$$module$build$src$core$utils$parsing=function(a,b,c){const d=[];var e=a.split("");e.push("");var f=0;a=[];let g=null;for(let l=0;l=h?(f=2,g=h,(h=a.join(""))&&d.push(h),a.length=0):"{"===h?f=3:(a.push("%",h),f=0);else if(2===f)if("0"<=h&&"9">=
+h)g+=h;else{var k=void 0;d.push(parseInt(null!=(k=g)?k:"",10));l--;f=0}else 3===f&&(""===h?(a.splice(0,0,"%{"),l--,f=0):"}"!==h?a.push(h):(f=a.join(""),/[A-Z]\w*/i.test(f)?(h=f.toUpperCase(),(h=h.startsWith("BKY_")?h.substring(4):null)&&h in $.Msg$$module$build$src$core$msg?(f=$.Msg$$module$build$src$core$msg[h],"string"===typeof f?Array.prototype.push.apply(d,tokenizeInterpolationInternal$$module$build$src$core$utils$parsing(f,b,c)):b?d.push(`${f}`):d.push(f)):d.push("%{"+f+"}")):d.push("%{"+f+"}"),
+f=a.length=0))}(b=a.join(""))&&d.push(b);k=[];a.length=0;for(e=0;e=c)return{hue:c,hex:hsvToHex$$module$build$src$core$utils$colour(c,getHsvSaturation$$module$build$src$core$utils$colour(),255*getHsvValue$$module$build$src$core$utils$colour())};if(c=parse$$module$build$src$core$utils$colour(b))return{hue:null,hex:c};c='Invalid colour: "'+b+'"';a!==b&&(c+=' (from "'+
+a+'")');throw Error(c);};isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block=function(a){return void 0!==a.getProcedureModel&&void 0!==a.doProcedureUpdate&&void 0!==a.isProcedureDef};isObservable$$module$build$src$core$interfaces$i_observable=function(a){return void 0!==a.startPublishing&&void 0!==a.stopPublishing};register$$module$build$src$core$field_registry=function(a,b){register$$module$build$src$core$registry(Type$$module$build$src$core$registry.FIELD,a,b)};
+unregister$$module$build$src$core$field_registry=function(a){unregister$$module$build$src$core$registry(Type$$module$build$src$core$registry.FIELD,a)};$.fromJson$$module$build$src$core$field_registry=function(a){return TEST_ONLY$$module$build$src$core$field_registry.fromJsonInternal(a)};
+fromJsonInternal$$module$build$src$core$field_registry=function(a){const b=getObject$$module$build$src$core$registry(Type$$module$build$src$core$registry.FIELD,a.type);if(b){if("function"!==typeof b.fromJson)throw new TypeError("returned Field was not a IRegistrableField");return b.fromJson(a)}console.warn("Blockly could not create a field of type "+a.type+". The field is probably not being registered. This could be because the file is not loaded, the field does not register itself (Issue #1584), or the registration is not being reached.");
+return null};
+trimOptions$$module$build$src$core$field_dropdown=function(a){let b=!1;const c=a.map(([g,h])=>{if("string"===typeof g)return[replaceMessageReferences$$module$build$src$core$utils$parsing(g),h];b=!0;return[null!==g.alt?Object.assign({},g,{alt:replaceMessageReferences$$module$build$src$core$utils$parsing(g.alt)}):Object.assign({},g),h]});if(b||2>a.length)return{options:c};var d=c.map(([g])=>g),e=shortestStringLength$$module$build$src$core$utils$string(d);a=commonWordPrefix$$module$build$src$core$utils$string(d,e);
+const f=commonWordSuffix$$module$build$src$core$utils$string(d,e);if(!a&&!f||e<=a+f)return{options:c};e=a?d[0].substring(0,a-1):void 0;d=f?d[0].substr(1-f):void 0;return{options:applyTrim$$module$build$src$core$field_dropdown(c,a,f),prefix:e,suffix:d}};applyTrim$$module$build$src$core$field_dropdown=function(a,b,c){return a.map(([d,e])=>[d.substring(b,d.length-c),e])};
+validateOptions$$module$build$src$core$field_dropdown=function(a){if(!Array.isArray(a))throw TypeError("FieldDropdown options must be an array.");if(!a.length)throw TypeError("FieldDropdown options must not be an empty array.");let b=!1;for(let c=0;c=c||0>=b)throw Error("Height and width values of an image field must be greater than 0.");this.flipRtl_=!1;this.altText_="";Blockly.FieldImage.superClass_.constructor.call(this,
-a||"",null,g);g||(this.flipRtl_=!!f,this.altText_=Blockly.utils.replaceMessageReferences(d)||"");this.size_=new Blockly.utils.Size(b,c+Blockly.FieldImage.Y_PADDING);this.imageHeight_=c;this.clickHandler_=null;"function"==typeof e&&(this.clickHandler_=e)};Blockly.utils.object.inherits(Blockly.FieldImage,Blockly.Field);Blockly.FieldImage.fromJson=function(a){return new Blockly.FieldImage(a.src,a.width,a.height,void 0,void 0,void 0,a)};Blockly.FieldImage.Y_PADDING=1;
-Blockly.FieldImage.prototype.EDITABLE=!1;Blockly.FieldImage.prototype.isDirty_=!1;Blockly.FieldImage.prototype.configure_=function(a){Blockly.FieldImage.superClass_.configure_.call(this,a);this.flipRtl_=!!a.flipRtl;this.altText_=Blockly.utils.replaceMessageReferences(a.alt)||""};
-Blockly.FieldImage.prototype.initView=function(){this.imageElement_=Blockly.utils.dom.createSvgElement("image",{height:this.imageHeight_+"px",width:this.size_.width+"px",alt:this.altText_},this.fieldGroup_);this.imageElement_.setAttributeNS(Blockly.utils.dom.XLINK_NS,"xlink:href",this.value_)};Blockly.FieldImage.prototype.doClassValidation_=function(a){return"string"!=typeof a?null:a};
-Blockly.FieldImage.prototype.doValueUpdate_=function(a){this.value_=a;this.imageElement_&&this.imageElement_.setAttributeNS(Blockly.utils.dom.XLINK_NS,"xlink:href",this.value_||"")};Blockly.FieldImage.prototype.getFlipRtl=function(){return this.flipRtl_};Blockly.FieldImage.prototype.setAlt=function(a){a!=this.altText_&&(this.altText_=a||"",this.imageElement_&&this.imageElement_.setAttribute("alt",this.altText_))};Blockly.FieldImage.prototype.showEditor_=function(){this.clickHandler_&&this.clickHandler_(this)};
-Blockly.FieldImage.prototype.setOnClickHandler=function(a){this.clickHandler_=a};Blockly.FieldImage.prototype.getText_=function(){return this.altText_};Blockly.fieldRegistry.register("field_image",Blockly.FieldImage);Blockly.FieldLabelSerializable=function(a,b,c){Blockly.FieldLabelSerializable.superClass_.constructor.call(this,a,b,c)};Blockly.utils.object.inherits(Blockly.FieldLabelSerializable,Blockly.FieldLabel);Blockly.FieldLabelSerializable.fromJson=function(a){var b=Blockly.utils.replaceMessageReferences(a.text);return new Blockly.FieldLabelSerializable(b,void 0,a)};Blockly.FieldLabelSerializable.prototype.EDITABLE=!1;Blockly.FieldLabelSerializable.prototype.SERIALIZABLE=!0;
-Blockly.fieldRegistry.register("field_label_serializable",Blockly.FieldLabelSerializable);Blockly.FieldMultilineInput=function(a,b,c){null==a&&(a="");Blockly.FieldMultilineInput.superClass_.constructor.call(this,a,b,c)};Blockly.utils.object.inherits(Blockly.FieldMultilineInput,Blockly.FieldTextInput);Blockly.FieldMultilineInput.LINE_HEIGHT=20;Blockly.FieldMultilineInput.fromJson=function(a){var b=Blockly.utils.replaceMessageReferences(a.text);return new Blockly.FieldMultilineInput(b,void 0,a)};
-Blockly.FieldMultilineInput.prototype.initView=function(){this.createBorderRect_();this.textGroup_=Blockly.utils.dom.createSvgElement("g",{"class":"blocklyEditableText"},this.fieldGroup_)};
-Blockly.FieldMultilineInput.prototype.getDisplayText_=function(){var a=this.value_;if(!a)return Blockly.Field.NBSP;var b=a.split("\n");a="";for(var c=0;cthis.maxDisplayLength&&(d=d.substring(0,this.maxDisplayLength-4)+"...");d=d.replace(/\s/g,Blockly.Field.NBSP);a+=d;c!==b.length-1&&(a+="\n")}this.sourceBlock_.RTL&&(a+="\u200f");return a};
-Blockly.FieldMultilineInput.prototype.render_=function(){for(var a;a=this.textGroup_.firstChild;)this.textGroup_.removeChild(a);a=this.getDisplayText_().split("\n");for(var b=Blockly.Field.Y_PADDING/2,c=0,d=0;db&&(b=e);c+=Blockly.FieldMultilineInput.LINE_HEIGHT}this.borderRect_&&(b+=Blockly.Field.X_PADDING,this.borderRect_.setAttribute("width",b),this.borderRect_.setAttribute("height",c));this.size_.width=b;this.size_.height=c};
-Blockly.FieldMultilineInput.prototype.resizeEditor_=function(){var a=Blockly.WidgetDiv.DIV,b=this.getScaledBBox_();a.style.width=b.right-b.left+"px";a.style.height=b.bottom-b.top+"px";b=new Blockly.utils.Coordinate(this.sourceBlock_.RTL?b.right-a.offsetWidth:b.left,b.top);a.style.left=b.x+"px";a.style.top=b.y+"px"};
-Blockly.FieldMultilineInput.prototype.widgetCreate_=function(){var a=Blockly.WidgetDiv.DIV,b=this.workspace_.scale,c=document.createElement("textarea");c.className="blocklyHtmlInput blocklyHtmlTextAreaInput";c.setAttribute("spellcheck",this.spellcheck_);var d=Blockly.FieldTextInput.FONTSIZE*b+"pt";a.style.fontSize=d;c.style.fontSize=d;c.style.borderRadius=Blockly.FieldTextInput.BORDERRADIUS*b+"px";d=Blockly.Field.DEFAULT_TEXT_OFFSET*b;c.style.paddingLeft=d+"px";c.style.width="calc(100% - "+d+"px)";
-c.style.lineHeight=Blockly.FieldMultilineInput.LINE_HEIGHT*b+"px";a.appendChild(c);c.value=c.defaultValue=this.getEditorText_(this.value_);c.untypedDefaultValue_=this.value_;c.oldValue_=null;Blockly.utils.userAgent.GECKO?setTimeout(this.resizeEditor_.bind(this),0):this.resizeEditor_();this.bindInputEvents_(c);return c};
-Blockly.FieldMultilineInput.prototype.onHtmlInputKeyDown_=function(a){a.keyCode!==Blockly.utils.KeyCodes.ENTER&&Blockly.FieldMultilineInput.superClass_.onHtmlInputKeyDown_.call(this,a)};Blockly.Css.register(".blocklyHtmlTextAreaInput {,font-family: monospace;,resize: none;,overflow: hidden;,height: 100%;,text-align: left;,}".split(","));Blockly.fieldRegistry.register("field_multilinetext",Blockly.FieldMultilineInput);Blockly.FieldNumber=function(a,b,c,d,e,f){this.min_=-Infinity;this.max_=Infinity;this.precision_=0;this.decimalPlaces_=null;Blockly.FieldNumber.superClass_.constructor.call(this,a||0,e,f);f||this.setConstraints(b,c,d)};Blockly.utils.object.inherits(Blockly.FieldNumber,Blockly.FieldTextInput);Blockly.FieldNumber.fromJson=function(a){return new Blockly.FieldNumber(a.value,void 0,void 0,void 0,void 0,a)};Blockly.FieldNumber.prototype.SERIALIZABLE=!0;
-Blockly.FieldNumber.prototype.configure_=function(a){Blockly.FieldNumber.superClass_.configure_.call(this,a);this.setMinInternal_(a.min);this.setMaxInternal_(a.max);this.setPrecisionInternal_(a.precision)};Blockly.FieldNumber.prototype.setConstraints=function(a,b,c){this.setMinInternal_(a);this.setMaxInternal_(b);this.setPrecisionInternal_(c);this.setValue(this.getValue())};Blockly.FieldNumber.prototype.setMin=function(a){this.setMinInternal_(a);this.setValue(this.getValue())};
-Blockly.FieldNumber.prototype.setMinInternal_=function(a){null==a?this.min_=-Infinity:(a=Number(a),isNaN(a)||(this.min_=a))};Blockly.FieldNumber.prototype.getMin=function(){return this.min_};Blockly.FieldNumber.prototype.setMax=function(a){this.setMaxInternal_(a);this.setValue(this.getValue())};Blockly.FieldNumber.prototype.setMaxInternal_=function(a){null==a?this.max_=Infinity:(a=Number(a),isNaN(a)||(this.max_=a))};Blockly.FieldNumber.prototype.getMax=function(){return this.max_};
-Blockly.FieldNumber.prototype.setPrecision=function(a){this.setPrecisionInternal_(a);this.setValue(this.getValue())};Blockly.FieldNumber.prototype.setPrecisionInternal_=function(a){null==a?this.precision_=0:(a=Number(a),isNaN(a)||(this.precision_=a));var b=this.precision_.toString(),c=b.indexOf(".");this.decimalPlaces_=-1==c?a?0:null:b.length-c-1};Blockly.FieldNumber.prototype.getPrecision=function(){return this.precision_};
-Blockly.FieldNumber.prototype.doClassValidation_=function(a){if(null===a)return null;a=String(a);a=a.replace(/O/ig,"0");a=a.replace(/,/g,"");a=Number(a||0);if(isNaN(a))return null;a=Math.min(Math.max(a,this.min_),this.max_);this.precision_&&isFinite(a)&&(a=Math.round(a/this.precision_)*this.precision_);null!=this.decimalPlaces_&&(a=Number(a.toFixed(this.decimalPlaces_)));return a};
-Blockly.FieldNumber.prototype.widgetCreate_=function(){var a=Blockly.FieldNumber.superClass_.widgetCreate_.call(this);-Infinitythis.max_&&Blockly.utils.aria.setState(a,Blockly.utils.aria.State.VALUEMAX,this.max_);return a};Blockly.fieldRegistry.register("field_number",Blockly.FieldNumber);Blockly.FieldVariable=function(a,b,c,d,e){this.menuGenerator_=Blockly.FieldVariable.dropdownCreate;this.defaultVariableName=a||"";this.size_=new Blockly.utils.Size(0,Blockly.BlockSvg.MIN_BLOCK_Y);e&&this.configure_(e);b&&this.setValidator(b);e||this.setTypes_(c,d)};Blockly.utils.object.inherits(Blockly.FieldVariable,Blockly.FieldDropdown);Blockly.FieldVariable.fromJson=function(a){var b=Blockly.utils.replaceMessageReferences(a.variable);return new Blockly.FieldVariable(b,void 0,void 0,void 0,a)};
-Blockly.FieldVariable.prototype.workspace_=null;Blockly.FieldVariable.prototype.SERIALIZABLE=!0;Blockly.FieldVariable.prototype.configure_=function(a){Blockly.FieldVariable.superClass_.configure_.call(this,a);this.setTypes_(a.variableTypes,a.defaultType)};
-Blockly.FieldVariable.prototype.initModel=function(){if(!this.variable_){var a=Blockly.Variables.getOrCreateVariablePackage(this.sourceBlock_.workspace,null,this.defaultVariableName,this.defaultType_);Blockly.Events.disable();this.setValue(a.getId());Blockly.Events.enable()}};
-Blockly.FieldVariable.prototype.fromXml=function(a){var b=a.getAttribute("id"),c=a.textContent,d=a.getAttribute("variabletype")||a.getAttribute("variableType")||"";b=Blockly.Variables.getOrCreateVariablePackage(this.sourceBlock_.workspace,b,c,d);if(null!=d&&d!==b.type)throw Error("Serialized variable type with id '"+b.getId()+"' had type "+b.type+", and does not match variable field that references it: "+Blockly.Xml.domToText(a)+".");this.setValue(b.getId())};
-Blockly.FieldVariable.prototype.toXml=function(a){this.initModel();a.id=this.variable_.getId();a.textContent=this.variable_.name;this.variable_.type&&a.setAttribute("variabletype",this.variable_.type);return a};Blockly.FieldVariable.prototype.setSourceBlock=function(a){if(a.isShadow())throw Error("Variable fields are not allowed to exist on shadow blocks.");Blockly.FieldVariable.superClass_.setSourceBlock.call(this,a)};
-Blockly.FieldVariable.prototype.getValue=function(){return this.variable_?this.variable_.getId():null};Blockly.FieldVariable.prototype.getText=function(){return this.variable_?this.variable_.name:""};Blockly.FieldVariable.prototype.getVariable=function(){return this.variable_};Blockly.FieldVariable.prototype.getValidator=function(){return this.variable_?this.validator_:null};
-Blockly.FieldVariable.prototype.doClassValidation_=function(a){if(null===a)return null;var b=Blockly.Variables.getVariable(this.sourceBlock_.workspace,a);if(!b)return console.warn("Variable id doesn't point to a real variable! ID was "+a),null;b=b.type;return this.typeIsAllowed_(b)?a:(console.warn("Variable type doesn't match this field!  Type was "+b),null)};
-Blockly.FieldVariable.prototype.doValueUpdate_=function(a){this.variable_=Blockly.Variables.getVariable(this.sourceBlock_.workspace,a);Blockly.FieldVariable.superClass_.doValueUpdate_.call(this,a)};Blockly.FieldVariable.prototype.typeIsAllowed_=function(a){var b=this.getVariableTypes_();if(!b)return!0;for(var c=0;c90-b||a>-90-b&&a<-90+b?!0:!1};
-Blockly.HorizontalFlyout.prototype.getClientRect=function(){if(!this.svgGroup_)return null;var a=this.svgGroup_.getBoundingClientRect(),b=a.top;return this.toolboxPosition_==Blockly.TOOLBOX_AT_TOP?new Blockly.utils.Rect(-1E9,b+a.height,-1E9,1E9):new Blockly.utils.Rect(b,-1E9,-1E9,1E9)};
-Blockly.HorizontalFlyout.prototype.reflowInternal_=function(){this.workspace_.scale=this.targetWorkspace_.scale;for(var a=0,b=this.workspace_.getTopBlocks(!1),c=0,d;d=b[c];c++)a=Math.max(a,d.getHeightWidth().height);a+=1.5*this.MARGIN;a*=this.workspace_.scale;a+=Blockly.Scrollbar.scrollbarThickness;if(this.height_!=a){for(c=0;d=b[c];c++)d.flyoutRect_&&this.moveRectToBlock_(d.flyoutRect_,d);this.height_=a;this.position()}};Blockly.VerticalFlyout=function(a){a.getMetrics=this.getMetrics_.bind(this);a.setMetrics=this.setMetrics_.bind(this);Blockly.VerticalFlyout.superClass_.constructor.call(this,a);this.horizontalLayout_=!1};Blockly.utils.object.inherits(Blockly.VerticalFlyout,Blockly.Flyout);
-Blockly.VerticalFlyout.prototype.getMetrics_=function(){if(!this.isVisible())return null;try{var a=this.workspace_.getCanvas().getBBox()}catch(e){a={height:0,y:0,width:0,x:0}}var b=this.SCROLLBAR_PADDING,c=this.height_-2*this.SCROLLBAR_PADDING,d=this.width_;this.RTL||(d-=this.SCROLLBAR_PADDING);return{viewHeight:c,viewWidth:d,contentHeight:a.height*this.workspace_.scale+2*this.MARGIN,contentWidth:a.width*this.workspace_.scale+2*this.MARGIN,viewTop:-this.workspace_.scrollY+a.y,viewLeft:-this.workspace_.scrollX,
-contentTop:a.y,contentLeft:a.x,absoluteTop:b,absoluteLeft:0}};Blockly.VerticalFlyout.prototype.setMetrics_=function(a){var b=this.getMetrics_();b&&("number"==typeof a.y&&(this.workspace_.scrollY=-b.contentHeight*a.y),this.workspace_.translate(this.workspace_.scrollX+b.absoluteLeft,this.workspace_.scrollY+b.absoluteTop))};
-Blockly.VerticalFlyout.prototype.position=function(){if(this.isVisible()){var a=this.targetWorkspace_.getMetrics();a&&(this.height_=a.viewHeight,this.setBackgroundPath_(this.width_-this.CORNER_RADIUS,a.viewHeight-2*this.CORNER_RADIUS),this.positionAt_(this.width_,this.height_,this.targetWorkspace_.toolboxPosition==this.toolboxPosition_?a.toolboxWidth?this.toolboxPosition_==Blockly.TOOLBOX_AT_LEFT?a.toolboxWidth:a.viewWidth-this.width_:this.toolboxPosition_==Blockly.TOOLBOX_AT_LEFT?0:a.viewWidth:this.toolboxPosition_==
-Blockly.TOOLBOX_AT_LEFT?0:a.viewWidth+a.absoluteLeft-this.width_,0))}};
-Blockly.VerticalFlyout.prototype.setBackgroundPath_=function(a,b){var c=this.toolboxPosition_==Blockly.TOOLBOX_AT_RIGHT,d=a+this.CORNER_RADIUS;d=["M "+(c?d:0)+",0"];d.push("h",c?-a:a);d.push("a",this.CORNER_RADIUS,this.CORNER_RADIUS,0,0,c?0:1,c?-this.CORNER_RADIUS:this.CORNER_RADIUS,this.CORNER_RADIUS);d.push("v",Math.max(0,b));d.push("a",this.CORNER_RADIUS,this.CORNER_RADIUS,0,0,c?0:1,c?this.CORNER_RADIUS:-this.CORNER_RADIUS,this.CORNER_RADIUS);d.push("h",c?a:-a);d.push("z");this.svgBackground_.setAttribute("d",
-d.join(" "))};Blockly.VerticalFlyout.prototype.scrollToStart=function(){this.scrollbar_.set(0)};Blockly.VerticalFlyout.prototype.wheel_=function(a){var b=Blockly.utils.getScrollDeltaPixels(a);if(b.y){var c=this.getMetrics_();b=c.viewTop-c.contentTop+b.y;b=Math.min(b,c.contentHeight-c.viewHeight);b=Math.max(b,0);this.scrollbar_.set(b);Blockly.WidgetDiv.hide()}a.preventDefault();a.stopPropagation()};
-Blockly.VerticalFlyout.prototype.layout_=function(a,b){this.workspace_.scale=this.targetWorkspace_.scale;for(var c=this.MARGIN,d=this.RTL?c:c+this.tabWidth_,e=0,f;f=a[e];e++)if("block"==f.type){f=f.block;for(var g=f.getDescendants(!1),h=0,k;k=g[h];h++)k.isInFlyout=!0;f.render();g=f.getSvgRoot();h=f.getHeightWidth();k=f.outputConnection?d-this.tabWidth_:d;f.moveBy(k,c);k=this.createRect_(f,this.RTL?k-h.width:k,c,h,e);this.addBlockListeners_(g,f,k);c+=h.height+b[e]}else"button"==f.type&&(this.initFlyoutButton_(f.button,
-d,c),c+=f.button.height+b[e])};Blockly.VerticalFlyout.prototype.isDragTowardWorkspace=function(a){a=Math.atan2(a.y,a.x)/Math.PI*180;var b=this.dragAngleRange_;return a-b||a<-180+b||a>180-b?!0:!1};
-Blockly.VerticalFlyout.prototype.getClientRect=function(){if(!this.svgGroup_)return null;var a=this.svgGroup_.getBoundingClientRect(),b=a.left;if(this.toolboxPosition_==Blockly.TOOLBOX_AT_LEFT)return new Blockly.utils.Rect(-1E9,1E9,-1E9,b+a.width);Blockly.utils.userAgent.GECKO&&this.targetWorkspace_&&this.targetWorkspace_.isMutator&&(a=this.targetWorkspace_.svgGroup_.getBoundingClientRect().x,10>Math.abs(a-b)&&(b+=this.leftEdge_*this.targetWorkspace_.options.parentWorkspace.scale));return new Blockly.utils.Rect(-1E9,
-1E9,b,1E9)};
-Blockly.VerticalFlyout.prototype.reflowInternal_=function(){this.workspace_.scale=this.targetWorkspace_.scale;for(var a=0,b=this.workspace_.getTopBlocks(!1),c=0,d;d=b[c];c++){var e=d.getHeightWidth().width;d.outputConnection&&(e-=this.tabWidth_);a=Math.max(a,e)}for(c=0;d=this.buttons_[c];c++)a=Math.max(a,d.width);a+=1.5*this.MARGIN+this.tabWidth_;a*=this.workspace_.scale;a+=Blockly.Scrollbar.scrollbarThickness;if(this.width_!=a){for(c=0;d=b[c];c++){if(this.RTL){e=d.getRelativeToSurfaceXY().x;var f=
-a/this.workspace_.scale-this.MARGIN;d.outputConnection||(f-=this.tabWidth_);d.moveBy(f-e,0)}d.flyoutRect_&&this.moveRectToBlock_(d.flyoutRect_,d)}if(this.RTL)for(c=0;d=this.buttons_[c];c++)b=d.getPosition().y,d.moveTo(a/this.workspace_.scale-d.width-this.MARGIN-this.tabWidth_,b);this.width_=a;this.position()}};Blockly.Generator=function(a){this.name_=a;this.FUNCTION_NAME_PLACEHOLDER_REGEXP_=new RegExp(this.FUNCTION_NAME_PLACEHOLDER_,"g")};Blockly.Generator.NAME_TYPE="generated_function";Blockly.Generator.prototype.INFINITE_LOOP_TRAP=null;Blockly.Generator.prototype.STATEMENT_PREFIX=null;Blockly.Generator.prototype.STATEMENT_SUFFIX=null;Blockly.Generator.prototype.INDENT="  ";Blockly.Generator.prototype.COMMENT_WRAP=60;Blockly.Generator.prototype.ORDER_OVERRIDES=[];
-Blockly.Generator.prototype.workspaceToCode=function(a){a||(console.warn("No workspace specified in workspaceToCode call.  Guessing."),a=Blockly.getMainWorkspace());var b=[];this.init(a);a=a.getTopBlocks(!0);for(var c=0,d;d=a[c];c++){var e=this.blockToCode(d);Array.isArray(e)&&(e=e[0]);e&&(d.outputConnection&&(e=this.scrubNakedValue(e),this.STATEMENT_PREFIX&&!d.suppressPrefixSuffix&&(e=this.injectId(this.STATEMENT_PREFIX,d)+e),this.STATEMENT_SUFFIX&&!d.suppressPrefixSuffix&&(e+=this.injectId(this.STATEMENT_SUFFIX,
-d))),b.push(e))}b=b.join("\n");b=this.finish(b);b=b.replace(/^\s+\n/,"");b=b.replace(/\n\s+$/,"\n");return b=b.replace(/[ \t]+\n/g,"\n")};Blockly.Generator.prototype.prefixLines=function(a,b){return b+a.replace(/(?!\n$)\n/g,"\n"+b)};Blockly.Generator.prototype.allNestedComments=function(a){var b=[];a=a.getDescendants(!0);for(var c=0;ca||Math.abs(this.workspaceHeight_-b)>a)this.workspaceWidth_=c,this.workspaceHeight_=b,this.bubble_.setBubbleSize(c+a,b+a),this.svgDialog_.setAttribute("width",this.workspaceWidth_),
-this.svgDialog_.setAttribute("height",this.workspaceHeight_);this.block_.RTL&&(a="translate("+this.workspaceWidth_+",0)",this.workspace_.getCanvas().setAttribute("transform",a));this.workspace_.resize()};
-Blockly.Mutator.prototype.setVisible=function(a){if(a!=this.isVisible())if(Blockly.Events.fire(new Blockly.Events.Ui(this.block_,"mutatorOpen",!a,a)),a){this.bubble_=new Blockly.Bubble(this.block_.workspace,this.createEditor_(),this.block_.svgPath_,this.iconXY_,null,null);this.bubble_.setSvgId(this.block_.id);if(a=this.workspace_.options.languageTree)this.workspace_.flyout_.init(this.workspace_),this.workspace_.flyout_.show(a.childNodes);this.rootBlock_=this.block_.decompose(this.workspace_);a=this.rootBlock_.getDescendants(!1);
-for(var b=0,c;c=a[b];b++)c.render();this.rootBlock_.setMovable(!1);this.rootBlock_.setDeletable(!1);this.workspace_.flyout_?(a=2*this.workspace_.flyout_.CORNER_RADIUS,b=this.workspace_.getFlyout().getWidth()+a):b=a=16;this.block_.RTL&&(b=-b);this.rootBlock_.moveBy(b,a);if(this.block_.saveConnections){var d=this;this.block_.saveConnections(this.rootBlock_);this.sourceListener_=function(){d.block_.saveConnections(d.rootBlock_)};this.block_.workspace.addChangeListener(this.sourceListener_)}this.resizeBubble_();
-this.workspace_.addChangeListener(this.workspaceChanged_.bind(this));this.updateColour()}else this.svgDialog_=null,this.workspace_.dispose(),this.rootBlock_=this.workspace_=null,this.bubble_.dispose(),this.bubble_=null,this.workspaceHeight_=this.workspaceWidth_=0,this.sourceListener_&&(this.block_.workspace.removeChangeListener(this.sourceListener_),this.sourceListener_=null)};
-Blockly.Mutator.prototype.workspaceChanged_=function(a){if(a.type!=Blockly.Events.UI&&(a.type!=Blockly.Events.CHANGE||"disabled"!=a.element)){if(!this.workspace_.isDragging()){a=this.workspace_.getTopBlocks(!1);for(var b=0,c;c=a[b];b++){var d=c.getRelativeToSurfaceXY(),e=c.getHeightWidth();20>d.y+e.height&&c.moveBy(0,20-e.height-d.y)}}if(this.rootBlock_.workspace==this.workspace_){Blockly.Events.setGroup(!0);c=this.block_;a=(a=c.mutationToDom())&&Blockly.Xml.domToText(a);b=c.rendered;c.rendered=!1;
-c.compose(this.rootBlock_);c.rendered=b;c.initSvg();b=(b=c.mutationToDom())&&Blockly.Xml.domToText(b);if(a!=b){Blockly.Events.fire(new Blockly.Events.BlockChange(c,"mutation",null,a,b));var f=Blockly.Events.getGroup();setTimeout(function(){Blockly.Events.setGroup(f);c.bumpNeighbours();Blockly.Events.setGroup(!1)},Blockly.BUMP_DELAY)}c.rendered&&c.render();a!=b&&Blockly.keyboardAccessibilityMode&&Blockly.navigation.moveCursorOnBlockMutation(c);this.workspace_.isDragging()||this.resizeBubble_();Blockly.Events.setGroup(!1)}}};
-Blockly.Mutator.prototype.getFlyoutMetrics_=function(){return{viewHeight:this.workspaceHeight_,viewWidth:this.workspaceWidth_-this.workspace_.getFlyout().getWidth(),absoluteTop:0,absoluteLeft:this.workspace_.RTL?0:this.workspace_.getFlyout().getWidth()}};Blockly.Mutator.prototype.dispose=function(){this.block_.mutator=null;Blockly.Icon.prototype.dispose.call(this)};
-Blockly.Mutator.prototype.updateBlockStyle=function(){var a=this.workspace_;if(a&&a.getAllBlocks()){for(var b=a.getAllBlocks(),c=0;cthis.highlightOffset_&&(this.steps_+=Blockly.utils.svgPaths.lineOnAxis("V",a.yPos+a.height-this.highlightOffset_)))};
-Blockly.geras.Highlighter.prototype.drawBottomRow=function(a){if(this.RTL_)this.steps_+=Blockly.utils.svgPaths.lineOnAxis("V",a.baseline-this.highlightOffset_);else{var b=this.info_.bottomRow.elements[0];Blockly.blockRendering.Types.isLeftSquareCorner(b)?this.steps_+=Blockly.utils.svgPaths.moveTo(a.xPos+this.highlightOffset_,a.baseline-this.highlightOffset_):Blockly.blockRendering.Types.isLeftRoundedCorner(b)&&(this.steps_+=Blockly.utils.svgPaths.moveTo(a.xPos,a.baseline),this.steps_+=this.outsideCornerPaths_.bottomLeft())}};
-Blockly.geras.Highlighter.prototype.drawLeft=function(){var a=this.info_.outputConnection;a&&(a=a.connectionOffsetY+a.height,this.RTL_?this.steps_+=Blockly.utils.svgPaths.moveTo(this.info_.startX,a):(this.steps_+=Blockly.utils.svgPaths.moveTo(this.info_.startX+this.highlightOffset_,this.info_.bottomRow.baseline-this.highlightOffset_),this.steps_+=Blockly.utils.svgPaths.lineOnAxis("V",a)),this.steps_+=this.puzzleTabPaths_.pathUp(this.RTL_));this.RTL_||(a=this.info_.topRow,Blockly.blockRendering.Types.isLeftRoundedCorner(a.elements[0])?
-this.steps_+=Blockly.utils.svgPaths.lineOnAxis("V",this.outsideCornerPaths_.height):this.steps_+=Blockly.utils.svgPaths.lineOnAxis("V",a.capline+this.highlightOffset_))};
-Blockly.geras.Highlighter.prototype.drawInlineInput=function(a){var b=this.highlightOffset_,c=a.xPos+a.connectionWidth,d=a.centerline-a.height/2,e=a.width-a.connectionWidth,f=d+b;this.RTL_?(d=a.connectionOffsetY-b,a=a.height-(a.connectionOffsetY+a.connectionHeight)+b,this.inlineSteps_+=Blockly.utils.svgPaths.moveTo(c-b,f)+Blockly.utils.svgPaths.lineOnAxis("v",d)+this.puzzleTabPaths_.pathDown(this.RTL_)+Blockly.utils.svgPaths.lineOnAxis("v",a)+Blockly.utils.svgPaths.lineOnAxis("h",e)):this.inlineSteps_+=
-Blockly.utils.svgPaths.moveTo(a.xPos+a.width+b,f)+Blockly.utils.svgPaths.lineOnAxis("v",a.height)+Blockly.utils.svgPaths.lineOnAxis("h",-e)+Blockly.utils.svgPaths.moveTo(c,d+a.connectionOffsetY)+this.puzzleTabPaths_.pathDown(this.RTL_)};Blockly.geras.PathObject=function(a){this.svgRoot=a;this.svgPathDark=Blockly.utils.dom.createSvgElement("path",{"class":"blocklyPathDark",transform:"translate(1,1)"},this.svgRoot);this.svgPath=Blockly.utils.dom.createSvgElement("path",{"class":"blocklyPath"},this.svgRoot);this.svgPathLight=Blockly.utils.dom.createSvgElement("path",{"class":"blocklyPathLight"},this.svgRoot)};
-Blockly.geras.PathObject.prototype.setPaths=function(a,b){this.svgPath.setAttribute("d",a);this.svgPathDark.setAttribute("d",a);this.svgPathLight.setAttribute("d",b)};Blockly.geras.PathObject.prototype.flipRTL=function(){this.svgPath.setAttribute("transform","scale(-1 1)");this.svgPathLight.setAttribute("transform","scale(-1 1)");this.svgPathDark.setAttribute("transform","translate(1,1) scale(-1 1)")};Blockly.geras.InlineInput=function(a,b){Blockly.geras.InlineInput.superClass_.constructor.call(this,a,b);this.connectedBlock&&(this.width+=this.constants_.DARK_PATH_OFFSET,this.height+=this.constants_.DARK_PATH_OFFSET)};Blockly.utils.object.inherits(Blockly.geras.InlineInput,Blockly.blockRendering.InlineInput);Blockly.geras.StatementInput=function(a,b){Blockly.geras.StatementInput.superClass_.constructor.call(this,a,b);this.connectedBlock&&(this.height+=this.constants_.DARK_PATH_OFFSET)};
-Blockly.utils.object.inherits(Blockly.geras.StatementInput,Blockly.blockRendering.StatementInput);Blockly.geras.RenderInfo=function(a,b){Blockly.geras.RenderInfo.superClass_.constructor.call(this,a,b)};Blockly.utils.object.inherits(Blockly.geras.RenderInfo,Blockly.blockRendering.RenderInfo);Blockly.geras.RenderInfo.prototype.getRenderer=function(){return this.renderer_};
-Blockly.geras.RenderInfo.prototype.addInput_=function(a,b){this.isInline&&a.type==Blockly.INPUT_VALUE?(b.elements.push(new Blockly.geras.InlineInput(this.constants_,a)),b.hasInlineInput=!0):a.type==Blockly.NEXT_STATEMENT?(b.elements.push(new Blockly.geras.StatementInput(this.constants_,a)),b.hasStatement=!0):a.type==Blockly.INPUT_VALUE?(b.elements.push(new Blockly.blockRendering.ExternalValueInput(this.constants_,a)),b.hasExternalInput=!0):a.type==Blockly.DUMMY_INPUT&&(b.minHeight=Math.max(b.minHeight,
-this.constants_.DUMMY_INPUT_MIN_HEIGHT),b.hasDummyInput=!0);b.align=a.align};
-Blockly.geras.RenderInfo.prototype.addElemSpacing_=function(){for(var a=!1,b=0,c;c=this.rows[b];b++)c.hasExternalInput&&(a=!0);for(b=0;c=this.rows[b];b++){var d=c.elements;c.elements=[];c.startsWithElemSpacer()&&c.elements.push(new Blockly.blockRendering.InRowSpacer(this.constants_,this.getInRowSpacing_(null,d[0])));for(var e=0;e>>/handdelete.cur"), auto;',"}",".blocklyToolboxGrab {",'cursor: url("<<>>/handclosed.cur"), auto;',"cursor: grabbing;","cursor: -webkit-grabbing;","}",".blocklyToolboxDiv {","background-color: #ddd;","overflow-x: visible;","overflow-y: auto;","position: absolute;","z-index: 70;","-webkit-tap-highlight-color: transparent;","}",".blocklyTreeRoot {","padding: 4px 0;","}",".blocklyTreeRoot:focus {","outline: none;","}",".blocklyTreeRow {",
-"height: 22px;","line-height: 22px;","margin-bottom: 3px;","padding-right: 8px;","white-space: nowrap;","}",".blocklyHorizontalTree {","float: left;","margin: 1px 5px 8px 0;","}",".blocklyHorizontalTreeRtl {","float: right;","margin: 1px 0 8px 5px;","}",'.blocklyToolboxDiv[dir="RTL"] .blocklyTreeRow {',"margin-left: 8px;","}",".blocklyTreeRow:not(.blocklyTreeSelected):hover {","background-color: #e4e4e4;","}",".blocklyTreeSeparator {","border-bottom: solid #e5e5e5 1px;","height: 0;","margin: 5px 0;",
-"}",".blocklyTreeSeparatorHorizontal {","border-right: solid #e5e5e5 1px;","width: 0;","padding: 5px 0;","margin: 0 5px;","}",".blocklyTreeIcon {","background-image: url(<<>>/sprites.png);","height: 16px;","vertical-align: middle;","width: 16px;","}",".blocklyTreeIconClosedLtr {","background-position: -32px -1px;","}",".blocklyTreeIconClosedRtl {","background-position: 0 -1px;","}",".blocklyTreeIconOpen {","background-position: -16px -1px;","}",".blocklyTreeSelected>.blocklyTreeIconClosedLtr {",
-"background-position: -32px -17px;","}",".blocklyTreeSelected>.blocklyTreeIconClosedRtl {","background-position: 0 -17px;","}",".blocklyTreeSelected>.blocklyTreeIconOpen {","background-position: -16px -17px;","}",".blocklyTreeIconNone,",".blocklyTreeSelected>.blocklyTreeIconNone {","background-position: -48px -1px;","}",".blocklyTreeLabel {","cursor: default;","font-family: sans-serif;","font-size: 16px;","padding: 0 3px;","vertical-align: middle;","}",".blocklyToolboxDelete .blocklyTreeLabel {",
-'cursor: url("<<>>/handdelete.cur"), auto;',"}",".blocklyTreeSelected .blocklyTreeLabel {","color: #fff;","}"]);Blockly.Trashcan=function(a){this.workspace_=a;this.contents_=[];if(!(0>=this.workspace_.options.maxTrashcanContents)){a={scrollbars:!0,disabledPatternId:this.workspace_.options.disabledPatternId,parentWorkspace:this.workspace_,RTL:this.workspace_.RTL,oneBasedIndex:this.workspace_.options.oneBasedIndex,renderer:this.workspace_.options.renderer};if(this.workspace_.horizontalLayout){a.toolboxPosition=this.workspace_.toolboxPosition==Blockly.TOOLBOX_AT_TOP?Blockly.TOOLBOX_AT_BOTTOM:Blockly.TOOLBOX_AT_TOP;
-if(!Blockly.HorizontalFlyout)throw Error("Missing require for Blockly.HorizontalFlyout");this.flyout_=new Blockly.HorizontalFlyout(a)}else{a.toolboxPosition=this.workspace_.toolboxPosition==Blockly.TOOLBOX_AT_RIGHT?Blockly.TOOLBOX_AT_LEFT:Blockly.TOOLBOX_AT_RIGHT;if(!Blockly.VerticalFlyout)throw Error("Missing require for Blockly.VerticalFlyout");this.flyout_=new Blockly.VerticalFlyout(a)}this.workspace_.addChangeListener(this.onDelete_.bind(this))}};Blockly.Trashcan.prototype.WIDTH_=47;
-Blockly.Trashcan.prototype.BODY_HEIGHT_=44;Blockly.Trashcan.prototype.LID_HEIGHT_=16;Blockly.Trashcan.prototype.MARGIN_BOTTOM_=20;Blockly.Trashcan.prototype.MARGIN_SIDE_=20;Blockly.Trashcan.prototype.MARGIN_HOTSPOT_=10;Blockly.Trashcan.prototype.SPRITE_LEFT_=0;Blockly.Trashcan.prototype.SPRITE_TOP_=32;Blockly.Trashcan.prototype.HAS_BLOCKS_LID_ANGLE=.1;Blockly.Trashcan.prototype.isOpen=!1;Blockly.Trashcan.prototype.minOpenness_=0;Blockly.Trashcan.prototype.svgGroup_=null;
-Blockly.Trashcan.prototype.svgLid_=null;Blockly.Trashcan.prototype.lidTask_=0;Blockly.Trashcan.prototype.lidOpen_=0;Blockly.Trashcan.prototype.left_=0;Blockly.Trashcan.prototype.top_=0;
-Blockly.Trashcan.prototype.createDom=function(){this.svgGroup_=Blockly.utils.dom.createSvgElement("g",{"class":"blocklyTrash"},null);var a=String(Math.random()).substring(2);var b=Blockly.utils.dom.createSvgElement("clipPath",{id:"blocklyTrashBodyClipPath"+a},this.svgGroup_);Blockly.utils.dom.createSvgElement("rect",{width:this.WIDTH_,height:this.BODY_HEIGHT_,y:this.LID_HEIGHT_},b);var c=Blockly.utils.dom.createSvgElement("image",{width:Blockly.SPRITE.width,x:-this.SPRITE_LEFT_,height:Blockly.SPRITE.height,
-y:-this.SPRITE_TOP_,"clip-path":"url(#blocklyTrashBodyClipPath"+a+")"},this.svgGroup_);c.setAttributeNS(Blockly.utils.dom.XLINK_NS,"xlink:href",this.workspace_.options.pathToMedia+Blockly.SPRITE.url);b=Blockly.utils.dom.createSvgElement("clipPath",{id:"blocklyTrashLidClipPath"+a},this.svgGroup_);Blockly.utils.dom.createSvgElement("rect",{width:this.WIDTH_,height:this.LID_HEIGHT_},b);this.svgLid_=Blockly.utils.dom.createSvgElement("image",{width:Blockly.SPRITE.width,x:-this.SPRITE_LEFT_,height:Blockly.SPRITE.height,
-y:-this.SPRITE_TOP_,"clip-path":"url(#blocklyTrashLidClipPath"+a+")"},this.svgGroup_);this.svgLid_.setAttributeNS(Blockly.utils.dom.XLINK_NS,"xlink:href",this.workspace_.options.pathToMedia+Blockly.SPRITE.url);Blockly.bindEventWithChecks_(this.svgGroup_,"mouseup",this,this.click);Blockly.bindEvent_(c,"mouseover",this,this.mouseOver_);Blockly.bindEvent_(c,"mouseout",this,this.mouseOut_);this.animateLid_();return this.svgGroup_};
-Blockly.Trashcan.prototype.init=function(a){0this.minOpenness_&&1>this.lidOpen_&&(this.lidTask_=setTimeout(this.animateLid_.bind(this),20))};
-Blockly.Trashcan.prototype.setLidAngle_=function(a){var b=this.workspace_.toolboxPosition==Blockly.TOOLBOX_AT_RIGHT||this.workspace_.horizontalLayout&&this.workspace_.RTL;this.svgLid_.setAttribute("transform","rotate("+(b?-a:a)+","+(b?4:this.WIDTH_-4)+","+(this.LID_HEIGHT_-2)+")")};Blockly.Trashcan.prototype.close=function(){this.setOpen_(!1)};Blockly.Trashcan.prototype.click=function(){if(this.contents_.length){for(var a=[],b=0,c;c=this.contents_[b];b++)a[b]=Blockly.Xml.textToDom(c);this.flyout_.show(a)}};
-Blockly.Trashcan.prototype.mouseOver_=function(){this.contents_.length&&this.setOpen_(!0)};Blockly.Trashcan.prototype.mouseOut_=function(){this.setOpen_(!1)};
-Blockly.Trashcan.prototype.onDelete_=function(a){if(!(0>=this.workspace_.options.maxTrashcanContents)&&a.type==Blockly.Events.BLOCK_DELETE&&"shadow"!=a.oldXml.tagName.toLowerCase()&&(a=this.cleanBlockXML_(a.oldXml),-1==this.contents_.indexOf(a))){for(this.contents_.unshift(a);this.contents_.length>this.workspace_.options.maxTrashcanContents;)this.contents_.pop();this.minOpenness_=this.HAS_BLOCKS_LID_ANGLE;this.setLidAngle_(45*this.minOpenness_)}};
-Blockly.Trashcan.prototype.cleanBlockXML_=function(a){for(var b=a=a.cloneNode(!0);b;){b.removeAttribute&&(b.removeAttribute("x"),b.removeAttribute("y"),b.removeAttribute("id"));var c=b.firstChild||b.nextSibling;if(!c)for(c=b.parentNode;c;){if(c.nextSibling){c=c.nextSibling;break}c=c.parentNode}b=c}return Blockly.Xml.domToText(a)};Blockly.VariablesDynamic={};Blockly.VariablesDynamic.onCreateVariableButtonClick_String=function(a){Blockly.Variables.createVariableButtonHandler(a.getTargetWorkspace(),null,"String")};Blockly.VariablesDynamic.onCreateVariableButtonClick_Number=function(a){Blockly.Variables.createVariableButtonHandler(a.getTargetWorkspace(),null,"Number")};Blockly.VariablesDynamic.onCreateVariableButtonClick_Colour=function(a){Blockly.Variables.createVariableButtonHandler(a.getTargetWorkspace(),null,"Colour")};
-Blockly.VariablesDynamic.flyoutCategory=function(a){var b=[],c=document.createElement("button");c.setAttribute("text",Blockly.Msg.NEW_STRING_VARIABLE);c.setAttribute("callbackKey","CREATE_VARIABLE_STRING");b.push(c);c=document.createElement("button");c.setAttribute("text",Blockly.Msg.NEW_NUMBER_VARIABLE);c.setAttribute("callbackKey","CREATE_VARIABLE_NUMBER");b.push(c);c=document.createElement("button");c.setAttribute("text",Blockly.Msg.NEW_COLOUR_VARIABLE);c.setAttribute("callbackKey","CREATE_VARIABLE_COLOUR");
-b.push(c);a.registerButtonCallback("CREATE_VARIABLE_STRING",Blockly.VariablesDynamic.onCreateVariableButtonClick_String);a.registerButtonCallback("CREATE_VARIABLE_NUMBER",Blockly.VariablesDynamic.onCreateVariableButtonClick_Number);a.registerButtonCallback("CREATE_VARIABLE_COLOUR",Blockly.VariablesDynamic.onCreateVariableButtonClick_Colour);a=Blockly.VariablesDynamic.flyoutCategoryBlocks(a);return b=b.concat(a)};
-Blockly.VariablesDynamic.flyoutCategoryBlocks=function(a){a=a.getAllVariables();var b=[];if(0image, .blocklyZoom>svg>image {","opacity: .4;","}",".blocklyZoom>image:hover, .blocklyZoom>svg>image:hover {","opacity: .6;","}",".blocklyZoom>image:active, .blocklyZoom>svg>image:active {","opacity: .8;","}"]);Blockly.Themes.Dark={};
-Blockly.Themes.Dark.defaultBlockStyles={colour_blocks:{colourPrimary:"#a5745b",colourSecondary:"#dbc7bd",colourTertiary:"#845d49"},list_blocks:{colourPrimary:"#745ba5",colourSecondary:"#c7bddb",colourTertiary:"#5d4984"},logic_blocks:{colourPrimary:"#5b80a5",colourSecondary:"#bdccdb",colourTertiary:"#496684"},loop_blocks:{colourPrimary:"#5ba55b",colourSecondary:"#bddbbd",colourTertiary:"#498449"},math_blocks:{colourPrimary:"#5b67a5",colourSecondary:"#bdc2db",colourTertiary:"#495284"},procedure_blocks:{colourPrimary:"#995ba5",
-colourSecondary:"#d6bddb",colourTertiary:"#7a4984"},text_blocks:{colourPrimary:"#5ba58c",colourSecondary:"#bddbd1",colourTertiary:"#498470"},variable_blocks:{colourPrimary:"#a55b99",colourSecondary:"#dbbdd6",colourTertiary:"#84497a"},variable_dynamic_blocks:{colourPrimary:"#a55b99",colourSecondary:"#dbbdd6",colourTertiary:"#84497a"},hat_blocks:{colourPrimary:"#a55b99",colourSecondary:"#dbbdd6",colourTertiary:"#84497a",hat:"cap"}};
-Blockly.Themes.Dark.categoryStyles={colour_category:{colour:"#a5745b"},list_category:{colour:"#745ba5"},logic_category:{colour:"#5b80a5"},loop_category:{colour:"#5ba55b"},math_category:{colour:"#5b67a5"},procedure_category:{colour:"#995ba5"},text_category:{colour:"#5ba58c"},variable_category:{colour:"#a55b99"},variable_dynamic_category:{colour:"#a55b99"}};Blockly.Themes.Dark=new Blockly.Theme(Blockly.Themes.Dark.defaultBlockStyles,Blockly.Themes.Dark.categoryStyles);
-Blockly.Themes.Dark.setComponentStyle("workspace","#1e1e1e");Blockly.Themes.Dark.setComponentStyle("toolbox","#333");Blockly.Themes.Dark.setComponentStyle("toolboxText","#fff");Blockly.Themes.Dark.setComponentStyle("flyout","#252526");Blockly.Themes.Dark.setComponentStyle("flyoutText","#ccc");Blockly.Themes.Dark.setComponentStyle("flyoutOpacity",1);Blockly.Themes.Dark.setComponentStyle("scrollbar","#797979");Blockly.Themes.Dark.setComponentStyle("scrollbarOpacity",.4);Blockly.Themes.HighContrast={};
-Blockly.Themes.HighContrast.defaultBlockStyles={colour_blocks:{colourPrimary:"#a52714",colourSecondary:"#FB9B8C",colourTertiary:"#FBE1DD"},list_blocks:{colourPrimary:"#4a148c",colourSecondary:"#AD7BE9",colourTertiary:"#CDB6E9"},logic_blocks:{colourPrimary:"#01579b",colourSecondary:"#64C7FF",colourTertiary:"#C5EAFF"},loop_blocks:{colourPrimary:"#33691e",colourSecondary:"#9AFF78",colourTertiary:"#E1FFD7"},math_blocks:{colourPrimary:"#1a237e",colourSecondary:"#8A9EFF",colourTertiary:"#DCE2FF"},procedure_blocks:{colourPrimary:"#006064",
-colourSecondary:"#77E6EE",colourTertiary:"#CFECEE"},text_blocks:{colourPrimary:"#004d40",colourSecondary:"#5ae27c",colourTertiary:"#D2FFDD"},variable_blocks:{colourPrimary:"#880e4f",colourSecondary:"#FF73BE",colourTertiary:"#FFD4EB"},variable_dynamic_blocks:{colourPrimary:"#880e4f",colourSecondary:"#FF73BE",colourTertiary:"#FFD4EB"},hat_blocks:{colourPrimary:"#880e4f",colourSecondary:"#FF73BE",colourTertiary:"#FFD4EB",hat:"cap"}};
-Blockly.Themes.HighContrast.categoryStyles={colour_category:{colour:"#a52714"},list_category:{colour:"#4a148c"},logic_category:{colour:"#01579b"},loop_category:{colour:"#33691e"},math_category:{colour:"#1a237e"},procedure_category:{colour:"#006064"},text_category:{colour:"#004d40"},variable_category:{colour:"#880e4f"},variable_dynamic_category:{colour:"#880e4f"}};Blockly.Themes.HighContrast=new Blockly.Theme(Blockly.Themes.HighContrast.defaultBlockStyles,Blockly.Themes.HighContrast.categoryStyles);Blockly.Themes.Modern={};
-Blockly.Themes.Modern.defaultBlockStyles={colour_blocks:{colourPrimary:"#a5745b",colourSecondary:"#dbc7bd",colourTertiary:"#845d49"},list_blocks:{colourPrimary:"#745ba5",colourSecondary:"#c7bddb",colourTertiary:"#5d4984"},logic_blocks:{colourPrimary:"#5b80a5",colourSecondary:"#bdccdb",colourTertiary:"#496684"},loop_blocks:{colourPrimary:"#5ba55b",colourSecondary:"#bddbbd",colourTertiary:"#498449"},math_blocks:{colourPrimary:"#5b67a5",colourSecondary:"#bdc2db",colourTertiary:"#495284"},procedure_blocks:{colourPrimary:"#995ba5",
-colourSecondary:"#d6bddb",colourTertiary:"#7a4984"},text_blocks:{colourPrimary:"#5ba58c",colourSecondary:"#bddbd1",colourTertiary:"#498470"},variable_blocks:{colourPrimary:"#a55b99",colourSecondary:"#dbbdd6",colourTertiary:"#84497a"},variable_dynamic_blocks:{colourPrimary:"#a55b99",colourSecondary:"#dbbdd6",colourTertiary:"#84497a"},hat_blocks:{colourPrimary:"#a55b99",colourSecondary:"#dbbdd6",colourTertiary:"#84497a",hat:"cap"}};
-Blockly.Themes.Modern.categoryStyles={colour_category:{colour:"#a5745b"},list_category:{colour:"#745ba5"},logic_category:{colour:"#5b80a5"},loop_category:{colour:"#5ba55b"},math_category:{colour:"#5b67a5"},procedure_category:{colour:"#995ba5"},text_category:{colour:"#5ba58c"},variable_category:{colour:"#a55b99"},variable_dynamic_category:{colour:"#a55b99"}};Blockly.Themes.Modern=new Blockly.Theme(Blockly.Themes.Modern.defaultBlockStyles,Blockly.Themes.Modern.categoryStyles);Blockly.requires={};
\ No newline at end of file
+$.register$$module$build$src$core$extensions=function(a,b){if("string"!==typeof a||""===a.trim())throw Error('Error: Invalid extension name "'+a+'"');if(allExtensions$$module$build$src$core$extensions[a])throw Error('Error: Extension "'+a+'" is already registered.');if("function"!==typeof b)throw Error('Error: Extension "'+a+'" must be a function');allExtensions$$module$build$src$core$extensions[a]=b};
+$.registerMixin$$module$build$src$core$extensions=function(a,b){if(!b||"object"!==typeof b)throw Error('Error: Mixin "'+a+'" must be a object');$.register$$module$build$src$core$extensions(a,function(){this.mixin(b)})};
+$.registerMutator$$module$build$src$core$extensions=function(a,b,c,d){const e='Error when registering mutator "'+a+'": ';checkHasMutatorProperties$$module$build$src$core$extensions(e,b);const f=checkMutatorDialog$$module$build$src$core$extensions(b,e);if(c&&"function"!==typeof c)throw Error(e+'Extension "'+a+'" is not a function');$.register$$module$build$src$core$extensions(a,function(){f&&this.setMutator(new $.MutatorIcon$$module$build$src$core$icons$mutator_icon(d||[],this));this.mixin(b);c&&c.apply(this)})};
+unregister$$module$build$src$core$extensions=function(a){isRegistered$$module$build$src$core$extensions(a)?delete allExtensions$$module$build$src$core$extensions[a]:console.warn('No extension mapping for name "'+a+'" found to unregister')};isRegistered$$module$build$src$core$extensions=function(a){return!!allExtensions$$module$build$src$core$extensions[a]};
+apply$$module$build$src$core$extensions=function(a,b,c){const d=allExtensions$$module$build$src$core$extensions[a];if("function"!==typeof d)throw Error('Error: Extension "'+a+'" not found.');let e;c?checkNoMutatorProperties$$module$build$src$core$extensions(a,b):e=getMutatorProperties$$module$build$src$core$extensions(b);d.apply(b);if(c)checkHasMutatorProperties$$module$build$src$core$extensions('Error after applying mutator "'+a+'": ',b);else if(!mutatorPropertiesMatch$$module$build$src$core$extensions(e,
+b))throw Error('Error when applying extension "'+a+'": mutation properties changed when applying a non-mutator extension.');};checkNoMutatorProperties$$module$build$src$core$extensions=function(a,b){if(getMutatorProperties$$module$build$src$core$extensions(b).length)throw Error('Error: tried to apply mutation "'+a+'" to a block that already has mutator functions.  Block id: '+b.id);};
+checkXmlHooks$$module$build$src$core$extensions=function(a,b){return checkHasFunctionPair$$module$build$src$core$extensions(a.mutationToDom,a.domToMutation,b+" mutationToDom/domToMutation")};checkJsonHooks$$module$build$src$core$extensions=function(a,b){return checkHasFunctionPair$$module$build$src$core$extensions(a.saveExtraState,a.loadExtraState,b+" saveExtraState/loadExtraState")};
+checkMutatorDialog$$module$build$src$core$extensions=function(a,b){return checkHasFunctionPair$$module$build$src$core$extensions(a.compose,a.decompose,b+" compose/decompose")};checkHasFunctionPair$$module$build$src$core$extensions=function(a,b,c){if(a&&b){if("function"!==typeof a||"function"!==typeof b)throw Error(c+" must be a function");return!0}if(!a&&!b)return!1;throw Error(c+"Must have both or neither functions");};
+checkHasMutatorProperties$$module$build$src$core$extensions=function(a,b){const c=checkXmlHooks$$module$build$src$core$extensions(b,a),d=checkJsonHooks$$module$build$src$core$extensions(b,a);if(!c&&!d)throw Error(a+"Mutations must contain either XML hooks, or JSON hooks, or both");checkMutatorDialog$$module$build$src$core$extensions(b,a)};
+getMutatorProperties$$module$build$src$core$extensions=function(a){const b=[];void 0!==a.domToMutation&&b.push(a.domToMutation);void 0!==a.mutationToDom&&b.push(a.mutationToDom);void 0!==a.saveExtraState&&b.push(a.saveExtraState);void 0!==a.loadExtraState&&b.push(a.loadExtraState);void 0!==a.compose&&b.push(a.compose);void 0!==a.decompose&&b.push(a.decompose);return b};
+mutatorPropertiesMatch$$module$build$src$core$extensions=function(a,b){b=getMutatorProperties$$module$build$src$core$extensions(b);if(b.length!==a.length)return!1;for(let c=0;c!d.getReturnTypes()).map(d=>[d.getName(),d.getParameters().map(e=>e.getName()),!1]);a.getBlocksByType("procedures_defnoreturn",!1).forEach(d=>{!isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block(d)&&isLegacyProcedureDefBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks(d)&&b.push(d.getProcedureDef())});const c=a.getProcedureMap().getProcedures().filter(d=>
+!!d.getReturnTypes()).map(d=>[d.getName(),d.getParameters().map(e=>e.getName()),!0]);a.getBlocksByType("procedures_defreturn",!1).forEach(d=>{!isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block(d)&&isLegacyProcedureDefBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks(d)&&c.push(d.getProcedureDef())});b.sort(procTupleComparator$$module$build$src$core$procedures);c.sort(procTupleComparator$$module$build$src$core$procedures);return[b,c]};
+procTupleComparator$$module$build$src$core$procedures=function(a,b){return a[0].localeCompare(b[0],void 0,{sensitivity:"base"})};$.findLegalName$$module$build$src$core$procedures=function(a,b){if(b.isInFlyout)return a;for(a=a||$.Msg$$module$build$src$core$msg.UNNAMED_KEY||"unnamed";!isLegalName$$module$build$src$core$procedures(a,b.workspace,b);){const c=a.match(/^(.*?)(\d+)$/);a=c?c[1]+(parseInt(c[2])+1):a+"2"}return a};
+isLegalName$$module$build$src$core$procedures=function(a,b,c){return!isNameUsed$$module$build$src$core$procedures(a,b,c)};
+isNameUsed$$module$build$src$core$procedures=function(a,b,c){for(const d of b.getAllBlocks(!1))if(d!==c&&isLegacyProcedureDefBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks(d)&&$.Names$$module$build$src$core$names.equals(d.getProcedureDef()[0],a))return!0;c=c&&isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block(c)?null==c?void 0:c.getProcedureModel():void 0;for(const d of b.getProcedureMap().getProcedures())if(d!==c&&$.Names$$module$build$src$core$names.equals(d.getName(),
+a))return!0;return!1};
+$.rename$$module$build$src$core$procedures=function(a){var b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;a=a.trim();const c=$.findLegalName$$module$build$src$core$procedures(a,b);isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block(b)&&!b.isInsertionMarker()&&b.getProcedureModel().setName(c);const d=this.getValue();if(d!==a&&d!==c)for(a=b.workspace.getAllBlocks(!1),b=0;bblockIsModernCallerFor$$module$build$src$core$procedures(c,a)||isLegacyProcedureCallBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks(c)&&$.Names$$module$build$src$core$names.equals(c.getProcedureCall(),a))};
+blockIsModernCallerFor$$module$build$src$core$procedures=function(a,b){return isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block(a)&&!a.isProcedureDef()&&a.getProcedureModel()&&$.Names$$module$build$src$core$names.equals(a.getProcedureModel().getName(),b)};
+$.mutateCallers$$module$build$src$core$procedures=function(a){const b=getRecordUndo$$module$build$src$core$events$utils();var c=a.getProcedureDef()[0];const d=a.mutationToDom(!0);a=getCallers$$module$build$src$core$procedures(c,a.workspace);for(let f=0,g;g=a[f];f++){c=(c=g.mutationToDom())&&domToText$$module$build$src$core$utils$xml(c);g.domToMutation&&g.domToMutation(d);var e=g.mutationToDom();e=e&&domToText$$module$build$src$core$utils$xml(e);c!==e&&(setRecordUndo$$module$build$src$core$events$utils(!1),
+fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CHANGE$$module$build$src$core$events$utils))(g,"mutation",null,c,e)),setRecordUndo$$module$build$src$core$events$utils(b))}};
+$.getDefinition$$module$build$src$core$procedures=function(a,b){for(const c of b.getAllBlocks(!1))if(isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block(c)&&c.isProcedureDef()&&$.Names$$module$build$src$core$names.equals(c.getProcedureModel().getName(),a)||isLegacyProcedureDefBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks(c)&&$.Names$$module$build$src$core$names.equals(c.getProcedureDef()[0],a))return c;return null};
+isDynamicShape$$module$build$src$core$renderers$common$constants=function(a){return a.isDynamic};getParentConnection$$module$build$src$core$keyboard_nav$ast_node=function(a){let b=a.outputConnection;if(!b||a.previousConnection&&a.previousConnection.isConnected())b=a.previousConnection;return b};connectReciprocally$$module$build$src$core$connection=function(a,b){if(!a||!b)throw Error("Cannot connect null connections.");a.targetConnection=b;b.targetConnection=a};
+getSingleConnection$$module$build$src$core$connection=function(a,b){let c=null;b=b.outputConnection;const d=null==b?void 0:b.getConnectionChecker();for(let e=0,f;f=a.inputList[e];e++){const g=f.connection;let h;if(g&&(null==(h=d)?0:h.canConnect(b,g,!1))){if(c)return null;c=g}}return c};getConnectionForOrphanedOutput$$module$build$src$core$connection=function(a,b){let c;for(;c=getSingleConnection$$module$build$src$core$connection(a,b);)if(a=c.targetBlock(),!a||a.isShadow())return c;return null};
+register$$module$build$src$core$renderers$common$block_rendering=function(a,b){register$$module$build$src$core$registry(Type$$module$build$src$core$registry.RENDERER,a,b)};unregister$$module$build$src$core$renderers$common$block_rendering=function(a){unregister$$module$build$src$core$registry(Type$$module$build$src$core$registry.RENDERER,a)};
+init$$module$build$src$core$renderers$common$block_rendering=function(a,b,c){a=new (getClass$$module$build$src$core$registry(Type$$module$build$src$core$registry.RENDERER,a))(a);a.init(b,c);return a};stringButtonClickHandler$$module$build$src$core$variables_dynamic=function(a){createVariableButtonHandler$$module$build$src$core$variables(a.getTargetWorkspace(),void 0,"String")};
+numberButtonClickHandler$$module$build$src$core$variables_dynamic=function(a){createVariableButtonHandler$$module$build$src$core$variables(a.getTargetWorkspace(),void 0,"Number")};colourButtonClickHandler$$module$build$src$core$variables_dynamic=function(a){createVariableButtonHandler$$module$build$src$core$variables(a.getTargetWorkspace(),void 0,"Colour")};
+flyoutCategory$$module$build$src$core$variables_dynamic=function(a){let b=[],c=document.createElement("button");c.setAttribute("text",$.Msg$$module$build$src$core$msg.NEW_STRING_VARIABLE);c.setAttribute("callbackKey","CREATE_VARIABLE_STRING");b.push(c);c=document.createElement("button");c.setAttribute("text",$.Msg$$module$build$src$core$msg.NEW_NUMBER_VARIABLE);c.setAttribute("callbackKey","CREATE_VARIABLE_NUMBER");b.push(c);c=document.createElement("button");c.setAttribute("text",$.Msg$$module$build$src$core$msg.NEW_COLOUR_VARIABLE);
+c.setAttribute("callbackKey","CREATE_VARIABLE_COLOUR");b.push(c);a.registerButtonCallback("CREATE_VARIABLE_STRING",stringButtonClickHandler$$module$build$src$core$variables_dynamic);a.registerButtonCallback("CREATE_VARIABLE_NUMBER",numberButtonClickHandler$$module$build$src$core$variables_dynamic);a.registerButtonCallback("CREATE_VARIABLE_COLOUR",colourButtonClickHandler$$module$build$src$core$variables_dynamic);a=flyoutCategoryBlocks$$module$build$src$core$variables_dynamic(a);return b=b.concat(a)};
+flyoutCategoryBlocks$$module$build$src$core$variables_dynamic=function(a){a=a.getAllVariables();const b=[];if(0saveParameter$$module$build$src$core$serialization$procedures(c));return b};saveParameter$$module$build$src$core$serialization$procedures=function(a){const b={id:a.getId(),name:a.getName()};if(!a.getTypes().length)return b;b.types=a.getTypes();return b};
+loadProcedure$$module$build$src$core$serialization$procedures=function(a,b,c,d){a=(new a(d,c.name,c.id)).setReturnTypes(c.returnTypes);if(!c.parameters)return a;for(const [e,f]of c.parameters.entries())a.insertParameter(loadParameter$$module$build$src$core$serialization$procedures(b,f,d),e);return a};loadParameter$$module$build$src$core$serialization$procedures=function(a,b,c){a=new a(c,b.name,b.id);b.types&&a.setTypes(b.types);return a};
+save$$module$build$src$core$serialization$workspaces=function(a){const b=Object.create(null),c=getAllItems$$module$build$src$core$registry(Type$$module$build$src$core$registry.SERIALIZER,!0);for(const d in c){let e;const f=null==(e=c[d])?void 0:e.save(a);f&&(b[d]=f)}return b};
+load$$module$build$src$core$serialization$workspaces=function(a,b,{recordUndo:c=!1}={}){var d=getAllItems$$module$build$src$core$registry(Type$$module$build$src$core$registry.SERIALIZER,!0);if(d){d=Object.entries(d).sort((f,g)=>g[1].priority-f[1].priority);var e=getRecordUndo$$module$build$src$core$events$utils();setRecordUndo$$module$build$src$core$events$utils(c);(c=$.getGroup$$module$build$src$core$events$utils())||$.setGroup$$module$build$src$core$events$utils(!0);startTextWidthCache$$module$build$src$core$utils$dom();
+b instanceof WorkspaceSvg$$module$build$src$core$workspace_svg&&b.setResizesEnabled(!1);for(const [,f]of d.reverse()){let g;null==(g=f)||g.clear(b)}for(let [f,g]of d.reverse())if(a[f]){let h;null==(h=g)||h.load(a[f],b)}b instanceof WorkspaceSvg$$module$build$src$core$workspace_svg&&b.setResizesEnabled(!0);stopTextWidthCache$$module$build$src$core$utils$dom();fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(FINISHED_LOADING$$module$build$src$core$events$utils))(b));
+$.setGroup$$module$build$src$core$events$utils(c);setRecordUndo$$module$build$src$core$events$utils(e)}};bumpObjectIntoBounds$$module$build$src$core$bump_objects=function(a,b,c){const d=c.getBoundingRectangle(),e=d.right-d.left,f=clamp$$module$build$src$core$utils$math(b.top,d.top,b.top+b.height-(d.bottom-d.top))-d.top;let g=b.left;b=b.left+b.width-e;a.RTL?g=Math.min(b,g):b=Math.max(g,b);return(a=clamp$$module$build$src$core$utils$math(g,d.left,b)-d.left)||f?(c.moveBy(a,f,["inbounds"]),!0):!1};
+bumpIntoBoundsHandler$$module$build$src$core$bump_objects=function(a){return b=>{var c=a.getMetricsManager();if(c.hasFixedEdges()&&!a.isDragging()){var d;if(-1!==BUMP_EVENTS$$module$build$src$core$events$utils.indexOf(null!=(d=b.type)?d:"")){d=c.getScrollMetrics(!0);const e=extractObjectFromEvent$$module$build$src$core$bump_objects(a,b);e&&(c=$.getGroup$$module$build$src$core$events$utils()||!1,$.setGroup$$module$build$src$core$events$utils(b.group),bumpObjectIntoBounds$$module$build$src$core$bump_objects(a,
+d,e)&&!b.group&&console.warn("Moved object in bounds but there was no event group. This may break undo."),$.setGroup$$module$build$src$core$events$utils(c))}else b.type===VIEWPORT_CHANGE$$module$build$src$core$events$utils&&b.scale&&b.oldScale&&b.scale>b.oldScale&&bumpTopObjectsIntoBounds$$module$build$src$core$bump_objects(a)}}};
+extractObjectFromEvent$$module$build$src$core$bump_objects=function(a,b){let c=null;switch(b.type){case $.CREATE$$module$build$src$core$events$utils:case $.MOVE$$module$build$src$core$events$utils:(c=a.getBlockById(b.blockId))&&(c=c.getRootBlock());break;case COMMENT_CREATE$$module$build$src$core$events$utils:case COMMENT_MOVE$$module$build$src$core$events$utils:c=a.getCommentById(b.commentId)}return c};
+bumpTopObjectsIntoBounds$$module$build$src$core$bump_objects=function(a){var b=a.getMetricsManager();if(b.hasFixedEdges()&&!a.isDragging()){b=b.getScrollMetrics(!0);var c=a.getTopBoundedElements();for(let d=0,e;e=c[d];d++)bumpObjectIntoBounds$$module$build$src$core$bump_objects(a,b,e)}};
+initIconData$$module$build$src$core$block_dragger=function(a,b){const c=[];for(const d of a.getIcons())if(!hasBubble$$module$build$src$core$interfaces$i_has_bubble(d)||d.bubbleIsVisible())c.push({location:b,icon:d}),d.onLocationChange(b);for(const d of a.getChildren(!1))c.push(...initIconData$$module$build$src$core$block_dragger(d,Coordinate$$module$build$src$core$utils$coordinate.sum(b,d.relativeCoords)));return c};
+registerUndo$$module$build$src$core$contextmenu_items=function(){ContextMenuRegistry$$module$build$src$core$contextmenu_registry.registry.register({displayText(){return $.Msg$$module$build$src$core$msg.UNDO},preconditionFn(a){return 0b.length?deleteNext_$$module$build$src$core$contextmenu_items(b):confirm$$module$build$src$core$dialog($.Msg$$module$build$src$core$msg.DELETE_ALL_BLOCKS.replace("%1",String(b.length)),function(c){c&&deleteNext_$$module$build$src$core$contextmenu_items(b)})}},scopeType:ContextMenuRegistry$$module$build$src$core$contextmenu_registry.ScopeType.WORKSPACE,
+id:"workspaceDelete",weight:6})};registerWorkspaceOptions_$$module$build$src$core$contextmenu_items=function(){registerUndo$$module$build$src$core$contextmenu_items();registerRedo$$module$build$src$core$contextmenu_items();registerCleanup$$module$build$src$core$contextmenu_items();registerCollapse$$module$build$src$core$contextmenu_items();registerExpand$$module$build$src$core$contextmenu_items();registerDeleteAll$$module$build$src$core$contextmenu_items()};
+registerDuplicate$$module$build$src$core$contextmenu_items=function(){ContextMenuRegistry$$module$build$src$core$contextmenu_registry.registry.register({displayText(){return $.Msg$$module$build$src$core$msg.DUPLICATE_BLOCK},preconditionFn(a){a=a.block;return!a.isInFlyout&&a.isDeletable()&&a.isMovable()?a.isDuplicatable()?"enabled":"disabled":"hidden"},callback(a){if(a.block){var b=a.block.toCopyData();b&&paste$$module$build$src$core$clipboard(b,a.block.workspace)}},scopeType:ContextMenuRegistry$$module$build$src$core$contextmenu_registry.ScopeType.BLOCK,
+id:"blockDuplicate",weight:1})};
+registerComment$$module$build$src$core$contextmenu_items=function(){ContextMenuRegistry$$module$build$src$core$contextmenu_registry.registry.register({displayText(a){return a.block.hasIcon(CommentIcon$$module$build$src$core$icons$comment_icon.TYPE)?$.Msg$$module$build$src$core$msg.REMOVE_COMMENT:$.Msg$$module$build$src$core$msg.ADD_COMMENT},preconditionFn(a){a=a.block;return!a.isInFlyout&&a.workspace.options.comments&&!a.isCollapsed()&&a.isEditable()?"enabled":"hidden"},callback(a){a=a.block;a.hasIcon(CommentIcon$$module$build$src$core$icons$comment_icon.TYPE)?
+a.setCommentText(null):a.setCommentText("")},scopeType:ContextMenuRegistry$$module$build$src$core$contextmenu_registry.ScopeType.BLOCK,id:"blockComment",weight:2})};
+registerInline$$module$build$src$core$contextmenu_items=function(){ContextMenuRegistry$$module$build$src$core$contextmenu_registry.registry.register({displayText(a){return a.block.getInputsInline()?$.Msg$$module$build$src$core$msg.EXTERNAL_INPUTS:$.Msg$$module$build$src$core$msg.INLINE_INPUTS},preconditionFn(a){a=a.block;if(!a.isInFlyout&&a.isMovable()&&!a.isCollapsed())for(let b=1;b>>0,$jscomp.propertyToPolyfillSymbol[e]=$jscomp.IS_SYMBOL_NATIVE?
+$jscomp.global.Symbol(e):$jscomp.POLYFILL_PREFIX+c+"$"+e),$jscomp.defineProperty(d,$jscomp.propertyToPolyfillSymbol[e],{configurable:!0,writable:!0,value:b})))};$jscomp.polyfill("globalThis",function(a){return a||$jscomp.global},"es_2020","es3");$jscomp.arrayIteratorImpl=function(a){var b=0;return function(){return b{const a=soup$$module$build$src$core$utils$idgenerator.length,b=[];for(let c=0;20>c;c++)b[c]=soup$$module$build$src$core$utils$idgenerator.charAt(Math.random()*a);return b.join("")}},TEST_ONLY$$module$build$src$core$utils$idgenerator=internal$$module$build$src$core$utils$idgenerator,nextId$$module$build$src$core$utils$idgenerator=
+0,module$build$src$core$utils$idgenerator={TEST_ONLY:internal$$module$build$src$core$utils$idgenerator};module$build$src$core$utils$idgenerator.genUid=genUid$$module$build$src$core$utils$idgenerator;module$build$src$core$utils$idgenerator.getNextUniqueId=getNextUniqueId$$module$build$src$core$utils$idgenerator;var group$$module$build$src$core$events$utils,recordUndo$$module$build$src$core$events$utils,disabled$$module$build$src$core$events$utils,BLOCK_CREATE$$module$build$src$core$events$utils,BLOCK_DELETE$$module$build$src$core$events$utils,BLOCK_CHANGE$$module$build$src$core$events$utils,BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils,BLOCK_MOVE$$module$build$src$core$events$utils,VAR_CREATE$$module$build$src$core$events$utils,VAR_DELETE$$module$build$src$core$events$utils,VAR_RENAME$$module$build$src$core$events$utils,
+UI$$module$build$src$core$events$utils,BLOCK_DRAG$$module$build$src$core$events$utils,SELECTED$$module$build$src$core$events$utils,CLICK$$module$build$src$core$events$utils,MARKER_MOVE$$module$build$src$core$events$utils,BUBBLE_OPEN$$module$build$src$core$events$utils,TRASHCAN_OPEN$$module$build$src$core$events$utils,TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils,THEME_CHANGE$$module$build$src$core$events$utils,VIEWPORT_CHANGE$$module$build$src$core$events$utils,COMMENT_CREATE$$module$build$src$core$events$utils,
+COMMENT_DELETE$$module$build$src$core$events$utils,COMMENT_CHANGE$$module$build$src$core$events$utils,COMMENT_MOVE$$module$build$src$core$events$utils,FINISHED_LOADING$$module$build$src$core$events$utils,BUMP_EVENTS$$module$build$src$core$events$utils,FIRE_QUEUE$$module$build$src$core$events$utils,TEST_ONLY$$module$build$src$core$events$utils,module$build$src$core$events$utils;group$$module$build$src$core$events$utils="";recordUndo$$module$build$src$core$events$utils=!0;
+disabled$$module$build$src$core$events$utils=0;$.CREATE$$module$build$src$core$events$utils="create";BLOCK_CREATE$$module$build$src$core$events$utils=$.CREATE$$module$build$src$core$events$utils;$.DELETE$$module$build$src$core$events$utils="delete";BLOCK_DELETE$$module$build$src$core$events$utils=$.DELETE$$module$build$src$core$events$utils;$.CHANGE$$module$build$src$core$events$utils="change";BLOCK_CHANGE$$module$build$src$core$events$utils=$.CHANGE$$module$build$src$core$events$utils;
+BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils="block_field_intermediate_change";$.MOVE$$module$build$src$core$events$utils="move";BLOCK_MOVE$$module$build$src$core$events$utils=$.MOVE$$module$build$src$core$events$utils;VAR_CREATE$$module$build$src$core$events$utils="var_create";VAR_DELETE$$module$build$src$core$events$utils="var_delete";VAR_RENAME$$module$build$src$core$events$utils="var_rename";UI$$module$build$src$core$events$utils="ui";
+BLOCK_DRAG$$module$build$src$core$events$utils="drag";SELECTED$$module$build$src$core$events$utils="selected";CLICK$$module$build$src$core$events$utils="click";MARKER_MOVE$$module$build$src$core$events$utils="marker_move";BUBBLE_OPEN$$module$build$src$core$events$utils="bubble_open";TRASHCAN_OPEN$$module$build$src$core$events$utils="trashcan_open";TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils="toolbox_item_select";THEME_CHANGE$$module$build$src$core$events$utils="theme_change";
+VIEWPORT_CHANGE$$module$build$src$core$events$utils="viewport_change";COMMENT_CREATE$$module$build$src$core$events$utils="comment_create";COMMENT_DELETE$$module$build$src$core$events$utils="comment_delete";COMMENT_CHANGE$$module$build$src$core$events$utils="comment_change";COMMENT_MOVE$$module$build$src$core$events$utils="comment_move";FINISHED_LOADING$$module$build$src$core$events$utils="finished_loading";
+BUMP_EVENTS$$module$build$src$core$events$utils=[$.CREATE$$module$build$src$core$events$utils,$.MOVE$$module$build$src$core$events$utils,COMMENT_CREATE$$module$build$src$core$events$utils,COMMENT_MOVE$$module$build$src$core$events$utils];FIRE_QUEUE$$module$build$src$core$events$utils=[];
+TEST_ONLY$$module$build$src$core$events$utils={FIRE_QUEUE:FIRE_QUEUE$$module$build$src$core$events$utils,fireNow:fireNow$$module$build$src$core$events$utils,fireInternal:fireInternal$$module$build$src$core$events$utils,setGroupInternal:setGroupInternal$$module$build$src$core$events$utils};
+module$build$src$core$events$utils={BLOCK_CHANGE:$.CHANGE$$module$build$src$core$events$utils,BLOCK_CREATE:$.CREATE$$module$build$src$core$events$utils,BLOCK_DELETE:$.DELETE$$module$build$src$core$events$utils,BLOCK_DRAG:BLOCK_DRAG$$module$build$src$core$events$utils,BLOCK_FIELD_INTERMEDIATE_CHANGE:BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils,BLOCK_MOVE:$.MOVE$$module$build$src$core$events$utils,BUBBLE_OPEN:BUBBLE_OPEN$$module$build$src$core$events$utils,BUMP_EVENTS:BUMP_EVENTS$$module$build$src$core$events$utils,
+CHANGE:$.CHANGE$$module$build$src$core$events$utils,CLICK:CLICK$$module$build$src$core$events$utils,COMMENT_CHANGE:COMMENT_CHANGE$$module$build$src$core$events$utils,COMMENT_CREATE:COMMENT_CREATE$$module$build$src$core$events$utils,COMMENT_DELETE:COMMENT_DELETE$$module$build$src$core$events$utils,COMMENT_MOVE:COMMENT_MOVE$$module$build$src$core$events$utils,CREATE:$.CREATE$$module$build$src$core$events$utils,DELETE:$.DELETE$$module$build$src$core$events$utils,FINISHED_LOADING:FINISHED_LOADING$$module$build$src$core$events$utils,
+MARKER_MOVE:MARKER_MOVE$$module$build$src$core$events$utils,MOVE:$.MOVE$$module$build$src$core$events$utils,SELECTED:SELECTED$$module$build$src$core$events$utils,TEST_ONLY:TEST_ONLY$$module$build$src$core$events$utils,THEME_CHANGE:THEME_CHANGE$$module$build$src$core$events$utils,TOOLBOX_ITEM_SELECT:TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils,TRASHCAN_OPEN:TRASHCAN_OPEN$$module$build$src$core$events$utils,UI:UI$$module$build$src$core$events$utils,VAR_CREATE:VAR_CREATE$$module$build$src$core$events$utils,
+VAR_DELETE:VAR_DELETE$$module$build$src$core$events$utils,VAR_RENAME:VAR_RENAME$$module$build$src$core$events$utils,VIEWPORT_CHANGE:VIEWPORT_CHANGE$$module$build$src$core$events$utils};module$build$src$core$events$utils.clearPendingUndo=clearPendingUndo$$module$build$src$core$events$utils;module$build$src$core$events$utils.disable=$.disable$$module$build$src$core$events$utils;module$build$src$core$events$utils.disableOrphans=disableOrphans$$module$build$src$core$events$utils;
+module$build$src$core$events$utils.enable=$.enable$$module$build$src$core$events$utils;module$build$src$core$events$utils.filter=filter$$module$build$src$core$events$utils;module$build$src$core$events$utils.fire=fire$$module$build$src$core$events$utils;module$build$src$core$events$utils.fromJson=fromJson$$module$build$src$core$events$utils;module$build$src$core$events$utils.get=get$$module$build$src$core$events$utils;module$build$src$core$events$utils.getDescendantIds=getDescendantIds$$module$build$src$core$events$utils;
+module$build$src$core$events$utils.getGroup=$.getGroup$$module$build$src$core$events$utils;module$build$src$core$events$utils.getRecordUndo=getRecordUndo$$module$build$src$core$events$utils;module$build$src$core$events$utils.isEnabled=isEnabled$$module$build$src$core$events$utils;module$build$src$core$events$utils.setGroup=$.setGroup$$module$build$src$core$events$utils;module$build$src$core$events$utils.setRecordUndo=setRecordUndo$$module$build$src$core$events$utils;var Abstract$$module$build$src$core$events$events_abstract=class{constructor(){this.workspaceId=void 0;this.isUiEvent=!1;this.type="";this.group=$.getGroup$$module$build$src$core$events$utils();this.recordUndo=getRecordUndo$$module$build$src$core$events$utils()}toJson(){return{type:this.type,group:this.group}}static fromJson(a,b,c){c.isBlank=!1;c.group=a.group||"";c.workspaceId=b.id;return c}isNull(){return!1}run(a){}getEventWorkspace_(){let a;this.workspaceId&&(a=getWorkspaceById$$module$build$src$core$common(this.workspaceId));
+if(!a)throw Error("Workspace is null. Event must have been generated from real Blockly events.");return a}},module$build$src$core$events$events_abstract={};module$build$src$core$events$events_abstract.Abstract=Abstract$$module$build$src$core$events$events_abstract;var UiBase$$module$build$src$core$events$events_ui_base=class extends Abstract$$module$build$src$core$events$events_abstract{constructor(a){super();this.recordUndo=!1;this.isUiEvent=!0;this.isBlank="undefined"===typeof a;this.workspaceId=a?a:""}},module$build$src$core$events$events_ui_base={};module$build$src$core$events$events_ui_base.UiBase=UiBase$$module$build$src$core$events$events_ui_base;var Click$$module$build$src$core$events$events_click=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b,c){b=a?a.workspace.id:b;null===b&&(b=void 0);super(b);this.type=CLICK$$module$build$src$core$events$utils;this.blockId=a?a.id:void 0;this.targetType=c}toJson(){const a=super.toJson();if(!this.targetType)throw Error("The click target type is undefined. Either pass a block to the constructor, or call fromJson");a.targetType=this.targetType;a.blockId=this.blockId;return a}static fromJson(a,
+b,c){b=super.fromJson(a,b,null!=c?c:new Click$$module$build$src$core$events$events_click);b.targetType=a.targetType;b.blockId=a.blockId;return b}},ClickTarget$$module$build$src$core$events$events_click;(function(a){a.BLOCK="block";a.WORKSPACE="workspace";a.ZOOM_CONTROLS="zoom_controls"})(ClickTarget$$module$build$src$core$events$events_click||(ClickTarget$$module$build$src$core$events$events_click={}));
+register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,CLICK$$module$build$src$core$events$utils,Click$$module$build$src$core$events$events_click);var module$build$src$core$events$events_click={};module$build$src$core$events$events_click.Click=Click$$module$build$src$core$events$events_click;module$build$src$core$events$events_click.ClickTarget=ClickTarget$$module$build$src$core$events$events_click;var LONGPRESS$$module$build$src$core$touch=750,TOUCH_ENABLED$$module$build$src$core$touch="ontouchstart"in globalThis||!!(globalThis.document&&document.documentElement&&"ontouchstart"in document.documentElement)||!(!globalThis.navigator||!globalThis.navigator.maxTouchPoints&&!globalThis.navigator.msMaxTouchPoints),touchIdentifier_$$module$build$src$core$touch=null,TOUCH_MAP$$module$build$src$core$touch={mousedown:["pointerdown"],mouseenter:["pointerenter"],mouseleave:["pointerleave"],mousemove:["pointermove"],
+mouseout:["pointerout"],mouseover:["pointerover"],mouseup:["pointerup","pointercancel"],touchend:["pointerup"],touchcancel:["pointercancel"]},longPid_$$module$build$src$core$touch=0,module$build$src$core$touch={TOUCH_ENABLED:TOUCH_ENABLED$$module$build$src$core$touch,TOUCH_MAP:TOUCH_MAP$$module$build$src$core$touch};module$build$src$core$touch.checkTouchIdentifier=checkTouchIdentifier$$module$build$src$core$touch;module$build$src$core$touch.clearTouchIdentifier=clearTouchIdentifier$$module$build$src$core$touch;
+module$build$src$core$touch.getTouchIdentifierFromEvent=getTouchIdentifierFromEvent$$module$build$src$core$touch;module$build$src$core$touch.longStart=longStart$$module$build$src$core$touch;module$build$src$core$touch.longStop=longStop$$module$build$src$core$touch;module$build$src$core$touch.shouldHandleEvent=shouldHandleEvent$$module$build$src$core$touch;var rawUserAgent$$module$build$src$core$utils$useragent,isJavaFx$$module$build$src$core$utils$useragent,isWebKit$$module$build$src$core$utils$useragent,isGecko$$module$build$src$core$utils$useragent,isAndroid$$module$build$src$core$utils$useragent,isIPad$$module$build$src$core$utils$useragent,isIPhone$$module$build$src$core$utils$useragent,isMac$$module$build$src$core$utils$useragent,isTablet$$module$build$src$core$utils$useragent,isMobile$$module$build$src$core$utils$useragent;
+(function(a){function b(d){return-1!==c.indexOf(d.toUpperCase())}rawUserAgent$$module$build$src$core$utils$useragent=a;const c=rawUserAgent$$module$build$src$core$utils$useragent.toUpperCase();isJavaFx$$module$build$src$core$utils$useragent=b("JavaFX");isWebKit$$module$build$src$core$utils$useragent=b("WebKit");isGecko$$module$build$src$core$utils$useragent=b("Gecko")&&!isWebKit$$module$build$src$core$utils$useragent;isAndroid$$module$build$src$core$utils$useragent=b("Android");a=globalThis.navigator&&
+globalThis.navigator.maxTouchPoints;isIPad$$module$build$src$core$utils$useragent=b("iPad")||b("Macintosh")&&0{d.push(this.componentData.get(e))});d.sort(function(e,f){return e.weight-f.weight});d.forEach(function(e){c.push(e.component)})}else a.forEach(d=>{c.push(this.componentData.get(d).component)});return c}};ComponentManager$$module$build$src$core$component_manager.Capability=Capability$$module$build$src$core$component_manager;var module$build$src$core$component_manager={};module$build$src$core$component_manager.ComponentManager=ComponentManager$$module$build$src$core$component_manager;var injected$$module$build$src$core$css=!1,content$$module$build$src$core$css='\n.blocklySvg {\n  background-color: #fff;\n  outline: none;\n  overflow: hidden;  /* IE overflows by default. */\n  position: absolute;\n  display: block;\n}\n\n.blocklyWidgetDiv {\n  display: none;\n  position: absolute;\n  z-index: 99999;  /* big value for bootstrap3 compatibility */\n}\n\n.injectionDiv {\n  height: 100%;\n  position: relative;\n  overflow: hidden;  /* So blocks in drag surface disappear at edges */\n  touch-action: none;\n}\n\n.blocklyNonSelectable {\n  user-select: none;\n  -ms-user-select: none;\n  -webkit-user-select: none;\n}\n\n.blocklyBlockCanvas.blocklyCanvasTransitioning,\n.blocklyBubbleCanvas.blocklyCanvasTransitioning {\n  transition: transform .5s;\n}\n\n.blocklyTooltipDiv {\n  background-color: #ffffc7;\n  border: 1px solid #ddc;\n  box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15);\n  color: #000;\n  display: none;\n  font: 9pt sans-serif;\n  opacity: .9;\n  padding: 2px;\n  position: absolute;\n  z-index: 100000;  /* big value for bootstrap3 compatibility */\n}\n\n.blocklyDropDownDiv {\n  position: absolute;\n  left: 0;\n  top: 0;\n  z-index: 1000;\n  display: none;\n  border: 1px solid;\n  border-color: #dadce0;\n  background-color: #fff;\n  border-radius: 2px;\n  padding: 4px;\n  box-shadow: 0 0 3px 1px rgba(0,0,0,.3);\n}\n\n.blocklyDropDownDiv.blocklyFocused {\n  box-shadow: 0 0 6px 1px rgba(0,0,0,.3);\n}\n\n.blocklyDropDownContent {\n  max-height: 300px;  /* @todo: spec for maximum height. */\n  overflow: auto;\n  overflow-x: hidden;\n  position: relative;\n}\n\n.blocklyDropDownArrow {\n  position: absolute;\n  left: 0;\n  top: 0;\n  width: 16px;\n  height: 16px;\n  z-index: -1;\n  background-color: inherit;\n  border-color: inherit;\n}\n\n.blocklyDropDownButton {\n  display: inline-block;\n  float: left;\n  padding: 0;\n  margin: 4px;\n  border-radius: 4px;\n  outline: none;\n  border: 1px solid;\n  transition: box-shadow .1s;\n  cursor: pointer;\n}\n\n.blocklyArrowTop {\n  border-top: 1px solid;\n  border-left: 1px solid;\n  border-top-left-radius: 4px;\n  border-color: inherit;\n}\n\n.blocklyArrowBottom {\n  border-bottom: 1px solid;\n  border-right: 1px solid;\n  border-bottom-right-radius: 4px;\n  border-color: inherit;\n}\n\n.blocklyResizeSE {\n  cursor: se-resize;\n  fill: #aaa;\n}\n\n.blocklyResizeSW {\n  cursor: sw-resize;\n  fill: #aaa;\n}\n\n.blocklyResizeLine {\n  stroke: #515A5A;\n  stroke-width: 1;\n}\n\n.blocklyHighlightedConnectionPath {\n  fill: none;\n  stroke: #fc3;\n  stroke-width: 4px;\n}\n\n.blocklyPathLight {\n  fill: none;\n  stroke-linecap: round;\n  stroke-width: 1;\n}\n\n.blocklySelected>.blocklyPathLight {\n  display: none;\n}\n\n.blocklyDraggable {\n  cursor: grab;\n  cursor: -webkit-grab;\n}\n\n.blocklyDragging {\n  cursor: grabbing;\n  cursor: -webkit-grabbing;\n}\n\n  /* Changes cursor on mouse down. Not effective in Firefox because of\n     https://bugzilla.mozilla.org/show_bug.cgi?id=771241 */\n.blocklyDraggable:active {\n  cursor: grabbing;\n  cursor: -webkit-grabbing;\n}\n\n.blocklyDragging.blocklyDraggingDelete {\n  cursor: url("<<>>/handdelete.cur"), auto;\n}\n\n.blocklyDragging>.blocklyPath,\n.blocklyDragging>.blocklyPathLight {\n  fill-opacity: .8;\n  stroke-opacity: .8;\n}\n\n.blocklyDragging>.blocklyPathDark {\n  display: none;\n}\n\n.blocklyDisabled>.blocklyPath {\n  fill-opacity: .5;\n  stroke-opacity: .5;\n}\n\n.blocklyDisabled>.blocklyPathLight,\n.blocklyDisabled>.blocklyPathDark {\n  display: none;\n}\n\n.blocklyInsertionMarker>.blocklyPath,\n.blocklyInsertionMarker>.blocklyPathLight,\n.blocklyInsertionMarker>.blocklyPathDark {\n  fill-opacity: .2;\n  stroke: none;\n}\n\n.blocklyMultilineText {\n  font-family: monospace;\n}\n\n.blocklyNonEditableText>text {\n  pointer-events: none;\n}\n\n.blocklyFlyout {\n  position: absolute;\n  z-index: 20;\n}\n\n.blocklyText text {\n  cursor: default;\n}\n\n/*\n  Don\'t allow users to select text.  It gets annoying when trying to\n  drag a block and selected text moves instead.\n*/\n.blocklySvg text {\n  user-select: none;\n  -ms-user-select: none;\n  -webkit-user-select: none;\n  cursor: inherit;\n}\n\n.blocklyHidden {\n  display: none;\n}\n\n.blocklyFieldDropdown:not(.blocklyHidden) {\n  display: block;\n}\n\n.blocklyIconGroup {\n  cursor: default;\n}\n\n.blocklyIconGroup:not(:hover),\n.blocklyIconGroupReadonly {\n  opacity: .6;\n}\n\n.blocklyIconShape {\n  fill: #00f;\n  stroke: #fff;\n  stroke-width: 1px;\n}\n\n.blocklyIconSymbol {\n  fill: #fff;\n}\n\n.blocklyMinimalBody {\n  margin: 0;\n  padding: 0;\n}\n\n.blocklyHtmlInput {\n  border: none;\n  border-radius: 4px;\n  height: 100%;\n  margin: 0;\n  outline: none;\n  padding: 0;\n  width: 100%;\n  text-align: center;\n  display: block;\n  box-sizing: border-box;\n}\n\n/* Remove the increase and decrease arrows on the field number editor */\ninput.blocklyHtmlInput[type=number]::-webkit-inner-spin-button,\ninput.blocklyHtmlInput[type=number]::-webkit-outer-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\ninput[type=number] {\n  -moz-appearance: textfield;\n}\n\n.blocklyMainBackground {\n  stroke-width: 1;\n  stroke: #c6c6c6;  /* Equates to #ddd due to border being off-pixel. */\n}\n\n.blocklyMutatorBackground {\n  fill: #fff;\n  stroke: #ddd;\n  stroke-width: 1;\n}\n\n.blocklyFlyoutBackground {\n  fill: #ddd;\n  fill-opacity: .8;\n}\n\n.blocklyMainWorkspaceScrollbar {\n  z-index: 20;\n}\n\n.blocklyFlyoutScrollbar {\n  z-index: 30;\n}\n\n.blocklyScrollbarHorizontal,\n.blocklyScrollbarVertical {\n  position: absolute;\n  outline: none;\n}\n\n.blocklyScrollbarBackground {\n  opacity: 0;\n}\n\n.blocklyScrollbarHandle {\n  fill: #ccc;\n}\n\n.blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,\n.blocklyScrollbarHandle:hover {\n  fill: #bbb;\n}\n\n/* Darken flyout scrollbars due to being on a grey background. */\n/* By contrast, workspace scrollbars are on a white background. */\n.blocklyFlyout .blocklyScrollbarHandle {\n  fill: #bbb;\n}\n\n.blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,\n.blocklyFlyout .blocklyScrollbarHandle:hover {\n  fill: #aaa;\n}\n\n.blocklyInvalidInput {\n  background: #faa;\n}\n\n.blocklyVerticalMarker {\n  stroke-width: 3px;\n  fill: rgba(255,255,255,.5);\n  pointer-events: none;\n}\n\n.blocklyComputeCanvas {\n  position: absolute;\n  width: 0;\n  height: 0;\n}\n\n.blocklyNoPointerEvents {\n  pointer-events: none;\n}\n\n.blocklyContextMenu {\n  border-radius: 4px;\n  max-height: 100%;\n}\n\n.blocklyDropdownMenu {\n  border-radius: 2px;\n  padding: 0 !important;\n}\n\n.blocklyDropdownMenu .blocklyMenuItem {\n  /* 28px on the left for icon or checkbox. */\n  padding-left: 28px;\n}\n\n/* BiDi override for the resting state. */\n.blocklyDropdownMenu .blocklyMenuItemRtl {\n  /* Flip left/right padding for BiDi. */\n  padding-left: 5px;\n  padding-right: 28px;\n}\n\n.blocklyWidgetDiv .blocklyMenu {\n  background: #fff;\n  border: 1px solid transparent;\n  box-shadow: 0 0 3px 1px rgba(0,0,0,.3);\n  font: normal 13px Arial, sans-serif;\n  margin: 0;\n  outline: none;\n  padding: 4px 0;\n  position: absolute;\n  overflow-y: auto;\n  overflow-x: hidden;\n  max-height: 100%;\n  z-index: 20000;  /* Arbitrary, but some apps depend on it... */\n}\n\n.blocklyWidgetDiv .blocklyMenu.blocklyFocused {\n  box-shadow: 0 0 6px 1px rgba(0,0,0,.3);\n}\n\n.blocklyDropDownDiv .blocklyMenu {\n  background: inherit;  /* Compatibility with gapi, reset from goog-menu */\n  border: inherit;  /* Compatibility with gapi, reset from goog-menu */\n  font: normal 13px "Helvetica Neue", Helvetica, sans-serif;\n  outline: none;\n  position: relative;  /* Compatibility with gapi, reset from goog-menu */\n  z-index: 20000;  /* Arbitrary, but some apps depend on it... */\n}\n\n/* State: resting. */\n.blocklyMenuItem {\n  border: none;\n  color: #000;\n  cursor: pointer;\n  list-style: none;\n  margin: 0;\n  /* 7em on the right for shortcut. */\n  min-width: 7em;\n  padding: 6px 15px;\n  white-space: nowrap;\n}\n\n/* State: disabled. */\n.blocklyMenuItemDisabled {\n  color: #ccc;\n  cursor: inherit;\n}\n\n/* State: hover. */\n.blocklyMenuItemHighlight {\n  background-color: rgba(0,0,0,.1);\n}\n\n/* State: selected/checked. */\n.blocklyMenuItemCheckbox {\n  height: 16px;\n  position: absolute;\n  width: 16px;\n}\n\n.blocklyMenuItemSelected .blocklyMenuItemCheckbox {\n  background: url(<<>>/sprites.png) no-repeat -48px -16px;\n  float: left;\n  margin-left: -24px;\n  position: static;  /* Scroll with the menu. */\n}\n\n.blocklyMenuItemRtl .blocklyMenuItemCheckbox {\n  float: right;\n  margin-right: -24px;\n}\n',
+module$build$src$core$css={};module$build$src$core$css.inject=inject$$module$build$src$core$css;module$build$src$core$css.register=register$$module$build$src$core$css;var Coordinate$$module$build$src$core$utils$coordinate=class{constructor(a,b){this.x=a;this.y=b}clone(){return new Coordinate$$module$build$src$core$utils$coordinate(this.x,this.y)}scale(a){this.x*=a;this.y*=a;return this}translate(a,b){this.x+=a;this.y+=b;return this}static equals(a,b){return a===b?!0:a&&b?a.x===b.x&&a.y===b.y:!1}static distance(a,b){const c=a.x-b.x;a=a.y-b.y;return Math.sqrt(c*c+a*a)}static magnitude(a){return Math.sqrt(a.x*a.x+a.y*a.y)}static difference(a,b){return new Coordinate$$module$build$src$core$utils$coordinate(a.x-
+b.x,a.y-b.y)}static sum(a,b){return new Coordinate$$module$build$src$core$utils$coordinate(a.x+b.x,a.y+b.y)}},module$build$src$core$utils$coordinate={};module$build$src$core$utils$coordinate.Coordinate=Coordinate$$module$build$src$core$utils$coordinate;var module$build$src$core$utils$deprecation={};module$build$src$core$utils$deprecation.warn=warn$$module$build$src$core$utils$deprecation;var SVG_NS$$module$build$src$core$utils$dom="http://www.w3.org/2000/svg",HTML_NS$$module$build$src$core$utils$dom="http://www.w3.org/1999/xhtml",XLINK_NS$$module$build$src$core$utils$dom="http://www.w3.org/1999/xlink",NodeType$$module$build$src$core$utils$dom;(function(a){a[a.ELEMENT_NODE=1]="ELEMENT_NODE";a[a.TEXT_NODE=3]="TEXT_NODE";a[a.COMMENT_NODE=8]="COMMENT_NODE"})(NodeType$$module$build$src$core$utils$dom||(NodeType$$module$build$src$core$utils$dom={}));
+var cacheWidths$$module$build$src$core$utils$dom=null,cacheReference$$module$build$src$core$utils$dom=0,canvasContext$$module$build$src$core$utils$dom=null,module$build$src$core$utils$dom={HTML_NS:HTML_NS$$module$build$src$core$utils$dom};module$build$src$core$utils$dom.NodeType=NodeType$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.SVG_NS=SVG_NS$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.XLINK_NS=XLINK_NS$$module$build$src$core$utils$dom;
+module$build$src$core$utils$dom.addClass=addClass$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.containsNode=containsNode$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.createSvgElement=createSvgElement$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.getFastTextWidth=getFastTextWidth$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.getFastTextWidthWithSizeString=getFastTextWidthWithSizeString$$module$build$src$core$utils$dom;
+module$build$src$core$utils$dom.getTextWidth=getTextWidth$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.hasClass=hasClass$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.insertAfter=insertAfter$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.measureFontMetrics=measureFontMetrics$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.removeClass=removeClass$$module$build$src$core$utils$dom;
+module$build$src$core$utils$dom.removeClasses=removeClasses$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.removeNode=removeNode$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.setCssTransform=setCssTransform$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.startTextWidthCache=startTextWidthCache$$module$build$src$core$utils$dom;module$build$src$core$utils$dom.stopTextWidthCache=stopTextWidthCache$$module$build$src$core$utils$dom;var Svg$$module$build$src$core$utils$svg=class{constructor(a){this.tagName=a}toString(){return this.tagName}};Svg$$module$build$src$core$utils$svg.ANIMATE=new Svg$$module$build$src$core$utils$svg("animate");Svg$$module$build$src$core$utils$svg.CIRCLE=new Svg$$module$build$src$core$utils$svg("circle");Svg$$module$build$src$core$utils$svg.CLIPPATH=new Svg$$module$build$src$core$utils$svg("clipPath");Svg$$module$build$src$core$utils$svg.DEFS=new Svg$$module$build$src$core$utils$svg("defs");
+Svg$$module$build$src$core$utils$svg.FECOMPOSITE=new Svg$$module$build$src$core$utils$svg("feComposite");Svg$$module$build$src$core$utils$svg.FECOMPONENTTRANSFER=new Svg$$module$build$src$core$utils$svg("feComponentTransfer");Svg$$module$build$src$core$utils$svg.FEFLOOD=new Svg$$module$build$src$core$utils$svg("feFlood");Svg$$module$build$src$core$utils$svg.FEFUNCA=new Svg$$module$build$src$core$utils$svg("feFuncA");Svg$$module$build$src$core$utils$svg.FEGAUSSIANBLUR=new Svg$$module$build$src$core$utils$svg("feGaussianBlur");
+Svg$$module$build$src$core$utils$svg.FEPOINTLIGHT=new Svg$$module$build$src$core$utils$svg("fePointLight");Svg$$module$build$src$core$utils$svg.FESPECULARLIGHTING=new Svg$$module$build$src$core$utils$svg("feSpecularLighting");Svg$$module$build$src$core$utils$svg.FILTER=new Svg$$module$build$src$core$utils$svg("filter");Svg$$module$build$src$core$utils$svg.FOREIGNOBJECT=new Svg$$module$build$src$core$utils$svg("foreignObject");Svg$$module$build$src$core$utils$svg.G=new Svg$$module$build$src$core$utils$svg("g");
+Svg$$module$build$src$core$utils$svg.IMAGE=new Svg$$module$build$src$core$utils$svg("image");Svg$$module$build$src$core$utils$svg.LINE=new Svg$$module$build$src$core$utils$svg("line");Svg$$module$build$src$core$utils$svg.PATH=new Svg$$module$build$src$core$utils$svg("path");Svg$$module$build$src$core$utils$svg.PATTERN=new Svg$$module$build$src$core$utils$svg("pattern");Svg$$module$build$src$core$utils$svg.POLYGON=new Svg$$module$build$src$core$utils$svg("polygon");
+Svg$$module$build$src$core$utils$svg.RECT=new Svg$$module$build$src$core$utils$svg("rect");Svg$$module$build$src$core$utils$svg.SVG=new Svg$$module$build$src$core$utils$svg("svg");Svg$$module$build$src$core$utils$svg.TEXT=new Svg$$module$build$src$core$utils$svg("text");Svg$$module$build$src$core$utils$svg.TSPAN=new Svg$$module$build$src$core$utils$svg("tspan");var module$build$src$core$utils$svg={};module$build$src$core$utils$svg.Svg=Svg$$module$build$src$core$utils$svg;var Rect$$module$build$src$core$utils$rect=class{constructor(a,b,c,d){this.top=a;this.bottom=b;this.left=c;this.right=d}getHeight(){return this.bottom-this.top}getWidth(){return this.right-this.left}contains(a,b){return a>=this.left&&a<=this.right&&b>=this.top&&b<=this.bottom}intersects(a){return!(this.left>a.right||this.righta.bottom||this.bottom=a||isNaN(a)?0:Math.min(a,this.scrollbarLength)}setHandleLength(a){this.handleLength=a;this.svgHandle.setAttribute(this.lengthAttribute_,String(this.handleLength))}constrainHandlePosition(a){return a=0>=a||isNaN(a)?0:Math.min(a,this.scrollbarLength-this.handleLength)}setHandlePosition(a){this.handlePosition=a;this.svgHandle.setAttribute(this.positionAttribute_,
+String(this.handlePosition))}setScrollbarLength(a){this.scrollbarLength=a;this.outerSvg.setAttribute(this.lengthAttribute_,String(this.scrollbarLength));this.svgBackground.setAttribute(this.lengthAttribute_,String(this.scrollbarLength))}setPosition(a,b){this.position.x=a;this.position.y=b;setCssTransform$$module$build$src$core$utils$dom(this.outerSvg,"translate("+(this.position.x+this.origin.x)+"px,"+(this.position.y+this.origin.y)+"px)")}resize(a){if(!a&&(a=this.workspace.getMetrics(),!a))return;
+this.oldHostMetrics&&Scrollbar$$module$build$src$core$scrollbar.metricsAreEquivalent(a,this.oldHostMetrics)||(this.horizontal?this.resizeHorizontal(a):this.resizeVertical(a),this.oldHostMetrics=a,this.updateMetrics())}requiresViewResize(a){return this.oldHostMetrics?this.oldHostMetrics.viewWidth!==a.viewWidth||this.oldHostMetrics.viewHeight!==a.viewHeight||this.oldHostMetrics.absoluteLeft!==a.absoluteLeft||this.oldHostMetrics.absoluteTop!==a.absoluteTop:!0}resizeHorizontal(a){this.requiresViewResize(a)?
+this.resizeViewHorizontal(a):this.resizeContentHorizontal(a)}resizeViewHorizontal(a){var b=a.viewWidth-2*this.margin;this.pair&&(b-=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness);this.setScrollbarLength(Math.max(0,b));b=a.absoluteLeft+this.margin;this.pair&&this.workspace.RTL&&(b+=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness);this.setPosition(b,a.absoluteTop+a.viewHeight-Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness-this.margin);this.resizeContentHorizontal(a)}resizeContentHorizontal(a){if(a.viewWidth>=
+a.scrollWidth)this.setHandleLength(this.scrollbarLength),this.setHandlePosition(0),this.pair||this.setVisible(!1);else{this.pair||this.setVisible(!0);var b=this.scrollbarLength*a.viewWidth/a.scrollWidth;b=this.constrainHandleLength(b);this.setHandleLength(b);b=a.scrollWidth-a.viewWidth;var c=this.scrollbarLength-this.handleLength;a=(a.viewLeft-a.scrollLeft)/b*c;a=this.constrainHandlePosition(a);this.setHandlePosition(a);this.ratio=c/b}}resizeVertical(a){this.requiresViewResize(a)?this.resizeViewVertical(a):
+this.resizeContentVertical(a)}resizeViewVertical(a){let b=a.viewHeight-2*this.margin;this.pair&&(b-=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness);this.setScrollbarLength(Math.max(0,b));this.setPosition(this.workspace.RTL?a.absoluteLeft+this.margin:a.absoluteLeft+a.viewWidth-Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness-this.margin,a.absoluteTop+this.margin);this.resizeContentVertical(a)}resizeContentVertical(a){if(a.viewHeight>=a.scrollHeight)this.setHandleLength(this.scrollbarLength),
+this.setHandlePosition(0),this.pair||this.setVisible(!1);else{this.pair||this.setVisible(!0);var b=this.scrollbarLength*a.viewHeight/a.scrollHeight;b=this.constrainHandleLength(b);this.setHandleLength(b);b=a.scrollHeight-a.viewHeight;var c=this.scrollbarLength-this.handleLength;a=(a.viewTop-a.scrollTop)/b*c;a=this.constrainHandlePosition(a);this.setHandlePosition(a);this.ratio=c/b}}isVisible(){return this.isHandleVisible}setContainerVisible(a){const b=a!==this.containerVisible;this.containerVisible=
+a;b&&this.updateDisplay_()}setVisible(a){if(this.pair)throw Error("Unable to toggle visibility of paired scrollbars.");this.setVisibleInternal(a)}setVisibleInternal(a){const b=a!==this.isVisible();this.isHandleVisible=a;b&&this.updateDisplay_()}updateDisplay_(){this.containerVisible&&this.isVisible()?this.outerSvg.setAttribute("display","block"):this.outerSvg.setAttribute("display","none")}onMouseDownBar(a){this.workspace.markFocused();clearTouchIdentifier$$module$build$src$core$touch();this.cleanUp();
+if(isRightButton$$module$build$src$core$browser_events(a))a.stopPropagation();else{var b=mouseToSvg$$module$build$src$core$browser_events(a,this.workspace.getParentSvg(),this.workspace.getInverseScreenCTM());b=this.horizontal?b.x:b.y;var c=getInjectionDivXY$$module$build$src$core$utils$svg_math(this.svgHandle);c=this.horizontal?c.x:c.y;var d=this.handlePosition,e=.95*this.handleLength;b<=c?d-=e:b>=c+this.handleLength&&(d+=e);this.setHandlePosition(this.constrainHandlePosition(d));this.updateMetrics();
+a.stopPropagation();a.preventDefault()}}onMouseDownHandle(a){this.workspace.markFocused();this.cleanUp();isRightButton$$module$build$src$core$browser_events(a)?a.stopPropagation():(this.startDragHandle=this.handlePosition,this.startDragMouse=this.horizontal?a.clientX:a.clientY,this.onMouseUpWrapper_=conditionalBind$$module$build$src$core$browser_events(document,"pointerup",this,this.onMouseUpHandle),this.onMouseMoveWrapper_=conditionalBind$$module$build$src$core$browser_events(document,"pointermove",
+this,this.onMouseMoveHandle),a.stopPropagation(),a.preventDefault())}onMouseMoveHandle(a){this.setHandlePosition(this.constrainHandlePosition(this.startDragHandle+((this.horizontal?a.clientX:a.clientY)-this.startDragMouse)));this.updateMetrics()}onMouseUpHandle(){clearTouchIdentifier$$module$build$src$core$touch();this.cleanUp()}cleanUp(){this.workspace.hideChaff(!0);this.onMouseUpWrapper_&&(unbind$$module$build$src$core$browser_events(this.onMouseUpWrapper_),this.onMouseUpWrapper_=null);this.onMouseMoveWrapper_&&
+(unbind$$module$build$src$core$browser_events(this.onMouseMoveWrapper_),this.onMouseMoveWrapper_=null)}getRatio_(){let a=this.handlePosition/(this.scrollbarLength-this.handleLength);isNaN(a)&&(a=0);return a}updateMetrics(){const a=this.getRatio_();this.horizontal?this.workspace.setMetrics({x:a}):this.workspace.setMetrics({y:a})}set(a,b){this.setHandlePosition(this.constrainHandlePosition(a*this.ratio));(b||void 0===b)&&this.updateMetrics()}setOrigin(a,b){this.origin=new Coordinate$$module$build$src$core$utils$coordinate(a,
+b)}static metricsAreEquivalent(a,b){return a.viewWidth===b.viewWidth&&a.viewHeight===b.viewHeight&&a.viewLeft===b.viewLeft&&a.viewTop===b.viewTop&&a.absoluteTop===b.absoluteTop&&a.absoluteLeft===b.absoluteLeft&&a.scrollWidth===b.scrollWidth&&a.scrollHeight===b.scrollHeight&&a.scrollLeft===b.scrollLeft&&a.scrollTop===b.scrollTop}};Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness=TOUCH_ENABLED$$module$build$src$core$touch?25:15;
+Scrollbar$$module$build$src$core$scrollbar.DEFAULT_SCROLLBAR_MARGIN=.5;var module$build$src$core$scrollbar={};module$build$src$core$scrollbar.Scrollbar=Scrollbar$$module$build$src$core$scrollbar;var domParser$$module$build$src$core$utils$xml={parseFromString:function(){throw Error("DOMParser was not found in the global scope and was not properly injected using injectDependencies");}},xmlSerializer$$module$build$src$core$utils$xml={serializeToString:function(){throw Error("XMLSerializer was not foundin the global scope and was not properly injected using injectDependencies");}},{document:document$$module$build$src$core$utils$xml,DOMParser:DOMParser$$module$build$src$core$utils$xml,XMLSerializer:XMLSerializer$$module$build$src$core$utils$xml}=
+globalThis;DOMParser$$module$build$src$core$utils$xml&&(domParser$$module$build$src$core$utils$xml=new DOMParser$$module$build$src$core$utils$xml);XMLSerializer$$module$build$src$core$utils$xml&&(xmlSerializer$$module$build$src$core$utils$xml=new XMLSerializer$$module$build$src$core$utils$xml);
+var NAME_SPACE$$module$build$src$core$utils$xml="https://developers.google.com/blockly/xml",INVALID_CONTROL_CHARS$$module$build$src$core$utils$xml=/[\x00-\x09\x0B\x0C\x0E-\x1F]/g,module$build$src$core$utils$xml={NAME_SPACE:NAME_SPACE$$module$build$src$core$utils$xml};module$build$src$core$utils$xml.createElement=$.createElement$$module$build$src$core$utils$xml;module$build$src$core$utils$xml.createTextNode=$.createTextNode$$module$build$src$core$utils$xml;
+module$build$src$core$utils$xml.domToText=domToText$$module$build$src$core$utils$xml;module$build$src$core$utils$xml.injectDependencies=injectDependencies$$module$build$src$core$utils$xml;module$build$src$core$utils$xml.textToDom=$.textToDom$$module$build$src$core$utils$xml;var CATEGORY_TOOLBOX_KIND$$module$build$src$core$utils$toolbox="categoryToolbox",FLYOUT_TOOLBOX_KIND$$module$build$src$core$utils$toolbox="flyoutToolbox",Position$$module$build$src$core$utils$toolbox;(function(a){a[a.TOP=0]="TOP";a[a.BOTTOM=1]="BOTTOM";a[a.LEFT=2]="LEFT";a[a.RIGHT=3]="RIGHT"})(Position$$module$build$src$core$utils$toolbox||(Position$$module$build$src$core$utils$toolbox={}));
+var TEST_ONLY$$module$build$src$core$utils$toolbox={hasCategoriesInternal:hasCategoriesInternal$$module$build$src$core$utils$toolbox},module$build$src$core$utils$toolbox={};module$build$src$core$utils$toolbox.Position=Position$$module$build$src$core$utils$toolbox;module$build$src$core$utils$toolbox.TEST_ONLY=TEST_ONLY$$module$build$src$core$utils$toolbox;module$build$src$core$utils$toolbox.convertFlyoutDefToJsonArray=convertFlyoutDefToJsonArray$$module$build$src$core$utils$toolbox;
+module$build$src$core$utils$toolbox.convertToolboxDefToJson=convertToolboxDefToJson$$module$build$src$core$utils$toolbox;module$build$src$core$utils$toolbox.hasCategories=hasCategories$$module$build$src$core$utils$toolbox;module$build$src$core$utils$toolbox.isCategoryCollapsible=isCategoryCollapsible$$module$build$src$core$utils$toolbox;module$build$src$core$utils$toolbox.parseToolboxTree=parseToolboxTree$$module$build$src$core$utils$toolbox;var verticalPosition$$module$build$src$core$positionable_helpers;(function(a){a[a.TOP=0]="TOP";a[a.BOTTOM=1]="BOTTOM"})(verticalPosition$$module$build$src$core$positionable_helpers||(verticalPosition$$module$build$src$core$positionable_helpers={}));var horizontalPosition$$module$build$src$core$positionable_helpers;(function(a){a[a.LEFT=0]="LEFT";a[a.RIGHT=1]="RIGHT"})(horizontalPosition$$module$build$src$core$positionable_helpers||(horizontalPosition$$module$build$src$core$positionable_helpers={}));
+var bumpDirection$$module$build$src$core$positionable_helpers;(function(a){a[a.UP=0]="UP";a[a.DOWN=1]="DOWN"})(bumpDirection$$module$build$src$core$positionable_helpers||(bumpDirection$$module$build$src$core$positionable_helpers={}));var module$build$src$core$positionable_helpers={};module$build$src$core$positionable_helpers.bumpDirection=bumpDirection$$module$build$src$core$positionable_helpers;module$build$src$core$positionable_helpers.bumpPositionRect=bumpPositionRect$$module$build$src$core$positionable_helpers;
+module$build$src$core$positionable_helpers.getCornerOppositeToolbox=getCornerOppositeToolbox$$module$build$src$core$positionable_helpers;module$build$src$core$positionable_helpers.getStartPositionRect=getStartPositionRect$$module$build$src$core$positionable_helpers;module$build$src$core$positionable_helpers.horizontalPosition=horizontalPosition$$module$build$src$core$positionable_helpers;module$build$src$core$positionable_helpers.verticalPosition=verticalPosition$$module$build$src$core$positionable_helpers;var SPRITE$$module$build$src$core$sprites={width:96,height:124,url:"sprites.png"},module$build$src$core$sprites={SPRITE:SPRITE$$module$build$src$core$sprites};var ZoomControls$$module$build$src$core$zoom_controls=class{constructor(a){this.workspace=a;this.id="zoomControls";this.boundEvents=[];this.zoomResetGroup=this.zoomOutGroup=this.zoomInGroup=null;this.HEIGHT=this.WIDTH=32;this.SMALL_SPACING=2;this.LARGE_SPACING=11;this.MARGIN_HORIZONTAL=this.MARGIN_VERTICAL=20;this.svgGroup=null;this.top=this.left=0;this.initialized=!1}createDom(){this.svgGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{});const a=String(Math.random()).substring(2);
+this.createZoomOutSvg(a);this.createZoomInSvg(a);this.workspace.isMovable()&&this.createZoomResetSvg(a);return this.svgGroup}init(){this.workspace.getComponentManager().addComponent({component:this,weight:2,capabilities:[ComponentManager$$module$build$src$core$component_manager.Capability.POSITIONABLE]});this.initialized=!0}dispose(){this.workspace.getComponentManager().removeComponent("zoomControls");this.svgGroup&&removeNode$$module$build$src$core$utils$dom(this.svgGroup);for(const a of this.boundEvents)unbind$$module$build$src$core$browser_events(a);
+this.boundEvents.length=0}getBoundingRectangle(){let a=this.SMALL_SPACING+2*this.HEIGHT;this.zoomResetGroup&&(a+=this.LARGE_SPACING+this.HEIGHT);return new Rect$$module$build$src$core$utils$rect(this.top,this.top+a,this.left,this.left+this.WIDTH)}position(a,b){if(this.initialized){var c=getCornerOppositeToolbox$$module$build$src$core$positionable_helpers(this.workspace,a),d=this.SMALL_SPACING+2*this.HEIGHT;this.zoomResetGroup&&(d+=this.LARGE_SPACING+this.HEIGHT);a=getStartPositionRect$$module$build$src$core$positionable_helpers(c,
+new Size$$module$build$src$core$utils$size(this.WIDTH,d),this.MARGIN_HORIZONTAL,this.MARGIN_VERTICAL,a,this.workspace);c=c.vertical;b=bumpPositionRect$$module$build$src$core$positionable_helpers(a,this.MARGIN_VERTICAL,c===verticalPosition$$module$build$src$core$positionable_helpers.TOP?bumpDirection$$module$build$src$core$positionable_helpers.DOWN:bumpDirection$$module$build$src$core$positionable_helpers.UP,b);if(c===verticalPosition$$module$build$src$core$positionable_helpers.TOP){var e=this.SMALL_SPACING+
+this.HEIGHT,f;null==(f=this.zoomInGroup)||f.setAttribute("transform","translate(0, "+e+")");this.zoomResetGroup&&this.zoomResetGroup.setAttribute("transform","translate(0, "+(e+this.LARGE_SPACING+this.HEIGHT)+")")}else{f=this.zoomResetGroup?this.LARGE_SPACING+this.HEIGHT:0;let h;null==(h=this.zoomInGroup)||h.setAttribute("transform","translate(0, "+f+")");f=f+this.SMALL_SPACING+this.HEIGHT;null==(e=this.zoomOutGroup)||e.setAttribute("transform","translate(0, "+f+")")}this.top=b.top;this.left=b.left;
+var g;null==(g=this.svgGroup)||g.setAttribute("transform","translate("+this.left+","+this.top+")")}}createZoomOutSvg(a){this.zoomOutGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":"blocklyZoom blocklyZoomOut"},this.svgGroup);const b=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CLIPPATH,{id:"blocklyZoomoutClipPath"+a},this.zoomOutGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,
+{width:32,height:32},b);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.IMAGE,{width:SPRITE$$module$build$src$core$sprites.width,height:SPRITE$$module$build$src$core$sprites.height,x:-64,y:-92,"clip-path":"url(#blocklyZoomoutClipPath"+a+")"},this.zoomOutGroup).setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.workspace.options.pathToMedia+SPRITE$$module$build$src$core$sprites.url);this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(this.zoomOutGroup,
+"pointerdown",null,this.zoom.bind(this,-1)))}createZoomInSvg(a){this.zoomInGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":"blocklyZoom blocklyZoomIn"},this.svgGroup);const b=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CLIPPATH,{id:"blocklyZoominClipPath"+a},this.zoomInGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{width:32,height:32},b);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.IMAGE,
+{width:SPRITE$$module$build$src$core$sprites.width,height:SPRITE$$module$build$src$core$sprites.height,x:-32,y:-92,"clip-path":"url(#blocklyZoominClipPath"+a+")"},this.zoomInGroup).setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.workspace.options.pathToMedia+SPRITE$$module$build$src$core$sprites.url);this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(this.zoomInGroup,"pointerdown",null,this.zoom.bind(this,1)))}zoom(a,b){this.workspace.markFocused();
+this.workspace.zoomCenter(a);this.fireZoomEvent();clearTouchIdentifier$$module$build$src$core$touch();b.stopPropagation();b.preventDefault()}createZoomResetSvg(a){this.zoomResetGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":"blocklyZoom blocklyZoomReset"},this.svgGroup);const b=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CLIPPATH,{id:"blocklyZoomresetClipPath"+a},this.zoomResetGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,
+{width:32,height:32},b);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.IMAGE,{width:SPRITE$$module$build$src$core$sprites.width,height:SPRITE$$module$build$src$core$sprites.height,y:-92,"clip-path":"url(#blocklyZoomresetClipPath"+a+")"},this.zoomResetGroup).setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.workspace.options.pathToMedia+SPRITE$$module$build$src$core$sprites.url);this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(this.zoomResetGroup,
+"pointerdown",null,this.resetZoom.bind(this)))}resetZoom(a){this.workspace.markFocused();const b=Math.log(this.workspace.options.zoomOptions.startScale/this.workspace.scale)/Math.log(this.workspace.options.zoomOptions.scaleSpeed);this.workspace.beginCanvasTransition();this.workspace.zoomCenter(b);this.workspace.scrollCenter();setTimeout(this.workspace.endCanvasTransition.bind(this.workspace),500);this.fireZoomEvent();clearTouchIdentifier$$module$build$src$core$touch();a.stopPropagation();a.preventDefault()}fireZoomEvent(){const a=
+new (get$$module$build$src$core$events$utils(CLICK$$module$build$src$core$events$utils))(null,this.workspace.id,"zoom_controls");fire$$module$build$src$core$events$utils(a)}};register$$module$build$src$core$css("\n.blocklyZoom>image, .blocklyZoom>svg>image {\n  opacity: .4;\n}\n\n.blocklyZoom>image:hover, .blocklyZoom>svg>image:hover {\n  opacity: .6;\n}\n\n.blocklyZoom>image:active, .blocklyZoom>svg>image:active {\n  opacity: .8;\n}\n");var module$build$src$core$zoom_controls={};
+module$build$src$core$zoom_controls.ZoomControls=ZoomControls$$module$build$src$core$zoom_controls;var IconType$$module$build$src$core$icons$icon_types=class{constructor(a){this.name=a}toString(){return this.name}equals(a){return this.name===a.toString()}};IconType$$module$build$src$core$icons$icon_types.MUTATOR=new IconType$$module$build$src$core$icons$icon_types("mutator");IconType$$module$build$src$core$icons$icon_types.WARNING=new IconType$$module$build$src$core$icons$icon_types("warning");IconType$$module$build$src$core$icons$icon_types.COMMENT=new IconType$$module$build$src$core$icons$icon_types("comment");
+var module$build$src$core$icons$icon_types={};module$build$src$core$icons$icon_types.IconType=IconType$$module$build$src$core$icons$icon_types;(function(a){a[a.VALUE=1]="VALUE";a[a.STATEMENT=3]="STATEMENT";a[a.DUMMY=5]="DUMMY";a[a.CUSTOM=6]="CUSTOM";a[a.END_ROW=7]="END_ROW"})($.inputTypes$$module$build$src$core$inputs$input_types||($.inputTypes$$module$build$src$core$inputs$input_types={}));var module$build$src$core$inputs$input_types={};module$build$src$core$inputs$input_types.inputTypes=$.inputTypes$$module$build$src$core$inputs$input_types;var alertImplementation$$module$build$src$core$dialog=function(a,b){window.alert(a);b&&b()},confirmImplementation$$module$build$src$core$dialog=function(a,b){b(window.confirm(a))},promptImplementation$$module$build$src$core$dialog=function(a,b,c){c(window.prompt(a,b))},TEST_ONLY$$module$build$src$core$dialog={confirmInternal:confirmInternal$$module$build$src$core$dialog},module$build$src$core$dialog={TEST_ONLY:TEST_ONLY$$module$build$src$core$dialog};module$build$src$core$dialog.alert=alert$$module$build$src$core$dialog;
+module$build$src$core$dialog.confirm=confirm$$module$build$src$core$dialog;module$build$src$core$dialog.prompt=prompt$$module$build$src$core$dialog;module$build$src$core$dialog.setAlert=setAlert$$module$build$src$core$dialog;module$build$src$core$dialog.setConfirm=setConfirm$$module$build$src$core$dialog;module$build$src$core$dialog.setPrompt=setPrompt$$module$build$src$core$dialog;var module$build$src$core$interfaces$i_variable_backed_parameter_model={};module$build$src$core$interfaces$i_variable_backed_parameter_model.isVariableBackedParameterModel=isVariableBackedParameterModel$$module$build$src$core$interfaces$i_variable_backed_parameter_model;var setLocale$$module$build$src$core$msg,module$build$src$core$msg;$.Msg$$module$build$src$core$msg=Object.create(null);setLocale$$module$build$src$core$msg=function(a){Object.keys(a).forEach(function(b){$.Msg$$module$build$src$core$msg[b]=a[b]})};module$build$src$core$msg={Msg:$.Msg$$module$build$src$core$msg,setLocale:setLocale$$module$build$src$core$msg};var module$build$src$core$interfaces$i_legacy_procedure_blocks={};module$build$src$core$interfaces$i_legacy_procedure_blocks.isLegacyProcedureCallBlock=isLegacyProcedureCallBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks;module$build$src$core$interfaces$i_legacy_procedure_blocks.isLegacyProcedureDefBlock=isLegacyProcedureDefBlock$$module$build$src$core$interfaces$i_legacy_procedure_blocks;var VarBase$$module$build$src$core$events$events_var_base=class extends Abstract$$module$build$src$core$events$events_abstract{constructor(a){super();this.isBlank="undefined"===typeof a;a&&(this.varId=a.getId(),this.workspaceId=a.workspace.id)}toJson(){const a=super.toJson();if(!this.varId)throw Error("The var ID is undefined. Either pass a variable to the constructor, or call fromJson");a.varId=this.varId;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new VarBase$$module$build$src$core$events$events_var_base);
+b.varId=a.varId;return b}},module$build$src$core$events$events_var_base={};module$build$src$core$events$events_var_base.VarBase=VarBase$$module$build$src$core$events$events_var_base;var VarCreate$$module$build$src$core$events$events_var_create=class extends VarBase$$module$build$src$core$events$events_var_base{constructor(a){super(a);this.type=VAR_CREATE$$module$build$src$core$events$utils;a&&(this.varType=a.type,this.varName=a.name)}toJson(){const a=super.toJson();if(void 0===this.varType)throw Error("The var type is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.varName)throw Error("The var name is undefined. Either pass a variable to the constructor, or call fromJson");
+a.varType=this.varType;a.varName=this.varName;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new VarCreate$$module$build$src$core$events$events_var_create);b.varType=a.varType;b.varName=a.varName;return b}run(a){const b=this.getEventWorkspace_();if(!this.varId)throw Error("The var ID is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.varName)throw Error("The var name is undefined. Either pass a variable to the constructor, or call fromJson");a?b.createVariable(this.varName,
+this.varType,this.varId):b.deleteVariableById(this.varId)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,VAR_CREATE$$module$build$src$core$events$utils,VarCreate$$module$build$src$core$events$events_var_create);var module$build$src$core$events$events_var_create={};module$build$src$core$events$events_var_create.VarCreate=VarCreate$$module$build$src$core$events$events_var_create;var VariableModel$$module$build$src$core$variable_model=class{constructor(a,b,c,d){this.workspace=a;this.name=b;this.type=c||"";this.id_=d||genUid$$module$build$src$core$utils$idgenerator()}getId(){return this.id_}static compareByName(a,b){return a.name.localeCompare(b.name,void 0,{sensitivity:"base"})}},module$build$src$core$variable_model={};module$build$src$core$variable_model.VariableModel=VariableModel$$module$build$src$core$variable_model;var CATEGORY_NAME$$module$build$src$core$variables="VARIABLE",VAR_LETTER_OPTIONS$$module$build$src$core$variables="ijkmnopqrstuvwxyzabcdefgh",TEST_ONLY$$module$build$src$core$variables={generateUniqueNameInternal:generateUniqueNameInternal$$module$build$src$core$variables},module$build$src$core$variables={CATEGORY_NAME:CATEGORY_NAME$$module$build$src$core$variables,TEST_ONLY:TEST_ONLY$$module$build$src$core$variables,VAR_LETTER_OPTIONS:VAR_LETTER_OPTIONS$$module$build$src$core$variables};
+module$build$src$core$variables.allDeveloperVariables=$.allDeveloperVariables$$module$build$src$core$variables;module$build$src$core$variables.allUsedVarModels=$.allUsedVarModels$$module$build$src$core$variables;module$build$src$core$variables.createVariableButtonHandler=createVariableButtonHandler$$module$build$src$core$variables;module$build$src$core$variables.flyoutCategory=flyoutCategory$$module$build$src$core$variables;module$build$src$core$variables.flyoutCategoryBlocks=flyoutCategoryBlocks$$module$build$src$core$variables;
+module$build$src$core$variables.generateUniqueName=generateUniqueName$$module$build$src$core$variables;module$build$src$core$variables.generateUniqueNameFromOptions=generateUniqueNameFromOptions$$module$build$src$core$variables;module$build$src$core$variables.generateVariableFieldDom=generateVariableFieldDom$$module$build$src$core$variables;module$build$src$core$variables.getAddedVariables=getAddedVariables$$module$build$src$core$variables;
+module$build$src$core$variables.getOrCreateVariablePackage=$.getOrCreateVariablePackage$$module$build$src$core$variables;module$build$src$core$variables.getVariable=$.getVariable$$module$build$src$core$variables;module$build$src$core$variables.nameUsedWithAnyType=nameUsedWithAnyType$$module$build$src$core$variables;module$build$src$core$variables.nameUsedWithConflictingParam=nameUsedWithConflictingParam$$module$build$src$core$variables;module$build$src$core$variables.promptName=promptName$$module$build$src$core$variables;
+module$build$src$core$variables.renameVariable=$.renameVariable$$module$build$src$core$variables;var WorkspaceComment$$module$build$src$core$workspace_comment=class{constructor(a,b,c,d,e){this.workspace=a;this.editable=this.movable=this.deletable=!0;this.disposed_=!1;this.isComment=!0;this.id=e&&!a.getCommentById(e)?e:genUid$$module$build$src$core$utils$idgenerator();a.addTopComment(this);this.xy_=new Coordinate$$module$build$src$core$utils$coordinate(0,0);this.height_=c;this.width_=d;this.RTL=a.RTL;this.content_=b;WorkspaceComment$$module$build$src$core$workspace_comment.fireCreateEvent(this)}dispose(){this.disposed_||
+(isEnabled$$module$build$src$core$events$utils()&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(COMMENT_DELETE$$module$build$src$core$events$utils))(this)),this.workspace.removeTopComment(this),this.disposed_=!0)}getHeight(){return this.height_}setHeight(a){this.height_=a}getWidth(){return this.width_}setWidth(a){this.width_=a}getRelativeToSurfaceXY(){return new Coordinate$$module$build$src$core$utils$coordinate(this.xy_.x,this.xy_.y)}moveBy(a,b){const c=new (get$$module$build$src$core$events$utils(COMMENT_MOVE$$module$build$src$core$events$utils))(this);
+this.xy_.translate(a,b);c.recordNew();fire$$module$build$src$core$events$utils(c)}isDeletable(){return this.deletable&&!(this.workspace&&this.workspace.options.readOnly)}setDeletable(a){this.deletable=a}isMovable(){return this.movable&&!(this.workspace&&this.workspace.options.readOnly)}setMovable(a){this.movable=a}isEditable(){return this.editable&&!(this.workspace&&this.workspace.options.readOnly)}setEditable(a){this.editable=a}getContent(){return this.content_}setContent(a){this.content_!==a&&(fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(COMMENT_CHANGE$$module$build$src$core$events$utils))(this,
+this.content_,a)),this.content_=a)}toXmlWithXY(a){a=this.toXml(a);a.setAttribute("x",String(Math.round(this.xy_.x)));a.setAttribute("y",String(Math.round(this.xy_.y)));a.setAttribute("h",String(this.height_));a.setAttribute("w",String(this.width_));return a}toXml(a){const b=$.createElement$$module$build$src$core$utils$xml("comment");a||(b.id=this.id);b.textContent=this.getContent();return b}static fireCreateEvent(a){if(isEnabled$$module$build$src$core$events$utils()){const b=$.getGroup$$module$build$src$core$events$utils();
+b||$.setGroup$$module$build$src$core$events$utils(!0);try{fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(COMMENT_CREATE$$module$build$src$core$events$utils))(a))}finally{$.setGroup$$module$build$src$core$events$utils(b)}}}static fromXml(a,b){var c=WorkspaceComment$$module$build$src$core$workspace_comment.parseAttributes(a);b=new WorkspaceComment$$module$build$src$core$workspace_comment(b,c.content,c.h,c.w,c.id);c=a.getAttribute("x");a=a.getAttribute("y");c=c?
+parseInt(c,10):NaN;a=a?parseInt(a,10):NaN;isNaN(c)||isNaN(a)||b.moveBy(c,a);WorkspaceComment$$module$build$src$core$workspace_comment.fireCreateEvent(b);return b}static parseAttributes(a){const b=a.getAttribute("h"),c=a.getAttribute("w"),d=a.getAttribute("x"),e=a.getAttribute("y"),f=a.getAttribute("id");if(!f)throw Error("No ID present in XML comment definition.");let g;return{id:f,h:b?parseInt(b):100,w:c?parseInt(c):100,x:d?parseInt(d):NaN,y:e?parseInt(e):NaN,content:null!=(g=a.textContent)?g:""}}},
+module$build$src$core$workspace_comment={};module$build$src$core$workspace_comment.WorkspaceComment=WorkspaceComment$$module$build$src$core$workspace_comment;var Selected$$module$build$src$core$events$events_selected=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b,c){super(c);this.type=SELECTED$$module$build$src$core$events$utils;this.oldElementId=null!=a?a:void 0;this.newElementId=null!=b?b:void 0}toJson(){const a=super.toJson();a.oldElementId=this.oldElementId;a.newElementId=this.newElementId;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new Selected$$module$build$src$core$events$events_selected);b.oldElementId=
+a.oldElementId;b.newElementId=a.newElementId;return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,SELECTED$$module$build$src$core$events$utils,Selected$$module$build$src$core$events$events_selected);var module$build$src$core$events$events_selected={};module$build$src$core$events$events_selected.Selected=Selected$$module$build$src$core$events$events_selected;var module$build$src$core$clipboard$registry={};module$build$src$core$clipboard$registry.register=register$$module$build$src$core$clipboard$registry;module$build$src$core$clipboard$registry.unregister=unregister$$module$build$src$core$clipboard$registry;var WorkspaceCommentPaster$$module$build$src$core$clipboard$workspace_comment_paster=class{paste(a,b,c){const d=a.commentState;if(c)d.setAttribute("x",`${c.x}`),d.setAttribute("y",`${c.y}`);else{var e;c=parseInt(null!=(e=d.getAttribute("x"))?e:"0")+50;let f;e=parseInt(null!=(f=d.getAttribute("y"))?f:"0")+50;d.setAttribute("x",`${c}`);d.setAttribute("y",`${e}`)}return WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.fromXmlRendered(a.commentState,b)}};
+WorkspaceCommentPaster$$module$build$src$core$clipboard$workspace_comment_paster.TYPE="workspace-comment";register$$module$build$src$core$clipboard$registry(WorkspaceCommentPaster$$module$build$src$core$clipboard$workspace_comment_paster.TYPE,new WorkspaceCommentPaster$$module$build$src$core$clipboard$workspace_comment_paster);var module$build$src$core$clipboard$workspace_comment_paster={};module$build$src$core$clipboard$workspace_comment_paster.WorkspaceCommentPaster=WorkspaceCommentPaster$$module$build$src$core$clipboard$workspace_comment_paster;var RESIZE_SIZE$$module$build$src$core$workspace_comment_svg=8,BORDER_RADIUS$$module$build$src$core$workspace_comment_svg=3,TEXTAREA_OFFSET$$module$build$src$core$workspace_comment_svg=2,WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg=class extends WorkspaceComment$$module$build$src$core$workspace_comment{constructor(a,b,c,d,e){super(a,b,c,d,e);this.onMouseMoveWrapper=this.onMouseUpWrapper=null;this.eventsInit=!1;this.deleteIconBorder=this.deleteGroup=this.resizeGroup=this.foreignObject=
+this.svgHandleTarget=this.svgRectTarget=this.textarea=null;this.rendered=this.autoLayout=this.focused=!1;this.svgGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":"blocklyComment"});this.workspace=a;this.svgRect_=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{"class":"blocklyCommentRect",x:0,y:0,rx:BORDER_RADIUS$$module$build$src$core$workspace_comment_svg,ry:BORDER_RADIUS$$module$build$src$core$workspace_comment_svg});
+this.svgGroup.appendChild(this.svgRect_);this.render()}dispose(){this.disposed_||(getSelected$$module$build$src$core$common()===this&&(this.unselect(),this.workspace.cancelCurrentGesture()),isEnabled$$module$build$src$core$events$utils()&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(COMMENT_DELETE$$module$build$src$core$events$utils))(this)),removeNode$$module$build$src$core$utils$dom(this.svgGroup),$.disable$$module$build$src$core$events$utils(),super.dispose(),
+$.enable$$module$build$src$core$events$utils())}initSvg(a){if(!this.workspace.rendered)throw TypeError("Workspace is headless.");this.workspace.options.readOnly||this.eventsInit||(conditionalBind$$module$build$src$core$browser_events(this.svgRectTarget,"pointerdown",this,this.pathMouseDown),conditionalBind$$module$build$src$core$browser_events(this.svgHandleTarget,"pointerdown",this,this.pathMouseDown));this.eventsInit=!0;this.updateMovable();this.getSvgRoot().parentNode||this.workspace.getBubbleCanvas().appendChild(this.getSvgRoot());
+!a&&this.textarea&&this.textarea.select()}pathMouseDown(a){const b=this.workspace.getGesture(a);b&&b.handleBubbleStart(a,this)}showContextMenu(a){throw Error("The implementation of showContextMenu should be monkey-patched in by blockly.ts");}select(){if(getSelected$$module$build$src$core$common()!==this){var a=null;if(getSelected$$module$build$src$core$common()){a=getSelected$$module$build$src$core$common().id;$.disable$$module$build$src$core$events$utils();try{getSelected$$module$build$src$core$common().unselect()}finally{$.enable$$module$build$src$core$events$utils()}}a=
+new (get$$module$build$src$core$events$utils(SELECTED$$module$build$src$core$events$utils))(a,this.id,this.workspace.id);fire$$module$build$src$core$events$utils(a);setSelected$$module$build$src$core$common(this);this.addSelect()}}unselect(){if(getSelected$$module$build$src$core$common()===this){var a=new (get$$module$build$src$core$events$utils(SELECTED$$module$build$src$core$events$utils))(this.id,null,this.workspace.id);fire$$module$build$src$core$events$utils(a);setSelected$$module$build$src$core$common(null);
+this.removeSelect();this.blurFocus()}}addSelect(){addClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklySelected");this.setFocus()}removeSelect(){addClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklySelected");this.blurFocus()}addFocus(){addClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklyFocused")}removeFocus(){removeClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklyFocused")}getRelativeToSurfaceXY(){let a=0,b=0,c=this.getSvgRoot();if(c){do{const d=getRelativeXY$$module$build$src$core$utils$svg_math(c);
+a+=d.x;b+=d.y;c=c.parentNode}while(c&&c!==this.workspace.getBubbleCanvas()&&null!==c)}return this.xy_=new Coordinate$$module$build$src$core$utils$coordinate(a,b)}moveBy(a,b){const c=new (get$$module$build$src$core$events$utils(COMMENT_MOVE$$module$build$src$core$events$utils))(this),d=this.getRelativeToSurfaceXY();this.translate(d.x+a,d.y+b);this.xy_=new Coordinate$$module$build$src$core$utils$coordinate(d.x+a,d.y+b);c.recordNew();fire$$module$build$src$core$events$utils(c);this.workspace.resizeContents()}translate(a,
+b){this.xy_=new Coordinate$$module$build$src$core$utils$coordinate(a,b);this.getSvgRoot().setAttribute("transform","translate("+a+","+b+")")}moveDuringDrag(a){a=`translate(${a.x}, ${a.y})`;this.getSvgRoot().setAttribute("transform",a)}moveTo(a,b){this.translate(a,b)}clearTransformAttributes(){this.getSvgRoot().removeAttribute("transform")}getBoundingRectangle(){var a=this.getRelativeToSurfaceXY();const b=this.getHeightWidth(),c=a.y,d=a.y+b.height;let e;this.RTL?(e=a.x-b.width,a=a.x):(e=a.x,a=a.x+
+b.width);return new Rect$$module$build$src$core$utils$rect(c,d,e,a)}updateMovable(){this.isMovable()?addClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklyDraggable"):removeClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklyDraggable")}setMovable(a){super.setMovable(a);this.updateMovable()}setEditable(a){super.setEditable(a);this.textarea&&(this.textarea.readOnly=!a)}setDragging(a){a?addClass$$module$build$src$core$utils$dom(this.getSvgRoot(),"blocklyDragging"):removeClass$$module$build$src$core$utils$dom(this.getSvgRoot(),
+"blocklyDragging")}getSvgRoot(){return this.svgGroup}getContent(){return this.textarea?this.textarea.value:this.content_}setContent(a){super.setContent(a);this.textarea&&(this.textarea.value=a)}setDeleteStyle(a){a?addClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklyDraggingDelete"):removeClass$$module$build$src$core$utils$dom(this.svgGroup,"blocklyDraggingDelete")}setAutoLayout(a){}toXmlWithXY(a){let b=0;this.workspace.RTL&&(b=this.workspace.getWidth());a=this.toXml(a);const c=this.getRelativeToSurfaceXY();
+a.setAttribute("x",String(Math.round(this.workspace.RTL?b-c.x:c.x)));a.setAttribute("y",String(Math.round(c.y)));a.setAttribute("h",String(this.getHeight()));a.setAttribute("w",String(this.getWidth()));return a}toCopyData(){return{paster:WorkspaceCommentPaster$$module$build$src$core$clipboard$workspace_comment_paster.TYPE,commentState:this.toXmlWithXY()}}getHeightWidth(){return{width:this.getWidth(),height:this.getHeight()}}render(){if(!this.rendered){var a=this.getHeightWidth(),b=this.createEditor();
+this.svgGroup.appendChild(b);this.svgHandleTarget=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{"class":"blocklyCommentHandleTarget",x:0,y:0});this.svgGroup.appendChild(this.svgHandleTarget);this.svgRectTarget=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{"class":"blocklyCommentTarget",x:0,y:0,rx:BORDER_RADIUS$$module$build$src$core$workspace_comment_svg,ry:BORDER_RADIUS$$module$build$src$core$workspace_comment_svg});
+this.svgGroup.appendChild(this.svgRectTarget);this.addResizeDom();this.isDeletable()&&this.addDeleteDom();this.setSize(a.width,a.height);this.textarea.value=this.content_;this.rendered=!0;this.resizeGroup&&conditionalBind$$module$build$src$core$browser_events(this.resizeGroup,"pointerdown",this,this.resizeMouseDown);this.isDeletable()&&(conditionalBind$$module$build$src$core$browser_events(this.deleteGroup,"pointerdown",this,this.deleteMouseDown),conditionalBind$$module$build$src$core$browser_events(this.deleteGroup,
+"pointerout",this,this.deleteMouseOut),conditionalBind$$module$build$src$core$browser_events(this.deleteGroup,"pointerup",this,this.deleteMouseUp))}}createEditor(){this.foreignObject=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FOREIGNOBJECT,{x:0,y:WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.TOP_OFFSET,"class":"blocklyCommentForeignObject"});const a=document.createElementNS(HTML_NS$$module$build$src$core$utils$dom,"body");a.setAttribute("xmlns",
+HTML_NS$$module$build$src$core$utils$dom);a.className="blocklyMinimalBody";const b=document.createElementNS(HTML_NS$$module$build$src$core$utils$dom,"textarea");b.className="blocklyCommentTextarea";b.setAttribute("dir",this.RTL?"RTL":"LTR");b.readOnly=!this.isEditable();a.appendChild(b);this.textarea=b;this.foreignObject.appendChild(a);conditionalBind$$module$build$src$core$browser_events(b,"wheel",this,function(c){c.stopPropagation()});conditionalBind$$module$build$src$core$browser_events(b,"change",
+this,function(c){this.setContent(b.value)});return this.foreignObject}addResizeDom(){this.resizeGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":this.RTL?"blocklyResizeSW":"blocklyResizeSE"},this.svgGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.POLYGON,{points:`0,${RESIZE_SIZE$$module$build$src$core$workspace_comment_svg} ${RESIZE_SIZE$$module$build$src$core$workspace_comment_svg},${RESIZE_SIZE$$module$build$src$core$workspace_comment_svg} ${RESIZE_SIZE$$module$build$src$core$workspace_comment_svg},0`},
+this.resizeGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{"class":"blocklyResizeLine",x1:RESIZE_SIZE$$module$build$src$core$workspace_comment_svg/3,y1:RESIZE_SIZE$$module$build$src$core$workspace_comment_svg-1,x2:RESIZE_SIZE$$module$build$src$core$workspace_comment_svg-1,y2:RESIZE_SIZE$$module$build$src$core$workspace_comment_svg/3},this.resizeGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{"class":"blocklyResizeLine",
+x1:2*RESIZE_SIZE$$module$build$src$core$workspace_comment_svg/3,y1:RESIZE_SIZE$$module$build$src$core$workspace_comment_svg-1,x2:RESIZE_SIZE$$module$build$src$core$workspace_comment_svg-1,y2:2*RESIZE_SIZE$$module$build$src$core$workspace_comment_svg/3},this.resizeGroup)}addDeleteDom(){this.deleteGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":"blocklyCommentDeleteIcon"},this.svgGroup);this.deleteIconBorder=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CIRCLE,
+{"class":"blocklyDeleteIconShape",r:"7",cx:"7.5",cy:"7.5"},this.deleteGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{x1:"5",y1:"10",x2:"10",y2:"5",stroke:"#fff","stroke-width":"2"},this.deleteGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{x1:"5",y1:"5",x2:"10",y2:"10",stroke:"#fff","stroke-width":"2"},this.deleteGroup)}resizeMouseDown(a){this.unbindDragEvents();isRightButton$$module$build$src$core$browser_events(a)||
+(this.workspace.startDrag(a,new Coordinate$$module$build$src$core$utils$coordinate(this.workspace.RTL?-this.width_:this.width_,this.height_)),this.onMouseUpWrapper=conditionalBind$$module$build$src$core$browser_events(document,"pointerup",this,this.resizeMouseUp),this.onMouseMoveWrapper=conditionalBind$$module$build$src$core$browser_events(document,"pointermove",this,this.resizeMouseMove),this.workspace.hideChaff());a.stopPropagation()}deleteMouseDown(a){this.deleteIconBorder&&addClass$$module$build$src$core$utils$dom(this.deleteIconBorder,
+"blocklyDeleteIconHighlighted");a.stopPropagation()}deleteMouseOut(a){this.deleteIconBorder&&removeClass$$module$build$src$core$utils$dom(this.deleteIconBorder,"blocklyDeleteIconHighlighted")}deleteMouseUp(a){this.dispose();a.stopPropagation()}unbindDragEvents(){this.onMouseUpWrapper&&(unbind$$module$build$src$core$browser_events(this.onMouseUpWrapper),this.onMouseUpWrapper=null);this.onMouseMoveWrapper&&(unbind$$module$build$src$core$browser_events(this.onMouseMoveWrapper),this.onMouseMoveWrapper=
+null)}resizeMouseUp(a){clearTouchIdentifier$$module$build$src$core$touch();this.unbindDragEvents()}resizeMouseMove(a){this.autoLayout=!1;a=this.workspace.moveDrag(a);this.setSize(this.RTL?-a.x:a.x,a.y)}resizeComment(){const a=this.getHeightWidth(),b=WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.TOP_OFFSET,c=2*TEXTAREA_OFFSET$$module$build$src$core$workspace_comment_svg;let d;null==(d=this.foreignObject)||d.setAttribute("width",String(a.width));let e;null==(e=this.foreignObject)||
+e.setAttribute("height",String(a.height-b));if(this.RTL){let f;null==(f=this.foreignObject)||f.setAttribute("x",String(-a.width))}this.textarea&&(this.textarea.style.width=a.width-c+"px",this.textarea.style.height=a.height-c-b+"px")}setSize(a,b){a=Math.max(a,45);b=Math.max(b,20+WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.TOP_OFFSET);this.width_=a;this.height_=b;this.svgRect_.setAttribute("width",`${a}`);this.svgRect_.setAttribute("height",`${b}`);let c;null==(c=this.svgRectTarget)||
+c.setAttribute("width",`${a}`);let d;null==(d=this.svgRectTarget)||d.setAttribute("height",`${b}`);let e;null==(e=this.svgHandleTarget)||e.setAttribute("width",`${a}`);let f;null==(f=this.svgHandleTarget)||f.setAttribute("height",String(WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.TOP_OFFSET));if(this.RTL){this.svgRect_.setAttribute("transform","scale(-1 1)");let g;null==(g=this.svgRectTarget)||g.setAttribute("transform","scale(-1 1)")}if(this.resizeGroup)if(this.RTL){this.resizeGroup.setAttribute("transform",
+"translate("+(-a+RESIZE_SIZE$$module$build$src$core$workspace_comment_svg)+","+(b-RESIZE_SIZE$$module$build$src$core$workspace_comment_svg)+") scale(-1 1)");let g;null==(g=this.deleteGroup)||g.setAttribute("transform","translate("+(-a+RESIZE_SIZE$$module$build$src$core$workspace_comment_svg)+","+-RESIZE_SIZE$$module$build$src$core$workspace_comment_svg+") scale(-1 1)")}else{this.resizeGroup.setAttribute("transform","translate("+(a-RESIZE_SIZE$$module$build$src$core$workspace_comment_svg)+","+(b-RESIZE_SIZE$$module$build$src$core$workspace_comment_svg)+
+")");let g;null==(g=this.deleteGroup)||g.setAttribute("transform","translate("+(a-RESIZE_SIZE$$module$build$src$core$workspace_comment_svg)+","+-RESIZE_SIZE$$module$build$src$core$workspace_comment_svg+")")}this.resizeComment()}setFocus(){this.focused=!0;setTimeout(()=>{this.disposed_||(this.textarea.focus(),this.addFocus(),this.svgRectTarget&&addClass$$module$build$src$core$utils$dom(this.svgRectTarget,"blocklyCommentTargetFocused"),this.svgHandleTarget&&addClass$$module$build$src$core$utils$dom(this.svgHandleTarget,
+"blocklyCommentHandleTargetFocused"))},0)}blurFocus(){this.focused=!1;setTimeout(()=>{this.disposed_||(this.textarea.blur(),this.removeFocus(),this.svgRectTarget&&removeClass$$module$build$src$core$utils$dom(this.svgRectTarget,"blocklyCommentTargetFocused"),this.svgHandleTarget&&removeClass$$module$build$src$core$utils$dom(this.svgHandleTarget,"blocklyCommentHandleTargetFocused"))},0)}static fromXmlRendered(a,b,c){$.disable$$module$build$src$core$events$utils();let d;try{const e=WorkspaceComment$$module$build$src$core$workspace_comment.parseAttributes(a);
+d=new WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg(b,e.content,e.h,e.w,e.id);b.rendered&&(d.initSvg(!0),d.render());if(!isNaN(e.x)&&!isNaN(e.y))if(b.RTL){const f=c||b.getWidth();d.moveBy(f-e.x,e.y)}else d.moveBy(e.x,e.y)}finally{$.enable$$module$build$src$core$events$utils()}WorkspaceComment$$module$build$src$core$workspace_comment.fireCreateEvent(d);return d}};WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.DEFAULT_SIZE=100;
+WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.TOP_OFFSET=10;register$$module$build$src$core$css("\n.blocklyCommentForeignObject {\n  position: relative;\n  z-index: 0;\n}\n\n.blocklyCommentRect {\n  fill: #E7DE8E;\n  stroke: #bcA903;\n  stroke-width: 1px;\n}\n\n.blocklyCommentTarget {\n  fill: transparent;\n  stroke: #bcA903;\n}\n\n.blocklyCommentTargetFocused {\n  fill: none;\n}\n\n.blocklyCommentHandleTarget {\n  fill: none;\n}\n\n.blocklyCommentHandleTargetFocused {\n  fill: transparent;\n}\n\n.blocklyFocused>.blocklyCommentRect {\n  fill: #B9B272;\n  stroke: #B9B272;\n}\n\n.blocklySelected>.blocklyCommentTarget {\n  stroke: #fc3;\n  stroke-width: 3px;\n}\n\n.blocklyCommentDeleteIcon {\n  cursor: pointer;\n  fill: #000;\n  display: none;\n}\n\n.blocklySelected > .blocklyCommentDeleteIcon {\n  display: block;\n}\n\n.blocklyDeleteIconShape {\n  fill: #000;\n  stroke: #000;\n  stroke-width: 1px;\n}\n\n.blocklyDeleteIconShape.blocklyDeleteIconHighlighted {\n  stroke: #fc3;\n}\n");
+var module$build$src$core$workspace_comment_svg={};module$build$src$core$workspace_comment_svg.WorkspaceCommentSvg=WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg;var rootBlocks$$module$build$src$core$render_management=new Set,dirtyBlocks$$module$build$src$core$render_management=new WeakSet,afterRendersPromise$$module$build$src$core$render_management=null,afterRendersResolver$$module$build$src$core$render_management=null,animationRequestId$$module$build$src$core$render_management=0,module$build$src$core$render_management={};module$build$src$core$render_management.finishQueuedRenders=finishQueuedRenders$$module$build$src$core$render_management;
+module$build$src$core$render_management.queueRender=queueRender$$module$build$src$core$render_management;module$build$src$core$render_management.triggerQueuedRenders=triggerQueuedRenders$$module$build$src$core$render_management;var module$build$src$core$xml={};module$build$src$core$xml.appendDomToWorkspace=appendDomToWorkspace$$module$build$src$core$xml;module$build$src$core$xml.blockToDom=blockToDom$$module$build$src$core$xml;module$build$src$core$xml.blockToDomWithXY=blockToDomWithXY$$module$build$src$core$xml;module$build$src$core$xml.clearWorkspaceAndLoadFromXml=clearWorkspaceAndLoadFromXml$$module$build$src$core$xml;module$build$src$core$xml.deleteNext=deleteNext$$module$build$src$core$xml;
+module$build$src$core$xml.domToBlock=$.domToBlock$$module$build$src$core$xml;module$build$src$core$xml.domToBlockInternal=domToBlockInternal$$module$build$src$core$xml;module$build$src$core$xml.domToPrettyText=domToPrettyText$$module$build$src$core$xml;module$build$src$core$xml.domToText=domToText$$module$build$src$core$xml;module$build$src$core$xml.domToVariables=domToVariables$$module$build$src$core$xml;module$build$src$core$xml.domToWorkspace=$.domToWorkspace$$module$build$src$core$xml;
+module$build$src$core$xml.variablesToDom=variablesToDom$$module$build$src$core$xml;module$build$src$core$xml.workspaceToDom=workspaceToDom$$module$build$src$core$xml;var module$build$src$core$interfaces$i_serializable={};module$build$src$core$interfaces$i_serializable.isSerializable=isSerializable$$module$build$src$core$interfaces$i_serializable;var DeserializationError$$module$build$src$core$serialization$exceptions=class extends Error{},MissingBlockType$$module$build$src$core$serialization$exceptions=class extends DeserializationError$$module$build$src$core$serialization$exceptions{constructor(a){super("Expected to find a 'type' property, defining the block type");this.state=a}},MissingConnection$$module$build$src$core$serialization$exceptions=class extends DeserializationError$$module$build$src$core$serialization$exceptions{constructor(a,
+b,c){super(`The block ${b.toDevString()} is missing a(n) ${a}
+connection`);this.block=b;this.state=c}},BadConnectionCheck$$module$build$src$core$serialization$exceptions=class extends DeserializationError$$module$build$src$core$serialization$exceptions{constructor(a,b,c,d){super(`The block ${c.toDevString()} could not connect its
+${b} to its parent, because: ${a}`);this.childBlock=c;this.childState=d}},RealChildOfShadow$$module$build$src$core$serialization$exceptions=class extends DeserializationError$$module$build$src$core$serialization$exceptions{constructor(a){super("Encountered a real block which is defined as a child of a shadow\nblock. It is an invariant of Blockly that shadow blocks only have shadow\nchildren");this.state=a}},UnregisteredIcon$$module$build$src$core$serialization$exceptions=class extends DeserializationError$$module$build$src$core$serialization$exceptions{constructor(a,
+b,c){super(`Cannot add an icon of type '${a}' to the block `+`${b.toDevString()}, because there is no icon registered with `+`type '${a}'. Make sure that all of your icons have been `+"registered.");this.block=b;this.state=c}},module$build$src$core$serialization$exceptions={};module$build$src$core$serialization$exceptions.BadConnectionCheck=BadConnectionCheck$$module$build$src$core$serialization$exceptions;module$build$src$core$serialization$exceptions.DeserializationError=DeserializationError$$module$build$src$core$serialization$exceptions;
+module$build$src$core$serialization$exceptions.MissingBlockType=MissingBlockType$$module$build$src$core$serialization$exceptions;module$build$src$core$serialization$exceptions.MissingConnection=MissingConnection$$module$build$src$core$serialization$exceptions;module$build$src$core$serialization$exceptions.RealChildOfShadow=RealChildOfShadow$$module$build$src$core$serialization$exceptions;module$build$src$core$serialization$exceptions.UnregisteredIcon=UnregisteredIcon$$module$build$src$core$serialization$exceptions;var VARIABLES$$module$build$src$core$serialization$priorities=100,PROCEDURES$$module$build$src$core$serialization$priorities=75,BLOCKS$$module$build$src$core$serialization$priorities=50,module$build$src$core$serialization$priorities={BLOCKS:BLOCKS$$module$build$src$core$serialization$priorities,PROCEDURES:PROCEDURES$$module$build$src$core$serialization$priorities,VARIABLES:VARIABLES$$module$build$src$core$serialization$priorities};var module$build$src$core$serialization$registry={};module$build$src$core$serialization$registry.register=register$$module$build$src$core$serialization$registry;module$build$src$core$serialization$registry.unregister=unregister$$module$build$src$core$serialization$registry;var saveBlock$$module$build$src$core$serialization$blocks=save$$module$build$src$core$serialization$blocks,BlockSerializer$$module$build$src$core$serialization$blocks=class{constructor(){this.priority=BLOCKS$$module$build$src$core$serialization$priorities}save(a){const b=[];for(const c of a.getTopBlocks(!1))(a=save$$module$build$src$core$serialization$blocks(c,{addCoordinates:!0,doFullSerialization:!1}))&&b.push(a);return b.length?{languageVersion:0,blocks:b}:null}load(a,b){a=a.blocks;for(const c of a)append$$module$build$src$core$serialization$blocks(c,
+b,{recordUndo:getRecordUndo$$module$build$src$core$events$utils()})}clear(a){for(const b of a.getTopBlocks(!1))b.dispose(!1)}};register$$module$build$src$core$serialization$registry("blocks",new BlockSerializer$$module$build$src$core$serialization$blocks);var module$build$src$core$serialization$blocks={};module$build$src$core$serialization$blocks.BlockSerializer=BlockSerializer$$module$build$src$core$serialization$blocks;module$build$src$core$serialization$blocks.append=append$$module$build$src$core$serialization$blocks;
+module$build$src$core$serialization$blocks.appendInternal=appendInternal$$module$build$src$core$serialization$blocks;module$build$src$core$serialization$blocks.save=save$$module$build$src$core$serialization$blocks;var BlockBase$$module$build$src$core$events$events_block_base=class extends Abstract$$module$build$src$core$events$events_abstract{constructor(a){super();this.isBlank=!a;a&&(this.blockId=a.id,this.workspaceId=a.workspace.id)}toJson(){const a=super.toJson();if(!this.blockId)throw Error("The block ID is undefined. Either pass a block to the constructor, or call fromJson");a.blockId=this.blockId;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BlockBase$$module$build$src$core$events$events_block_base);
+b.blockId=a.blockId;return b}},module$build$src$core$events$events_block_base={};module$build$src$core$events$events_block_base.BlockBase=BlockBase$$module$build$src$core$events$events_block_base;var BlockCreate$$module$build$src$core$events$events_block_create=class extends BlockBase$$module$build$src$core$events$events_block_base{constructor(a){super(a);this.type=$.CREATE$$module$build$src$core$events$utils;a&&(a.isShadow()&&(this.recordUndo=!1),this.xml=blockToDomWithXY$$module$build$src$core$xml(a),this.ids=getDescendantIds$$module$build$src$core$events$utils(a),this.json=save$$module$build$src$core$serialization$blocks(a,{addCoordinates:!0}))}toJson(){const a=super.toJson();if(!this.xml)throw Error("The block XML is undefined. Either pass a block to the constructor, or call fromJson");
+if(!this.ids)throw Error("The block IDs are undefined. Either pass a block to the constructor, or call fromJson");if(!this.json)throw Error("The block JSON is undefined. Either pass a block to the constructor, or call fromJson");a.xml=domToText$$module$build$src$core$xml(this.xml);a.ids=this.ids;a.json=this.json;this.recordUndo||(a.recordUndo=this.recordUndo);return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BlockCreate$$module$build$src$core$events$events_block_create);b.xml=$.textToDom$$module$build$src$core$utils$xml(a.xml);
+b.ids=a.ids;b.json=a.json;void 0!==a.recordUndo&&(b.recordUndo=a.recordUndo);return b}run(a){const b=this.getEventWorkspace_();if(!this.json)throw Error("The block JSON is undefined. Either pass a block to the constructor, or call fromJson");if(!this.ids)throw Error("The block IDs are undefined. Either pass a block to the constructor, or call fromJson");if(!allShadowBlocks$$module$build$src$core$events$events_block_create(b,this.ids))if(a)append$$module$build$src$core$serialization$blocks(this.json,
+b);else for(a=0;aa.getBlockById(c)).filter(c=>c&&c.isShadow()).length===b.length};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,$.CREATE$$module$build$src$core$events$utils,BlockCreate$$module$build$src$core$events$events_block_create);
+var module$build$src$core$events$events_block_create={};module$build$src$core$events$events_block_create.BlockCreate=BlockCreate$$module$build$src$core$events$events_block_create;var ThemeChange$$module$build$src$core$events$events_theme_change=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b){super(b);this.type=THEME_CHANGE$$module$build$src$core$events$utils;this.themeName=a}toJson(){const a=super.toJson();if(!this.themeName)throw Error("The theme name is undefined. Either pass a theme name to the constructor, or call fromJson");a.themeName=this.themeName;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new ThemeChange$$module$build$src$core$events$events_theme_change);
+b.themeName=a.themeName;return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,THEME_CHANGE$$module$build$src$core$events$utils,ThemeChange$$module$build$src$core$events$events_theme_change);var module$build$src$core$events$events_theme_change={};module$build$src$core$events$events_theme_change.ThemeChange=ThemeChange$$module$build$src$core$events$events_theme_change;var ViewportChange$$module$build$src$core$events$events_viewport=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b,c,d,e){super(d);this.type=VIEWPORT_CHANGE$$module$build$src$core$events$utils;this.viewTop=a;this.viewLeft=b;this.scale=c;this.oldScale=e}toJson(){const a=super.toJson();if(void 0===this.viewTop)throw Error("The view top is undefined. Either pass a value to the constructor, or call fromJson");if(void 0===this.viewLeft)throw Error("The view left is undefined. Either pass a value to the constructor, or call fromJson");
+if(void 0===this.scale)throw Error("The scale is undefined. Either pass a value to the constructor, or call fromJson");if(void 0===this.oldScale)throw Error("The old scale is undefined. Either pass a value to the constructor, or call fromJson");a.viewTop=this.viewTop;a.viewLeft=this.viewLeft;a.scale=this.scale;a.oldScale=this.oldScale;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new ViewportChange$$module$build$src$core$events$events_viewport);b.viewTop=a.viewTop;b.viewLeft=a.viewLeft;
+b.scale=a.scale;b.oldScale=a.oldScale;return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,VIEWPORT_CHANGE$$module$build$src$core$events$utils,ViewportChange$$module$build$src$core$events$events_viewport);var module$build$src$core$events$events_viewport={};module$build$src$core$events$events_viewport.ViewportChange=ViewportChange$$module$build$src$core$events$events_viewport;var DEFAULT_SNAP_RADIUS$$module$build$src$core$config,module$build$src$core$config;DEFAULT_SNAP_RADIUS$$module$build$src$core$config=28;$.config$$module$build$src$core$config={dragRadius:5,flyoutDragRadius:10,snapRadius:DEFAULT_SNAP_RADIUS$$module$build$src$core$config,connectingSnapRadius:DEFAULT_SNAP_RADIUS$$module$build$src$core$config,currentConnectionPreference:8,bumpDelay:250};module$build$src$core$config={config:$.config$$module$build$src$core$config};var ConnectionType$$module$build$src$core$connection_type;(function(a){a[a.INPUT_VALUE=1]="INPUT_VALUE";a[a.OUTPUT_VALUE=2]="OUTPUT_VALUE";a[a.NEXT_STATEMENT=3]="NEXT_STATEMENT";a[a.PREVIOUS_STATEMENT=4]="PREVIOUS_STATEMENT"})(ConnectionType$$module$build$src$core$connection_type||(ConnectionType$$module$build$src$core$connection_type={}));var module$build$src$core$connection_type={};module$build$src$core$connection_type.ConnectionType=ConnectionType$$module$build$src$core$connection_type;var ConnectionDB$$module$build$src$core$connection_db=class{constructor(a){this.connectionChecker=a;this.connections=[]}addConnection(a,b){b=this.calculateIndexForYPos(b);this.connections.splice(b,0,a)}findIndexOfConnection(a,b){if(!this.connections.length)return-1;const c=this.calculateIndexForYPos(b);if(c>=this.connections.length)return-1;b=a.y;let d=c;for(;0<=d&&this.connections[d].y===b;){if(this.connections[d]===a)return d;d--}for(d=c;da)c=d;else{b=d;break}}return b}removeConnection(a,b){a=this.findIndexOfConnection(a,b);if(-1===a)throw Error("Unable to find connection in connectionDB.");this.connections.splice(a,1)}getNeighbours(a,b){function c(l){const m=e-d[l].x,n=f-d[l].y;Math.sqrt(m*m+n*n)<=b&&k.push(d[l]);
+return na?this.menuItems.length:a,-1)}highlightFirst(){this.highlightHelper(-1,1)}highlightLast(){this.highlightHelper(this.menuItems.length,-1)}highlightHelper(a,
+b){a+=b;let c;for(;c=this.menuItems[a];){if(c.isEnabled()){this.setHighlighted(c);break}a+=b}}handleMouseOver(a){(a=this.getMenuItem(a.target))&&(a.isEnabled()?this.highlightedItem!==a&&this.setHighlighted(a):this.setHighlighted(null))}handleClick(a){const b=this.openingCoords;this.openingCoords=null;if(b&&"number"===typeof a.clientX){const c=new Coordinate$$module$build$src$core$utils$coordinate(a.clientX,a.clientY);if(1>Coordinate$$module$build$src$core$utils$coordinate.distance(b,c))return}(a=
+this.getMenuItem(a.target))&&a.performAction()}handleMouseEnter(a){this.focus()}handleMouseLeave(a){this.getElement()&&(this.blur(),this.setHighlighted(null))}handleKeyEvent(a){if(this.menuItems.length&&!(a.shiftKey||a.ctrlKey||a.metaKey||a.altKey)){var b=this.highlightedItem;switch(a.key){case "Enter":case " ":b&&b.performAction();break;case "ArrowUp":this.highlightPrevious();break;case "ArrowDown":this.highlightNext();break;case "PageUp":case "Home":this.highlightFirst();break;case "PageDown":case "End":this.highlightLast();
+break;default:return}a.preventDefault();a.stopPropagation()}}getSize(){const a=this.getElement(),b=getSize$$module$build$src$core$utils$style(a);b.height=a.scrollHeight;return b}},module$build$src$core$menu={};module$build$src$core$menu.Menu=Menu$$module$build$src$core$menu;var MenuItem$$module$build$src$core$menuitem=class{constructor(a,b){this.content=a;this.opt_value=b;this.enabled=!0;this.element=null;this.rightToLeft=!1;this.roleName=null;this.highlight=this.checked=this.checkable=!1;this.actionHandler=null}createDom(){const a=document.createElement("div");a.id=getNextUniqueId$$module$build$src$core$utils$idgenerator();this.element=a;a.className="blocklyMenuItem goog-menuitem "+(this.enabled?"":"blocklyMenuItemDisabled goog-menuitem-disabled ")+(this.checked?"blocklyMenuItemSelected goog-option-selected ":
+"")+(this.highlight?"blocklyMenuItemHighlight goog-menuitem-highlight ":"")+(this.rightToLeft?"blocklyMenuItemRtl goog-menuitem-rtl ":"");const b=document.createElement("div");b.className="blocklyMenuItemContent goog-menuitem-content";if(this.checkable){var c=document.createElement("div");c.className="blocklyMenuItemCheckbox goog-menuitem-checkbox";b.appendChild(c)}c=this.content;"string"===typeof this.content&&(c=document.createTextNode(this.content));b.appendChild(c);a.appendChild(b);this.roleName&&
+setRole$$module$build$src$core$utils$aria(a,this.roleName);setState$$module$build$src$core$utils$aria(a,State$$module$build$src$core$utils$aria.SELECTED,this.checkable&&this.checked||!1);setState$$module$build$src$core$utils$aria(a,State$$module$build$src$core$utils$aria.DISABLED,!this.enabled);return a}dispose(){this.element=null}getElement(){return this.element}getId(){return this.element.id}getValue(){let a;return null!=(a=this.opt_value)?a:null}setRightToLeft(a){this.rightToLeft=a}setRole(a){this.roleName=
+a}setCheckable(a){this.checkable=a}setChecked(a){this.checked=a}setHighlighted(a){this.highlight=a;const b=this.getElement();b&&this.isEnabled()&&(a?(addClass$$module$build$src$core$utils$dom(b,"blocklyMenuItemHighlight"),addClass$$module$build$src$core$utils$dom(b,"goog-menuitem-highlight")):(removeClass$$module$build$src$core$utils$dom(b,"blocklyMenuItemHighlight"),removeClass$$module$build$src$core$utils$dom(b,"goog-menuitem-highlight")))}isEnabled(){return this.enabled}setEnabled(a){this.enabled=
+a}performAction(){this.isEnabled()&&this.actionHandler&&this.actionHandler(this)}onAction(a,b){this.actionHandler=a.bind(b)}},module$build$src$core$menuitem={};module$build$src$core$menuitem.MenuItem=MenuItem$$module$build$src$core$menuitem;var owner$$module$build$src$core$widgetdiv=null,dispose$$module$build$src$core$widgetdiv=null,rendererClassName$$module$build$src$core$widgetdiv="",themeClassName$$module$build$src$core$widgetdiv="",containerDiv$$module$build$src$core$widgetdiv,module$build$src$core$widgetdiv={};module$build$src$core$widgetdiv.createDom=createDom$$module$build$src$core$widgetdiv;module$build$src$core$widgetdiv.getDiv=getDiv$$module$build$src$core$widgetdiv;module$build$src$core$widgetdiv.hide=hide$$module$build$src$core$widgetdiv;
+module$build$src$core$widgetdiv.hideIfOwner=hideIfOwner$$module$build$src$core$widgetdiv;module$build$src$core$widgetdiv.isVisible=isVisible$$module$build$src$core$widgetdiv;module$build$src$core$widgetdiv.positionWithAnchor=positionWithAnchor$$module$build$src$core$widgetdiv;module$build$src$core$widgetdiv.repositionForWindowResize=repositionForWindowResize$$module$build$src$core$widgetdiv;module$build$src$core$widgetdiv.show=show$$module$build$src$core$widgetdiv;
+module$build$src$core$widgetdiv.testOnly_setDiv=testOnly_setDiv$$module$build$src$core$widgetdiv;var currentBlock$$module$build$src$core$contextmenu=null,dummyOwner$$module$build$src$core$contextmenu={},menu_$$module$build$src$core$contextmenu=null,module$build$src$core$contextmenu={};module$build$src$core$contextmenu.callbackFactory=$.callbackFactory$$module$build$src$core$contextmenu;module$build$src$core$contextmenu.commentDeleteOption=commentDeleteOption$$module$build$src$core$contextmenu;module$build$src$core$contextmenu.commentDuplicateOption=commentDuplicateOption$$module$build$src$core$contextmenu;
+module$build$src$core$contextmenu.dispose=dispose$$module$build$src$core$contextmenu;module$build$src$core$contextmenu.getCurrentBlock=getCurrentBlock$$module$build$src$core$contextmenu;module$build$src$core$contextmenu.hide=hide$$module$build$src$core$contextmenu;module$build$src$core$contextmenu.setCurrentBlock=setCurrentBlock$$module$build$src$core$contextmenu;module$build$src$core$contextmenu.show=show$$module$build$src$core$contextmenu;
+module$build$src$core$contextmenu.workspaceCommentOption=workspaceCommentOption$$module$build$src$core$contextmenu;var ContextMenuRegistry$$module$build$src$core$contextmenu_registry=class{constructor(){this.registry_=new Map;this.reset()}reset(){this.registry_.clear()}register(a){if(this.registry_.has(a.id))throw Error('Menu item with ID "'+a.id+'" is already registered.');this.registry_.set(a.id,a)}unregister(a){if(!this.registry_.has(a))throw Error('Menu item with ID "'+a+'" not found.');this.registry_.delete(a)}getItem(a){let b;return null!=(b=this.registry_.get(a))?b:null}getContextMenuOptions(a,b){const c=
+[];for(const e of this.registry_.values())if(a===e.scopeType){var d=e.preconditionFn(b);"hidden"!==d&&(d={text:"function"===typeof e.displayText?e.displayText(b):e.displayText,enabled:"enabled"===d,callback:e.callback,scope:b,weight:e.weight},c.push(d))}c.sort(function(e,f){return e.weight-f.weight});return c}};
+(function(a){var b=a.ScopeType||(a.ScopeType={});b.BLOCK="block";b.WORKSPACE="workspace";a.registry=new a})(ContextMenuRegistry$$module$build$src$core$contextmenu_registry||(ContextMenuRegistry$$module$build$src$core$contextmenu_registry={}));var ScopeType$$module$build$src$core$contextmenu_registry=ContextMenuRegistry$$module$build$src$core$contextmenu_registry.ScopeType,module$build$src$core$contextmenu_registry={};module$build$src$core$contextmenu_registry.ContextMenuRegistry=ContextMenuRegistry$$module$build$src$core$contextmenu_registry;
+module$build$src$core$contextmenu_registry.ScopeType=ScopeType$$module$build$src$core$contextmenu_registry;var module$build$src$core$utils$math={};module$build$src$core$utils$math.clamp=clamp$$module$build$src$core$utils$math;module$build$src$core$utils$math.toDegrees=toDegrees$$module$build$src$core$utils$math;module$build$src$core$utils$math.toRadians=toRadians$$module$build$src$core$utils$math;var ARROW_SIZE$$module$build$src$core$dropdowndiv=16,BORDER_SIZE$$module$build$src$core$dropdowndiv=1,ARROW_HORIZONTAL_PADDING$$module$build$src$core$dropdowndiv=12,PADDING_Y$$module$build$src$core$dropdowndiv=16,ANIMATION_TIME$$module$build$src$core$dropdowndiv=.25,animateOutTimer$$module$build$src$core$dropdowndiv=null,onHide$$module$build$src$core$dropdowndiv=null,renderedClassName$$module$build$src$core$dropdowndiv="",themeClassName$$module$build$src$core$dropdowndiv="",div$$module$build$src$core$dropdowndiv,
+content$$module$build$src$core$dropdowndiv,arrow$$module$build$src$core$dropdowndiv,boundsElement$$module$build$src$core$dropdowndiv=null,owner$$module$build$src$core$dropdowndiv=null,positionToField$$module$build$src$core$dropdowndiv=null,internal$$module$build$src$core$dropdowndiv={getBoundsInfo:function(){const a=getPageOffset$$module$build$src$core$utils$style(boundsElement$$module$build$src$core$dropdowndiv),b=getSize$$module$build$src$core$utils$style(boundsElement$$module$build$src$core$dropdowndiv);
+return{left:a.x,right:a.x+b.width,top:a.y,bottom:a.y+b.height,width:b.width,height:b.height}},getPositionMetrics:function(a,b,c,d){const e=internal$$module$build$src$core$dropdowndiv.getBoundsInfo(),f=getSize$$module$build$src$core$utils$style(div$$module$build$src$core$dropdowndiv);return b+f.heighte.top?getPositionAboveMetrics$$module$build$src$core$dropdowndiv(c,d,e,f):b+f.heightdocument.documentElement.clientTop?getPositionAboveMetrics$$module$build$src$core$dropdowndiv(c,d,e,f):getPositionTopOfPageMetrics$$module$build$src$core$dropdowndiv(a,e,f)}},TEST_ONLY$$module$build$src$core$dropdowndiv=internal$$module$build$src$core$dropdowndiv,module$build$src$core$dropdowndiv={ANIMATION_TIME:ANIMATION_TIME$$module$build$src$core$dropdowndiv,ARROW_HORIZONTAL_PADDING:ARROW_HORIZONTAL_PADDING$$module$build$src$core$dropdowndiv,
+ARROW_SIZE:ARROW_SIZE$$module$build$src$core$dropdowndiv,BORDER_SIZE:BORDER_SIZE$$module$build$src$core$dropdowndiv,PADDING_Y:PADDING_Y$$module$build$src$core$dropdowndiv,TEST_ONLY:internal$$module$build$src$core$dropdowndiv};module$build$src$core$dropdowndiv.clearContent=clearContent$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.createDom=createDom$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.getContentDiv=getContentDiv$$module$build$src$core$dropdowndiv;
+module$build$src$core$dropdowndiv.getOwner=getOwner$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.getPositionX=getPositionX$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.hide=hide$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.hideIfOwner=hideIfOwner$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.hideWithoutAnimation=hideWithoutAnimation$$module$build$src$core$dropdowndiv;
+module$build$src$core$dropdowndiv.isVisible=isVisible$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.repositionForWindowResize=repositionForWindowResize$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.setBoundsElement=setBoundsElement$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.setColour=setColour$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.show=show$$module$build$src$core$dropdowndiv;
+module$build$src$core$dropdowndiv.showPositionedByBlock=showPositionedByBlock$$module$build$src$core$dropdowndiv;module$build$src$core$dropdowndiv.showPositionedByField=showPositionedByField$$module$build$src$core$dropdowndiv;var disconnectPid$$module$build$src$core$block_animations=null,wobblingBlock$$module$build$src$core$block_animations=null,module$build$src$core$block_animations={};module$build$src$core$block_animations.connectionUiEffect=connectionUiEffect$$module$build$src$core$block_animations;module$build$src$core$block_animations.disconnectUiEffect=disconnectUiEffect$$module$build$src$core$block_animations;module$build$src$core$block_animations.disconnectUiStop=disconnectUiStop$$module$build$src$core$block_animations;
+module$build$src$core$block_animations.disposeUiEffect=disposeUiEffect$$module$build$src$core$block_animations;var BubbleDragger$$module$build$src$core$bubble_dragger=class{constructor(a,b){this.bubble=a;this.workspace=b;this.dragTarget_=null;this.wouldDeleteBubble_=!1;this.startXY_=this.bubble.getRelativeToSurfaceXY()}startBubbleDrag(){$.getGroup$$module$build$src$core$events$utils()||$.setGroup$$module$build$src$core$events$utils(!0);this.workspace.setResizesEnabled(!1);this.bubble.setAutoLayout&&this.bubble.setAutoLayout(!1);this.bubble.setDragging&&this.bubble.setDragging(!0)}dragBubble(a,b){b=this.pixelsToWorkspaceUnits_(b);
+b=Coordinate$$module$build$src$core$utils$coordinate.sum(this.startXY_,b);this.bubble.moveDuringDrag(b);b=this.dragTarget_;this.dragTarget_=this.workspace.getDragTarget(a);a=this.wouldDeleteBubble_;this.wouldDeleteBubble_=this.shouldDelete_(this.dragTarget_);a!==this.wouldDeleteBubble_&&this.updateCursorDuringBubbleDrag_();this.dragTarget_!==b&&(b&&b.onDragExit(this.bubble),this.dragTarget_&&this.dragTarget_.onDragEnter(this.bubble));this.dragTarget_&&this.dragTarget_.onDragOver(this.bubble)}shouldDelete_(a){return a&&
+this.workspace.getComponentManager().hasCapability(a.id,ComponentManager$$module$build$src$core$component_manager.Capability.DELETE_AREA)?a.wouldDelete(this.bubble,!1):!1}updateCursorDuringBubbleDrag_(){this.bubble.setDeleteStyle(this.wouldDeleteBubble_)}endBubbleDrag(a,b){this.dragBubble(a,b);this.dragTarget_&&this.dragTarget_.shouldPreventMove(this.bubble)?a=this.startXY_:(a=this.pixelsToWorkspaceUnits_(b),a=Coordinate$$module$build$src$core$utils$coordinate.sum(this.startXY_,a));this.bubble.moveTo(a.x,
+a.y);if(this.dragTarget_)this.dragTarget_.onDrop(this.bubble);this.wouldDeleteBubble_?(this.fireMoveEvent_(),this.bubble.dispose()):(this.bubble.setDragging&&this.bubble.setDragging(!1),this.fireMoveEvent_());this.workspace.setResizesEnabled(!0);$.setGroup$$module$build$src$core$events$utils(!1)}fireMoveEvent_(){if(this.bubble instanceof WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg){const a=new (get$$module$build$src$core$events$utils(COMMENT_MOVE$$module$build$src$core$events$utils))(this.bubble);
+a.setOldCoordinate(this.startXY_);a.recordNew();fire$$module$build$src$core$events$utils(a)}}pixelsToWorkspaceUnits_(a){a=new Coordinate$$module$build$src$core$utils$coordinate(a.x/this.workspace.scale,a.y/this.workspace.scale);this.workspace.isMutator&&a.scale(1/this.workspace.options.parentWorkspace.scale);return a}},module$build$src$core$bubble_dragger={};module$build$src$core$bubble_dragger.BubbleDragger=BubbleDragger$$module$build$src$core$bubble_dragger;var COLLAPSE_CHARS$$module$build$src$core$internal_constants=30,DRAG_STACK$$module$build$src$core$internal_constants=!0,OPPOSITE_TYPE$$module$build$src$core$internal_constants=[];OPPOSITE_TYPE$$module$build$src$core$internal_constants[ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE]=ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE;OPPOSITE_TYPE$$module$build$src$core$internal_constants[ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE]=ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE;
+OPPOSITE_TYPE$$module$build$src$core$internal_constants[ConnectionType$$module$build$src$core$connection_type.NEXT_STATEMENT]=ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT;OPPOSITE_TYPE$$module$build$src$core$internal_constants[ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT]=ConnectionType$$module$build$src$core$connection_type.NEXT_STATEMENT;
+var RENAME_VARIABLE_ID$$module$build$src$core$internal_constants="RENAME_VARIABLE_ID",DELETE_VARIABLE_ID$$module$build$src$core$internal_constants="DELETE_VARIABLE_ID",module$build$src$core$internal_constants={COLLAPSE_CHARS:COLLAPSE_CHARS$$module$build$src$core$internal_constants,DELETE_VARIABLE_ID:DELETE_VARIABLE_ID$$module$build$src$core$internal_constants,DRAG_STACK:DRAG_STACK$$module$build$src$core$internal_constants,OPPOSITE_TYPE:OPPOSITE_TYPE$$module$build$src$core$internal_constants,RENAME_VARIABLE_ID:RENAME_VARIABLE_ID$$module$build$src$core$internal_constants};var module$build$src$core$utils$string={};module$build$src$core$utils$string.commonWordPrefix=commonWordPrefix$$module$build$src$core$utils$string;module$build$src$core$utils$string.commonWordSuffix=commonWordSuffix$$module$build$src$core$utils$string;module$build$src$core$utils$string.isNumber=$.isNumber$$module$build$src$core$utils$string;module$build$src$core$utils$string.shortestStringLength=shortestStringLength$$module$build$src$core$utils$string;
+module$build$src$core$utils$string.startsWith=startsWith$$module$build$src$core$utils$string;module$build$src$core$utils$string.wrap=$.wrap$$module$build$src$core$utils$string;var customTooltip$$module$build$src$core$tooltip=void 0,visible$$module$build$src$core$tooltip=!1,blocked$$module$build$src$core$tooltip=!1,LIMIT$$module$build$src$core$tooltip=50,mouseOutPid$$module$build$src$core$tooltip=0,showPid$$module$build$src$core$tooltip=0,lastX$$module$build$src$core$tooltip=0,lastY$$module$build$src$core$tooltip=0,element$$module$build$src$core$tooltip=null,poisonedElement$$module$build$src$core$tooltip=null,OFFSET_X$$module$build$src$core$tooltip=0,OFFSET_Y$$module$build$src$core$tooltip=
+10,RADIUS_OK$$module$build$src$core$tooltip=10,HOVER_MS$$module$build$src$core$tooltip=750,MARGINS$$module$build$src$core$tooltip=5,containerDiv$$module$build$src$core$tooltip=null,module$build$src$core$tooltip={HOVER_MS:HOVER_MS$$module$build$src$core$tooltip,LIMIT:LIMIT$$module$build$src$core$tooltip,MARGINS:MARGINS$$module$build$src$core$tooltip,OFFSET_X:OFFSET_X$$module$build$src$core$tooltip,OFFSET_Y:OFFSET_Y$$module$build$src$core$tooltip,RADIUS_OK:RADIUS_OK$$module$build$src$core$tooltip};
+module$build$src$core$tooltip.bindMouseEvents=bindMouseEvents$$module$build$src$core$tooltip;module$build$src$core$tooltip.block=block$$module$build$src$core$tooltip;module$build$src$core$tooltip.createDom=createDom$$module$build$src$core$tooltip;module$build$src$core$tooltip.dispose=dispose$$module$build$src$core$tooltip;module$build$src$core$tooltip.getCustomTooltip=getCustomTooltip$$module$build$src$core$tooltip;module$build$src$core$tooltip.getDiv=getDiv$$module$build$src$core$tooltip;
+module$build$src$core$tooltip.getTooltipOfObject=getTooltipOfObject$$module$build$src$core$tooltip;module$build$src$core$tooltip.hide=hide$$module$build$src$core$tooltip;module$build$src$core$tooltip.isVisible=isVisible$$module$build$src$core$tooltip;module$build$src$core$tooltip.setCustomTooltip=setCustomTooltip$$module$build$src$core$tooltip;module$build$src$core$tooltip.unbindMouseEvents=unbindMouseEvents$$module$build$src$core$tooltip;module$build$src$core$tooltip.unblock=unblock$$module$build$src$core$tooltip;var WorkspaceDragger$$module$build$src$core$workspace_dragger=class{constructor(a){this.workspace=a;this.horizontalScrollEnabled_=this.workspace.isMovableHorizontally();this.verticalScrollEnabled_=this.workspace.isMovableVertically();this.startScrollXY_=new Coordinate$$module$build$src$core$utils$coordinate(a.scrollX,a.scrollY)}dispose(){this.workspace=null}startDrag(){getSelected$$module$build$src$core$common()&&getSelected$$module$build$src$core$common().unselect()}endDrag(a){this.drag(a)}drag(a){a=
+Coordinate$$module$build$src$core$utils$coordinate.sum(this.startScrollXY_,a);if(this.horizontalScrollEnabled_&&this.verticalScrollEnabled_)this.workspace.scroll(a.x,a.y);else if(this.horizontalScrollEnabled_)this.workspace.scroll(a.x,this.workspace.scrollY);else if(this.verticalScrollEnabled_)this.workspace.scroll(this.workspace.scrollX,a.y);else throw new TypeError("Invalid state.");}},module$build$src$core$workspace_dragger={};module$build$src$core$workspace_dragger.WorkspaceDragger=WorkspaceDragger$$module$build$src$core$workspace_dragger;var ZOOM_IN_MULTIPLIER$$module$build$src$core$gesture=5,ZOOM_OUT_MULTIPLIER$$module$build$src$core$gesture=6,Gesture$$module$build$src$core$gesture=class{constructor(a,b){this.creatorWorkspace=b;this.mouseDownXY=new Coordinate$$module$build$src$core$utils$coordinate(0,0);this.startWorkspace_=this.targetBlock=this.startBlock=this.startIcon=this.startField=this.startBubble=null;this.hasExceededDragRadius=!1;this.boundEvents=[];this.flyout=this.workspaceDragger=this.blockDragger=this.bubbleDragger=null;
+this.isMultiTouch_=this.isEnding_=this.gestureHasStarted=this.calledUpdateIsDragging=!1;this.cachedPoints=new Map;this.startDistance=this.previousScale=0;this.currentDropdownOwner=this.isPinchZoomEnabled=null;this.mostRecentEvent=a;this.currentDragDeltaXY=new Coordinate$$module$build$src$core$utils$coordinate(0,0);this.healStack=!DRAG_STACK$$module$build$src$core$internal_constants}dispose(){clearTouchIdentifier$$module$build$src$core$touch();unblock$$module$build$src$core$tooltip();this.creatorWorkspace.clearGesture();
+for(const a of this.boundEvents)unbind$$module$build$src$core$browser_events(a);this.boundEvents.length=0;this.blockDragger&&this.blockDragger.dispose();this.workspaceDragger&&this.workspaceDragger.dispose()}updateFromEvent(a){const b=new Coordinate$$module$build$src$core$utils$coordinate(a.clientX,a.clientY);this.updateDragDelta(b)&&(this.updateIsDragging(),longStop$$module$build$src$core$touch());this.mostRecentEvent=a}updateDragDelta(a){this.currentDragDeltaXY=Coordinate$$module$build$src$core$utils$coordinate.difference(a,
+this.mouseDownXY);return this.hasExceededDragRadius?!1:this.hasExceededDragRadius=Coordinate$$module$build$src$core$utils$coordinate.magnitude(this.currentDragDeltaXY)>(this.flyout?$.config$$module$build$src$core$config.flyoutDragRadius:$.config$$module$build$src$core$config.dragRadius)}updateIsDraggingFromFlyout(){let a;if(!this.targetBlock||null==(a=this.flyout)||!a.isBlockCreatable(this.targetBlock))return!1;if(!this.flyout.targetWorkspace)throw Error("Cannot update dragging from the flyout because the ' +\n          'flyout's target workspace is undefined");
+return!this.flyout.isScrollable()||this.flyout.isDragTowardWorkspace(this.currentDragDeltaXY)?(this.startWorkspace_=this.flyout.targetWorkspace,this.startWorkspace_.updateScreenCalculationsIfScrolled(),$.getGroup$$module$build$src$core$events$utils()||$.setGroup$$module$build$src$core$events$utils(!0),this.startBlock=null,this.targetBlock=this.flyout.createBlock(this.targetBlock),this.targetBlock.select(),!0):!1}updateIsDraggingBubble(){if(!this.startBubble)return!1;this.startDraggingBubble();return!0}updateIsDraggingBlock(){if(!this.targetBlock)return!1;
+if(this.flyout){if(this.updateIsDraggingFromFlyout())return this.startDraggingBlock(),!0}else if(this.targetBlock.isMovable())return this.startDraggingBlock(),!0;return!1}updateIsDraggingWorkspace(){if(!this.startWorkspace_)throw Error("Cannot update dragging the workspace because the start workspace is undefined");if(this.flyout?this.flyout.isScrollable():this.startWorkspace_&&this.startWorkspace_.isDraggable())this.workspaceDragger=new WorkspaceDragger$$module$build$src$core$workspace_dragger(this.startWorkspace_),
+this.workspaceDragger.startDrag()}updateIsDragging(){if(this.calledUpdateIsDragging)throw Error("updateIsDragging_ should only be called once per gesture.");this.calledUpdateIsDragging=!0;this.updateIsDraggingBubble()||this.updateIsDraggingBlock()||this.updateIsDraggingWorkspace()}startDraggingBlock(){this.blockDragger=new (getClassFromOptions$$module$build$src$core$registry(Type$$module$build$src$core$registry.BLOCK_DRAGGER,this.creatorWorkspace.options,!0))(this.targetBlock,this.startWorkspace_);
+this.blockDragger.startDrag(this.currentDragDeltaXY,this.healStack);this.blockDragger.drag(this.mostRecentEvent,this.currentDragDeltaXY)}startDraggingBubble(){if(!this.startBubble)throw Error("Cannot update dragging the bubble because the start bubble is undefined");if(!this.startWorkspace_)throw Error("Cannot update dragging the bubble because the start workspace is undefined");this.bubbleDragger=new BubbleDragger$$module$build$src$core$bubble_dragger(this.startBubble,this.startWorkspace_);this.bubbleDragger.startBubbleDrag();
+this.bubbleDragger.dragBubble(this.mostRecentEvent,this.currentDragDeltaXY)}doStart(a){if(!this.startWorkspace_)throw Error("Cannot start the touch gesture becauase the start workspace is undefined");this.isPinchZoomEnabled=this.startWorkspace_.options.zoomOptions&&this.startWorkspace_.options.zoomOptions.pinch;isTargetInput$$module$build$src$core$browser_events(a)?this.cancel():(this.gestureHasStarted=!0,disconnectUiStop$$module$build$src$core$block_animations(),this.startWorkspace_.updateScreenCalculationsIfScrolled(),
+this.startWorkspace_.isMutator&&this.startWorkspace_.resize(),this.currentDropdownOwner=getOwner$$module$build$src$core$dropdowndiv(),this.startWorkspace_.hideChaff(!!this.flyout),this.startWorkspace_.markFocused(),this.mostRecentEvent=a,block$$module$build$src$core$tooltip(),this.targetBlock&&this.targetBlock.select(),isRightButton$$module$build$src$core$browser_events(a)?this.handleRightClick(a):("pointerdown"===a.type.toLowerCase()&&"mouse"!==a.pointerType&&longStart$$module$build$src$core$touch(a,
+this),this.mouseDownXY=new Coordinate$$module$build$src$core$utils$coordinate(a.clientX,a.clientY),this.healStack=a.altKey||a.ctrlKey||a.metaKey,this.bindMouseEvents(a),this.isEnding_||this.handleTouchStart(a)))}bindMouseEvents(a){this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(document,"pointerdown",null,this.handleStart.bind(this),!0));this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(document,"pointermove",null,this.handleMove.bind(this),!0));
+this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(document,"pointerup",null,this.handleUp.bind(this),!0));a.preventDefault();a.stopPropagation()}handleStart(a){this.isDragging()||(this.handleTouchStart(a),this.isMultiTouch()&&longStop$$module$build$src$core$touch())}handleMove(a){this.isDragging()&&shouldHandleEvent$$module$build$src$core$touch(a)||!this.isMultiTouch()?(this.updateFromEvent(a),this.workspaceDragger?this.workspaceDragger.drag(this.currentDragDeltaXY):this.blockDragger?
+this.blockDragger.drag(this.mostRecentEvent,this.currentDragDeltaXY):this.bubbleDragger&&this.bubbleDragger.dragBubble(this.mostRecentEvent,this.currentDragDeltaXY),a.preventDefault(),a.stopPropagation()):this.isMultiTouch()&&(this.handleTouchMove(a),longStop$$module$build$src$core$touch())}handleUp(a){this.isDragging()||this.handleTouchEnd(a);if(!this.isMultiTouch()||this.isDragging()){if(!shouldHandleEvent$$module$build$src$core$touch(a))return;this.updateFromEvent(a);longStop$$module$build$src$core$touch();
+if(this.isEnding_){console.log("Trying to end a gesture recursively.");return}this.isEnding_=!0;this.bubbleDragger?this.bubbleDragger.endBubbleDrag(a,this.currentDragDeltaXY):this.blockDragger?this.blockDragger.endDrag(a,this.currentDragDeltaXY):this.workspaceDragger?this.workspaceDragger.endDrag(this.currentDragDeltaXY):this.isBubbleClick()?this.doBubbleClick():this.isFieldClick()?this.doFieldClick():this.isIconClick()?this.doIconClick():this.isBlockClick()?this.doBlockClick():this.isWorkspaceClick()&&
+this.doWorkspaceClick(a)}a.preventDefault();a.stopPropagation();this.dispose()}handleTouchStart(a){var b=getTouchIdentifierFromEvent$$module$build$src$core$touch(a);this.cachedPoints.set(b,this.getTouchPoint(a));var c=Array.from(this.cachedPoints.keys());2===c.length&&(b=this.cachedPoints.get(c[0]),c=this.cachedPoints.get(c[1]),this.startDistance=Coordinate$$module$build$src$core$utils$coordinate.distance(b,c),this.isMultiTouch_=!0,a.preventDefault())}handleTouchMove(a){const b=getTouchIdentifierFromEvent$$module$build$src$core$touch(a);
+this.cachedPoints.set(b,this.getTouchPoint(a));this.isPinchZoomEnabled&&2===this.cachedPoints.size?this.handlePinch(a):this.handleMove(a)}handlePinch(a){var b=Array.from(this.cachedPoints.keys()),c=this.cachedPoints.get(b[0]);b=this.cachedPoints.get(b[1]);c=Coordinate$$module$build$src$core$utils$coordinate.distance(c,b)/this.startDistance;if(0this.previousScale){b=c-this.previousScale;b=0this.cachedPoints.size&&(this.cachedPoints.clear(),this.previousScale=0)}getTouchPoint(a){return this.startWorkspace_?
+new Coordinate$$module$build$src$core$utils$coordinate(a.pageX,a.pageY):null}isMultiTouch(){return this.isMultiTouch_}cancel(){this.isEnding_||(longStop$$module$build$src$core$touch(),this.bubbleDragger?this.bubbleDragger.endBubbleDrag(this.mostRecentEvent,this.currentDragDeltaXY):this.blockDragger?this.blockDragger.endDrag(this.mostRecentEvent,this.currentDragDeltaXY):this.workspaceDragger&&this.workspaceDragger.endDrag(this.currentDragDeltaXY),this.dispose())}handleRightClick(a){this.targetBlock?
+(this.bringBlockToFront(),this.targetBlock.workspace.hideChaff(!!this.flyout),this.targetBlock.showContextMenu(a)):this.startBubble?this.startBubble.showContextMenu(a):this.startWorkspace_&&!this.flyout&&(this.startWorkspace_.hideChaff(),this.startWorkspace_.showContextMenu(a));a.preventDefault();a.stopPropagation();this.dispose()}handleWsStart(a,b){if(this.gestureHasStarted)throw Error("Tried to call gesture.handleWsStart, but the gesture had already been started.");this.setStartWorkspace(b);this.mostRecentEvent=
+a;this.doStart(a)}fireWorkspaceClick(a){fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(CLICK$$module$build$src$core$events$utils))(null,a.id,"workspace"))}handleFlyoutStart(a,b){if(this.gestureHasStarted)throw Error("Tried to call gesture.handleFlyoutStart, but the gesture had already been started.");this.setStartFlyout(b);this.handleWsStart(a,b.getWorkspace())}handleBlockStart(a,b){if(this.gestureHasStarted)throw Error("Tried to call gesture.handleBlockStart, but the gesture had already been started.");
+this.setStartBlock(b);this.mostRecentEvent=a}handleBubbleStart(a,b){if(this.gestureHasStarted)throw Error("Tried to call gesture.handleBubbleStart, but the gesture had already been started.");this.setStartBubble(b);this.mostRecentEvent=a}doBubbleClick(){this.startBubble instanceof WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg&&(this.startBubble.setFocus(),this.startBubble.select())}doFieldClick(){if(!this.startField)throw Error("Cannot do a field click because the start field is undefined");
+this.currentDropdownOwner!==this.startField&&this.startField.showEditor(this.mostRecentEvent);this.bringBlockToFront()}doIconClick(){if(!this.startIcon)throw Error("Cannot do an icon click because the start icon is undefined");this.startIcon.onClick()}doBlockClick(){if(this.flyout&&this.flyout.autoClose){if(!this.targetBlock)throw Error("Cannot do a block click because the target block is undefined");this.targetBlock.isEnabled()&&($.getGroup$$module$build$src$core$events$utils()||$.setGroup$$module$build$src$core$events$utils(!0),
+this.flyout.createBlock(this.targetBlock).scheduleSnapAndBump())}else{if(!this.startWorkspace_)throw Error("Cannot do a block click because the start workspace is undefined");const a=new (get$$module$build$src$core$events$utils(CLICK$$module$build$src$core$events$utils))(this.startBlock,this.startWorkspace_.id,"block");fire$$module$build$src$core$events$utils(a)}this.bringBlockToFront();$.setGroup$$module$build$src$core$events$utils(!1)}doWorkspaceClick(a){a=this.creatorWorkspace;getSelected$$module$build$src$core$common()&&
+getSelected$$module$build$src$core$common().unselect();this.fireWorkspaceClick(this.startWorkspace_||a)}bringBlockToFront(){this.targetBlock&&!this.flyout&&this.targetBlock.bringToFront()}setStartField(a){if(this.gestureHasStarted)throw Error("Tried to call gesture.setStartField, but the gesture had already been started.");this.startField||(this.startField=a)}setStartIcon(a){if(this.gestureHasStarted)throw Error("Tried to call gesture.setStartIcon, but the gesture had already been started.");this.startIcon||
+(this.startIcon=a)}setStartBubble(a){this.startBubble||(this.startBubble=a)}setStartBlock(a){this.startBlock||this.startBubble||(this.startBlock=a,a.isInFlyout&&a!==a.getRootBlock()?this.setTargetBlock(a.getRootBlock()):this.setTargetBlock(a))}setTargetBlock(a){a.isShadow()?this.setTargetBlock(a.getParent()):this.targetBlock=a}setStartWorkspace(a){this.startWorkspace_||(this.startWorkspace_=a)}setStartFlyout(a){this.flyout||(this.flyout=a)}isBubbleClick(){return!!this.startBubble&&!this.hasExceededDragRadius}isBlockClick(){return!!this.startBlock&&
+!this.hasExceededDragRadius&&!this.isFieldClick()&&!this.isIconClick()}isFieldClick(){return(this.startField?this.startField.isClickable():!1)&&!this.hasExceededDragRadius&&(!this.flyout||!this.flyout.autoClose)}isIconClick(){return!!this.startIcon&&!this.hasExceededDragRadius}isWorkspaceClick(){return!this.startBlock&&!this.startBubble&&!this.startField&&!this.hasExceededDragRadius}isDragging(){return!!this.workspaceDragger||!!this.blockDragger||!!this.bubbleDragger}hasStarted(){return this.gestureHasStarted}getInsertionMarkers(){return this.blockDragger?
+this.blockDragger.getInsertionMarkers():[]}getCurrentDragger(){let a,b;return null!=(b=null!=(a=this.blockDragger)?a:this.workspaceDragger)?b:this.bubbleDragger}static inProgress(){const a=getAllWorkspaces$$module$build$src$core$common();for(let b=0,c;c=a[b];b++)if(c.currentGesture_)return!0;return!1}},module$build$src$core$gesture={};module$build$src$core$gesture.Gesture=Gesture$$module$build$src$core$gesture;var Grid$$module$build$src$core$grid=class{constructor(a,b){this.pattern=a;let c;this.spacing=null!=(c=b.spacing)?c:0;let d;this.length=null!=(d=b.length)?d:1;this.line2=(this.line1=a.firstChild)&&this.line1.nextSibling;let e;this.snapToGrid=null!=(e=b.snap)?e:!1}shouldSnap(){return this.snapToGrid}getSpacing(){return this.spacing}getPatternId(){return this.pattern.id}update(a){var b=this.spacing*a;this.pattern.setAttribute("width",`${b}`);this.pattern.setAttribute("height",`${b}`);b=Math.floor(this.spacing/
+2)+.5;let c=b-this.length/2,d=b+this.length/2;b*=a;c*=a;d*=a;this.setLineAttributes(this.line1,a,c,d,b,b);this.setLineAttributes(this.line2,a,b,b,c,d)}setLineAttributes(a,b,c,d,e,f){a&&(a.setAttribute("stroke-width",`${b}`),a.setAttribute("x1",`${c}`),a.setAttribute("y1",`${e}`),a.setAttribute("x2",`${d}`),a.setAttribute("y2",`${f}`))}moveTo(a,b){this.pattern.setAttribute("x",`${a}`);this.pattern.setAttribute("y",`${b}`)}static createDom(a,b,c){a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.PATTERN,
+{id:"blocklyGridPattern"+a,patternUnits:"userSpaceOnUse"},c);let d,e;if(0<(null!=(d=b.length)?d:1)&&0<(null!=(e=b.spacing)?e:0)){createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{stroke:b.colour},a);let f;null!=(f=b.length)&&f&&createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{stroke:b.colour},a)}else createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{},a);return a}},module$build$src$core$grid=
+{};module$build$src$core$grid.Grid=Grid$$module$build$src$core$grid;var MarkerManager$$module$build$src$core$marker_manager=class{constructor(a){this.workspace=a;this.cursorSvg_=this.cursor_=null;this.markers=new Map;this.markerSvg_=null}registerMarker(a,b){this.markers.has(a)&&this.unregisterMarker(a);b.setDrawer(this.workspace.getRenderer().makeMarkerDrawer(this.workspace,b));this.setMarkerSvg(b.getDrawer().createDom());this.markers.set(a,b)}unregisterMarker(a){const b=this.markers.get(a);if(b)b.dispose(),this.markers.delete(a);else throw Error("Marker with ID "+
+a+" does not exist. Can only unregister markers that exist.");}getCursor(){return this.cursor_}getMarker(a){return this.markers.get(a)||null}setCursor(a){this.cursor_&&this.cursor_.getDrawer()&&this.cursor_.getDrawer().dispose();if(this.cursor_=a)a=this.workspace.getRenderer().makeMarkerDrawer(this.workspace,this.cursor_),this.cursor_.setDrawer(a),this.setCursorSvg(this.cursor_.getDrawer().createDom())}setCursorSvg(a){a?(this.workspace.getBlockCanvas().appendChild(a),this.cursorSvg_=a):this.cursorSvg_=
+null}setMarkerSvg(a){a?this.workspace.getBlockCanvas()&&(this.cursorSvg_?this.workspace.getBlockCanvas().insertBefore(a,this.cursorSvg_):this.workspace.getBlockCanvas().appendChild(a)):this.markerSvg_=null}updateMarkers(){this.workspace.keyboardAccessibilityMode&&this.cursorSvg_&&this.workspace.getCursor().draw()}dispose(){const a=Object.keys(this.markers);for(let b=0,c;c=a[b];b++)this.unregisterMarker(c);this.markers.clear();this.cursor_&&(this.cursor_.dispose(),this.cursor_=null)}};
+MarkerManager$$module$build$src$core$marker_manager.LOCAL_MARKER="local_marker_1";var module$build$src$core$marker_manager={};module$build$src$core$marker_manager.MarkerManager=MarkerManager$$module$build$src$core$marker_manager;var module$build$src$core$utils$object={};module$build$src$core$utils$object.deepMerge=deepMerge$$module$build$src$core$utils$object;var Theme$$module$build$src$core$theme=class{constructor(a,b,c,d){this.name=a;this.startHats=!1;this.blockStyles=b||Object.create(null);this.categoryStyles=c||Object.create(null);this.componentStyles=d||Object.create(null);this.fontStyle=Object.create(null);register$$module$build$src$core$registry(Type$$module$build$src$core$registry.THEME,a,this,!0)}getClassName(){return this.name+"-theme"}setBlockStyle(a,b){this.blockStyles[a]=b}setCategoryStyle(a,b){this.categoryStyles[a]=b}getComponentStyle(a){a=
+this.componentStyles[a];if(!a)return null;if("string"===typeof a){const b=this.getComponentStyle(a);if(b)return b}return`${a}`}setComponentStyle(a,b){this.componentStyles[a]=b}setFontStyle(a){this.fontStyle=a}setStartHats(a){this.startHats=a}static defineTheme(a,b){a=a.toLowerCase();const c=new Theme$$module$build$src$core$theme(a);let d=b.base;if(d){if("string"===typeof d){let e;d=null!=(e=getObject$$module$build$src$core$registry(Type$$module$build$src$core$registry.THEME,d))?e:void 0}d instanceof
+Theme$$module$build$src$core$theme&&(deepMerge$$module$build$src$core$utils$object(c,d),c.name=a)}deepMerge$$module$build$src$core$utils$object(c.blockStyles,b.blockStyles);deepMerge$$module$build$src$core$utils$object(c.categoryStyles,b.categoryStyles);deepMerge$$module$build$src$core$utils$object(c.componentStyles,b.componentStyles);deepMerge$$module$build$src$core$utils$object(c.fontStyle,b.fontStyle);null!==b.startHats&&(c.startHats=b.startHats);return c}},module$build$src$core$theme={};
+module$build$src$core$theme.Theme=Theme$$module$build$src$core$theme;var defaultBlockStyles$$module$build$src$core$theme$classic={colour_blocks:{colourPrimary:"20"},list_blocks:{colourPrimary:"260"},logic_blocks:{colourPrimary:"210"},loop_blocks:{colourPrimary:"120"},math_blocks:{colourPrimary:"230"},procedure_blocks:{colourPrimary:"290"},text_blocks:{colourPrimary:"160"},variable_blocks:{colourPrimary:"330"},variable_dynamic_blocks:{colourPrimary:"310"},hat_blocks:{colourPrimary:"330",hat:"cap"}},categoryStyles$$module$build$src$core$theme$classic={colour_category:{colour:"20"},
+list_category:{colour:"260"},logic_category:{colour:"210"},loop_category:{colour:"120"},math_category:{colour:"230"},procedure_category:{colour:"290"},text_category:{colour:"160"},variable_category:{colour:"330"},variable_dynamic_category:{colour:"310"}},Classic$$module$build$src$core$theme$classic=new Theme$$module$build$src$core$theme("classic",defaultBlockStyles$$module$build$src$core$theme$classic,categoryStyles$$module$build$src$core$theme$classic),module$build$src$core$theme$classic={Classic:Classic$$module$build$src$core$theme$classic};var Options$$module$build$src$core$options=class{constructor(a){this.gridPattern=null;this.getMetrics=this.setMetrics=void 0;let b=null,c=!1;var d=!1,e=!1,f=!1,g=!1,h=!1;const k=!!a.readOnly;if(!k){var l;b=convertToolboxDefToJson$$module$build$src$core$utils$toolbox(null!=(l=a.toolbox)?l:null);c=hasCategories$$module$build$src$core$utils$toolbox(b);d=a.trashcan;d=void 0===d?c:d;e=a.collapse;e=void 0===e?c:e;f=a.comments;f=void 0===f?c:f;g=a.disable;g=void 0===g?c:g;h=a.sounds;h=void 0===h?!0:h}l=
+a.maxTrashcanContents;d?void 0===l&&(l=32):l=0;const m=!!a.rtl;let n=a.horizontalLayout;void 0===n&&(n=!1);var p="end"!==a.toolboxPosition;p=n?p?Position$$module$build$src$core$utils$toolbox.TOP:Position$$module$build$src$core$utils$toolbox.BOTTOM:p===m?Position$$module$build$src$core$utils$toolbox.RIGHT:Position$$module$build$src$core$utils$toolbox.LEFT;let q=a.css;void 0===q&&(q=!0);let r="https://blockly-demo.appspot.com/static/media/";a.media?r=a.media.endsWith("/")?a.media:a.media+"/":"path"in
+a&&(warn$$module$build$src$core$utils$deprecation("path","Nov 2014","Jul 2023","media"),r=a.path+"media/");const u=a.oneBasedIndex,y=a.renderer||"geras",z=a.plugins||{};let t=a.modalInputs;void 0===t&&(t=!0);this.RTL=m;this.oneBasedIndex=void 0===u?!0:u;this.collapse=e;this.comments=f;this.disable=g;this.readOnly=k;this.maxBlocks=a.maxBlocks||Infinity;let v;this.maxInstances=null!=(v=a.maxInstances)?v:null;this.modalInputs=t;this.pathToMedia=r;this.hasCategories=c;this.moveOptions=Options$$module$build$src$core$options.parseMoveOptions_(a,
+c);this.hasScrollbars=!!this.moveOptions.scrollbars;this.hasTrashcan=d;this.maxTrashcanContents=l;this.hasSounds=h;this.hasCss=q;this.horizontalLayout=n;this.languageTree=b;this.gridOptions=Options$$module$build$src$core$options.parseGridOptions_(a);this.zoomOptions=Options$$module$build$src$core$options.parseZoomOptions_(a);this.toolboxPosition=p;this.theme=Options$$module$build$src$core$options.parseThemeOptions_(a);this.renderer=y;let w;this.rendererOverrides=null!=(w=a.rendererOverrides)?w:null;
+let x;this.parentWorkspace=null!=(x=a.parentWorkspace)?x:null;this.plugins=z}static parseMoveOptions_(a,b){const c=a.move||{},d={};void 0===c.scrollbars&&void 0===a.scrollbars?d.scrollbars=b:"object"===typeof c.scrollbars?(d.scrollbars={horizontal:!!c.scrollbars.horizontal,vertical:!!c.scrollbars.vertical},d.scrollbars.horizontal&&d.scrollbars.vertical?d.scrollbars=!0:d.scrollbars.horizontal||d.scrollbars.vertical||(d.scrollbars=!1)):d.scrollbars=!!c.scrollbars||!!a.scrollbars;d.wheel=d.scrollbars&&
+void 0!==c.wheel?!!c.wheel:"object"===typeof d.scrollbars;d.drag=d.scrollbars?void 0===c.drag?!0:!!c.drag:!1;return d}static parseZoomOptions_(a){a=a.zoom||{};const b={};b.controls=void 0===a.controls?!1:!!a.controls;b.wheel=void 0===a.wheel?!1:!!a.wheel;b.startScale=void 0===a.startScale?1:Number(a.startScale);b.maxScale=void 0===a.maxScale?3:Number(a.maxScale);b.minScale=void 0===a.minScale?.3:Number(a.minScale);b.scaleSpeed=void 0===a.scaleSpeed?1.2:Number(a.scaleSpeed);b.pinch=void 0===a.pinch?
+b.wheel||b.controls:!!a.pinch;return b}static parseGridOptions_(a){a=a.grid||{};const b={};b.spacing=Number(a.spacing)||0;b.colour=a.colour||"#888";b.length=void 0===a.length?1:Number(a.length);b.snap=0"));fire$$module$build$src$core$events$utils(new BlockChange$$module$build$src$core$events$events_block_change(b,"mutation",null,c,a));break;default:console.warn("Unknown change type: "+this.element)}}static getExtraBlockState_(a){return a.saveExtraState?
+(a=a.saveExtraState(!0))?JSON.stringify(a):"":a.mutationToDom?(a=a.mutationToDom())?domToText$$module$build$src$core$xml(a):"":""}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,$.CHANGE$$module$build$src$core$events$utils,BlockChange$$module$build$src$core$events$events_block_change);var module$build$src$core$events$events_block_change={};module$build$src$core$events$events_block_change.BlockChange=BlockChange$$module$build$src$core$events$events_block_change;var hsvSaturation$$module$build$src$core$utils$colour=.45,hsvValue$$module$build$src$core$utils$colour=.65,names$$module$build$src$core$utils$colour={aqua:"#00ffff",black:"#000000",blue:"#0000ff",fuchsia:"#ff00ff",gray:"#808080",green:"#008000",lime:"#00ff00",maroon:"#800000",navy:"#000080",olive:"#808000",purple:"#800080",red:"#ff0000",silver:"#c0c0c0",teal:"#008080",white:"#ffffff",yellow:"#ffff00"},module$build$src$core$utils$colour={};module$build$src$core$utils$colour.blend=blend$$module$build$src$core$utils$colour;
+module$build$src$core$utils$colour.getHsvSaturation=getHsvSaturation$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.getHsvValue=getHsvValue$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.hexToRgb=hexToRgb$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.hsvToHex=hsvToHex$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.hueToHex=hueToHex$$module$build$src$core$utils$colour;
+module$build$src$core$utils$colour.names=names$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.parse=parse$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.rgbToHex=rgbToHex$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.setHsvSaturation=setHsvSaturation$$module$build$src$core$utils$colour;module$build$src$core$utils$colour.setHsvValue=setHsvValue$$module$build$src$core$utils$colour;var module$build$src$core$utils$parsing={};module$build$src$core$utils$parsing.checkMessageReferences=checkMessageReferences$$module$build$src$core$utils$parsing;module$build$src$core$utils$parsing.parseBlockColour=parseBlockColour$$module$build$src$core$utils$parsing;module$build$src$core$utils$parsing.replaceMessageReferences=replaceMessageReferences$$module$build$src$core$utils$parsing;module$build$src$core$utils$parsing.tokenizeInterpolation=tokenizeInterpolation$$module$build$src$core$utils$parsing;var Field$$module$build$src$core$field=class{constructor(a,b,c){this.DEFAULT_VALUE=null;this.name=void 0;this.constants_=this.mouseDownWrapper_=this.textContent_=this.textElement_=this.borderRect_=this.fieldGroup_=this.markerSvg_=this.cursorSvg_=this.tooltip_=this.validator_=null;this.disposed=!1;this.maxDisplayLength=50;this.sourceBlock_=null;this.enabled_=this.visible_=this.isDirty_=!0;this.suffixField=this.prefixField=this.clickTarget_=null;this.EDITABLE=!0;this.SERIALIZABLE=!1;this.CURSOR="";
+this.value_="DEFAULT_VALUE"in new.target.prototype?new.target.prototype.DEFAULT_VALUE:this.DEFAULT_VALUE;this.size_=new Size$$module$build$src$core$utils$size(0,0);a!==Field$$module$build$src$core$field.SKIP_SETUP&&(c&&this.configure_(c),this.setValue(a),b&&this.setValidator(b))}configure_(a){a.tooltip&&this.setTooltip(replaceMessageReferences$$module$build$src$core$utils$parsing(a.tooltip))}setSourceBlock(a){if(this.sourceBlock_)throw Error("Field already bound to a block");this.sourceBlock_=a}getConstants(){!this.constants_&&
+this.sourceBlock_&&!this.sourceBlock_.isDeadOrDying()&&this.sourceBlock_.workspace.rendered&&(this.constants_=this.sourceBlock_.workspace.getRenderer().getConstants());return this.constants_}getSourceBlock(){return this.sourceBlock_}init(){this.fieldGroup_||(this.fieldGroup_=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{}),this.isVisible()||(this.fieldGroup_.style.display="none"),this.sourceBlock_.getSvgRoot().appendChild(this.fieldGroup_),this.initView(),
+this.updateEditable(),this.setTooltip(this.tooltip_),this.bindEvents_(),this.initModel())}initView(){this.createBorderRect_();this.createTextElement_()}initModel(){}isFullBlockField(){return!this.borderRect_}createBorderRect_(){this.borderRect_=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{rx:this.getConstants().FIELD_BORDER_RECT_RADIUS,ry:this.getConstants().FIELD_BORDER_RECT_RADIUS,x:0,y:0,height:this.size_.height,width:this.size_.width,"class":"blocklyFieldRect"},
+this.fieldGroup_)}createTextElement_(){this.textElement_=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.TEXT,{"class":"blocklyText"},this.fieldGroup_);this.getConstants().FIELD_TEXT_BASELINE_CENTER&&this.textElement_.setAttribute("dominant-baseline","central");this.textContent_=document.createTextNode("");this.textElement_.appendChild(this.textContent_)}bindEvents_(){const a=this.getClickTarget_();if(!a)throw Error("A click target has not been set.");bindMouseEvents$$module$build$src$core$tooltip(a);
+this.mouseDownWrapper_=conditionalBind$$module$build$src$core$browser_events(a,"pointerdown",this,this.onMouseDown_)}fromXml(a){this.setValue(a.textContent)}toXml(a){a.textContent=this.getValue();return a}saveState(a){a=this.saveLegacyState(Field$$module$build$src$core$field);return null!==a?a:this.getValue()}loadState(a){this.loadLegacyState(Field$$module$build$src$core$field,a)||this.setValue(a)}saveLegacyState(a){return a.prototype.saveState===this.saveState&&a.prototype.toXml!==this.toXml?(a=
+$.createElement$$module$build$src$core$utils$xml("field"),a.setAttribute("name",this.name||""),domToText$$module$build$src$core$utils$xml(this.toXml(a)).replace(' xmlns="https://developers.google.com/blockly/xml"',"")):null}loadLegacyState(a,b){return a.prototype.loadState===this.loadState&&a.prototype.fromXml!==this.fromXml?(this.fromXml($.textToDom$$module$build$src$core$utils$xml(b)),!0):!1}dispose(){hideIfOwner$$module$build$src$core$dropdowndiv(this);hideIfOwner$$module$build$src$core$widgetdiv(this);
+let a;(null==(a=this.getSourceBlock())?0:a.isDeadOrDying())||removeNode$$module$build$src$core$utils$dom(this.fieldGroup_);this.disposed=!0}updateEditable(){const a=this.fieldGroup_,b=this.getSourceBlock();this.EDITABLE&&a&&b&&(this.enabled_&&b.isEditable()?(addClass$$module$build$src$core$utils$dom(a,"blocklyEditableText"),removeClass$$module$build$src$core$utils$dom(a,"blocklyNonEditableText"),a.style.cursor=this.CURSOR):(addClass$$module$build$src$core$utils$dom(a,"blocklyNonEditableText"),removeClass$$module$build$src$core$utils$dom(a,
+"blocklyEditableText"),a.style.cursor=""))}setEnabled(a){this.enabled_=a;this.updateEditable()}isEnabled(){return this.enabled_}isClickable(){return this.enabled_&&!!this.sourceBlock_&&this.sourceBlock_.isEditable()&&this.showEditor_!==Field$$module$build$src$core$field.prototype.showEditor_}isCurrentlyEditable(){return this.enabled_&&this.EDITABLE&&!!this.sourceBlock_&&this.sourceBlock_.isEditable()}isSerializable(){let a=!1;this.name&&(this.SERIALIZABLE?a=!0:this.EDITABLE&&(console.warn("Detected an editable field that was not serializable. Please define SERIALIZABLE property as true on all editable custom fields. Proceeding with serialization."),
+a=!0));return a}isVisible(){return this.visible_}setVisible(a){if(this.visible_!==a){this.visible_=a;var b=this.fieldGroup_;b&&(b.style.display=a?"block":"none")}}setValidator(a){this.validator_=a}getValidator(){return this.validator_}getSvgRoot(){return this.fieldGroup_}getBorderRect(){if(!this.borderRect_)throw Error(`The border rectangle is ${this.borderRect_}.`);return this.borderRect_}getTextElement(){if(!this.textElement_)throw Error(`The text element is ${this.textElement_}.`);return this.textElement_}getTextContent(){if(!this.textContent_)throw Error(`The text content is ${this.textContent_}.`);
+return this.textContent_}applyColour(){}render_(){this.textContent_&&(this.textContent_.nodeValue=this.getDisplayText_());this.updateSize_()}showEditor(a){this.isClickable()&&this.showEditor_(a)}showEditor_(a){}repositionForWindowResize(){return!1}updateSize_(a){const b=this.getConstants();a=void 0!==a?a:this.isFullBlockField()?0:this.getConstants().FIELD_BORDER_RECT_X_PADDING;let c=2*a,d=b.FIELD_TEXT_HEIGHT,e=0;this.textElement_&&(e=getFastTextWidth$$module$build$src$core$utils$dom(this.textElement_,
+b.FIELD_TEXT_FONTSIZE,b.FIELD_TEXT_FONTWEIGHT,b.FIELD_TEXT_FONTFAMILY),c+=e);this.isFullBlockField()||(d=Math.max(d,b.FIELD_BORDER_RECT_HEIGHT));this.size_.height=d;this.size_.width=c;this.positionTextElement_(a,e);this.positionBorderRect_()}positionTextElement_(a,b){if(this.textElement_){var c=this.getConstants(),d=this.size_.height/2,e;this.textElement_.setAttribute("x",String((null==(e=this.getSourceBlock())?0:e.RTL)?this.size_.width-b-a:a));this.textElement_.setAttribute("y",String(c.FIELD_TEXT_BASELINE_CENTER?
+d:d-c.FIELD_TEXT_HEIGHT/2+c.FIELD_TEXT_BASELINE))}}positionBorderRect_(){this.borderRect_&&(this.borderRect_.setAttribute("width",String(this.size_.width)),this.borderRect_.setAttribute("height",String(this.size_.height)),this.borderRect_.setAttribute("rx",String(this.getConstants().FIELD_BORDER_RECT_RADIUS)),this.borderRect_.setAttribute("ry",String(this.getConstants().FIELD_BORDER_RECT_RADIUS)))}getSize(){if(!this.isVisible())return new Size$$module$build$src$core$utils$size(0,0);this.isDirty_?
+(this.render_(),this.isDirty_=!1):this.visible_&&0===this.size_.width&&(this.render_(),0!==this.size_.width&&console.warn("Deprecated use of setting size_.width to 0 to rerender a field. Set field.isDirty_ to true instead."));return this.size_}getScaledBBox(){let a;var b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;if(this.isFullBlockField()){var c=this.sourceBlock_.getHeightWidth();const d=b.workspace.scale;a=this.getAbsoluteXY_();b=(c.width+1)*d;c=(c.height+
+1)*d;GECKO$$module$build$src$core$utils$useragent?(a.x+=1.5*d,a.y+=1.5*d):(a.x-=.5*d,a.y-=.5*d)}else c=this.borderRect_.getBoundingClientRect(),a=getPageOffset$$module$build$src$core$utils$style(this.borderRect_),b=c.width,c=c.height;return new Rect$$module$build$src$core$utils$rect(a.y,a.y+c,a.x,a.x+b)}getDisplayText_(){let a=this.getText();if(!a)return Field$$module$build$src$core$field.NBSP;a.length>this.maxDisplayLength&&(a=a.substring(0,this.maxDisplayLength-2)+"\u2026");a=a.replace(/\s/g,Field$$module$build$src$core$field.NBSP);
+this.sourceBlock_&&this.sourceBlock_.RTL&&(a+="\u200f");return a}getText(){const a=this.getText_();return null!==a?String(a):String(this.getValue())}getText_(){return null}markDirty(){this.isDirty_=!0;this.constants_=null}forceRerender(){this.isDirty_=!0;this.sourceBlock_&&this.sourceBlock_.rendered&&(this.sourceBlock_.queueRender(),this.sourceBlock_.bumpNeighbours())}setValue(a,b=!0){if(null!==a){var c=this.doClassValidation_(a);a=this.processValidation_(a,c);if(!(a instanceof Error)){var d;c=null==
+(d=this.getValidator())?void 0:d.call(this,a);d=this.processValidation_(a,c);d instanceof Error||(a=this.sourceBlock_,a&&a.disposed||(c=this.getValue(),c===d?this.doValueUpdate_(d):(this.doValueUpdate_(d),b&&a&&isEnabled$$module$build$src$core$events$utils()&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CHANGE$$module$build$src$core$events$utils))(a,"field",this.name||null,c,d)),this.isDirty_&&this.forceRerender())))}}}processValidation_(a,b){return null===
+b?(this.doValueInvalid_(a),this.isDirty_&&this.forceRerender(),Error()):void 0===b?a:b}getValue(){return this.value_}doClassValidation_(a){return null===a||void 0===a?null:a}doValueUpdate_(a){this.value_=a;this.isDirty_=!0}doValueInvalid_(a){}onMouseDown_(a){this.sourceBlock_&&!this.sourceBlock_.isDeadOrDying()&&(a=this.sourceBlock_.workspace.getGesture(a))&&a.setStartField(this)}setTooltip(a){a||""===a||(a=this.sourceBlock_);const b=this.getClickTarget_();b?b.tooltip=a:this.tooltip_=a}getTooltip(){const a=
+this.getClickTarget_();return a?getTooltipOfObject$$module$build$src$core$tooltip(a):getTooltipOfObject$$module$build$src$core$tooltip({tooltip:this.tooltip_})}getClickTarget_(){return this.clickTarget_||this.getSvgRoot()}getAbsoluteXY_(){return getPageOffset$$module$build$src$core$utils$style(this.getClickTarget_())}referencesVariables(){return!1}refreshVariableName(){}getParentInput(){let a=null;const b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;const c=
+b.inputList;for(let d=0;d
+b[1]===a)?a:(this.sourceBlock_&&console.warn("Cannot set the dropdown's value to an unavailable option. Block type: "+this.sourceBlock_.type+", Field name: "+this.name+", Value: "+a),null)}doValueUpdate_(a){super.doValueUpdate_(a);a=this.getOptions(!0);for(let b=0,c;c=a[b];b++)c[1]===this.value_&&(this.selectedOption=c)}applyColour(){const a=this.sourceBlock_.style;this.borderRect_&&(this.borderRect_.setAttribute("stroke",a.colourTertiary),this.menu_?this.borderRect_.setAttribute("fill",a.colourTertiary):
+this.borderRect_.setAttribute("fill","transparent"));this.sourceBlock_&&this.arrow&&(this.sourceBlock_.isShadow()?this.arrow.style.fill=a.colourSecondary:this.arrow.style.fill=a.colourPrimary)}render_(){this.getTextContent().nodeValue="";this.imageElement.style.display="none";const a=this.selectedOption&&this.selectedOption[0];a&&"object"===typeof a?this.renderSelectedImage(a):this.renderSelectedText();this.positionBorderRect_()}renderSelectedImage(a){const b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;
+this.imageElement.style.display="";this.imageElement.setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",a.src);this.imageElement.setAttribute("height",String(a.height));this.imageElement.setAttribute("width",String(a.width));const c=Number(a.height);a=Number(a.width);var d=!!this.borderRect_;const e=Math.max(d?this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT:0,c+IMAGE_Y_PADDING$$module$build$src$core$field_dropdown);d=d?this.getConstants().FIELD_BORDER_RECT_X_PADDING:0;let f;
+f=this.svgArrow?this.positionSVGArrow(a+d,e/2-this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE/2):getFastTextWidth$$module$build$src$core$utils$dom(this.arrow,this.getConstants().FIELD_TEXT_FONTSIZE,this.getConstants().FIELD_TEXT_FONTWEIGHT,this.getConstants().FIELD_TEXT_FONTFAMILY);this.size_.width=a+f+2*d;this.size_.height=e;let g=0;b.RTL?this.imageElement.setAttribute("x",`${d+f}`):(g=a+f,this.getTextElement().setAttribute("text-anchor","end"),this.imageElement.setAttribute("x",`${d}`));this.imageElement.setAttribute("y",
+String(e/2-c/2));this.positionTextElement_(g+d,a+f)}renderSelectedText(){this.getTextContent().nodeValue=this.getDisplayText_();var a=this.getTextElement();addClass$$module$build$src$core$utils$dom(a,"blocklyDropdownText");a.setAttribute("text-anchor","start");var b=!!this.borderRect_;a=Math.max(b?this.getConstants().FIELD_DROPDOWN_BORDER_RECT_HEIGHT:0,this.getConstants().FIELD_TEXT_HEIGHT);const c=getFastTextWidth$$module$build$src$core$utils$dom(this.getTextElement(),this.getConstants().FIELD_TEXT_FONTSIZE,
+this.getConstants().FIELD_TEXT_FONTWEIGHT,this.getConstants().FIELD_TEXT_FONTFAMILY);b=b?this.getConstants().FIELD_BORDER_RECT_X_PADDING:0;let d=0;this.svgArrow&&(d=this.positionSVGArrow(c+b,a/2-this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE/2));this.size_.width=c+d+2*b;this.size_.height=a;this.positionTextElement_(b,c)}positionSVGArrow(a,b){if(!this.svgArrow)return 0;const c=this.getSourceBlock();if(!c)throw new UnattachedFieldError$$module$build$src$core$field;const d=this.borderRect_?this.getConstants().FIELD_BORDER_RECT_X_PADDING:
+0,e=this.getConstants().FIELD_DROPDOWN_SVG_ARROW_PADDING,f=this.getConstants().FIELD_DROPDOWN_SVG_ARROW_SIZE;this.svgArrow.setAttribute("transform","translate("+(c.RTL?d:a+e)+","+b+")");return f+e}getText_(){if(!this.selectedOption)return null;const a=this.selectedOption[0];return"object"===typeof a?a.alt:a}static fromJson(a){if(!a.options)throw Error("options are required for the dropdown field. The options property must be assigned an array of [humanReadableValue, languageNeutralValue] tuples.");
+return new this(a.options,void 0,a)}};FieldDropdown$$module$build$src$core$field_dropdown.CHECKMARK_OVERHANG=25;FieldDropdown$$module$build$src$core$field_dropdown.MAX_MENU_HEIGHT_VH=.45;FieldDropdown$$module$build$src$core$field_dropdown.ARROW_CHAR="\u25be";var IMAGE_Y_OFFSET$$module$build$src$core$field_dropdown=5,IMAGE_Y_PADDING$$module$build$src$core$field_dropdown=2*IMAGE_Y_OFFSET$$module$build$src$core$field_dropdown;register$$module$build$src$core$field_registry("field_dropdown",FieldDropdown$$module$build$src$core$field_dropdown);
+var module$build$src$core$field_dropdown={};module$build$src$core$field_dropdown.FieldDropdown=FieldDropdown$$module$build$src$core$field_dropdown;var _a$$module$build$src$core$bubbles$bubble,Bubble$$module$build$src$core$bubbles$bubble=class{constructor(a,b,c){this.workspace=a;this.anchor=b;this.ownerRect=c;this.size=new Size$$module$build$src$core$utils$size(0,0);this.colour="#ffffff";this.disposed=!1;this.relativeLeft=this.relativeTop=0;this.svgRoot=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{},a.getBubbleCanvas());a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,
+{filter:`url(#${this.workspace.getRenderer().getConstants().embossFilterId})`},this.svgRoot);this.tail=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.PATH,{},a);this.background=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{"class":"blocklyDraggable",x:0,y:0,rx:_a$$module$build$src$core$bubbles$bubble.BORDER_WIDTH,ry:_a$$module$build$src$core$bubbles$bubble.BORDER_WIDTH},a);this.contentContainer=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,
+{},this.svgRoot);conditionalBind$$module$build$src$core$browser_events(this.background,"pointerdown",this,this.onMouseDown)}dispose(){removeNode$$module$build$src$core$utils$dom(this.svgRoot);this.disposed=!0}setAnchorLocation(a,b=!1){this.anchor=a;b?this.positionByRect(this.ownerRect):this.positionRelativeToAnchor();this.renderTail()}setPositionRelativeToAnchor(a,b){this.relativeLeft=a;this.relativeTop=b;this.positionRelativeToAnchor();this.renderTail()}getSize(){return this.size}setSize(a,b=!1){a.width=
+Math.max(a.width,_a$$module$build$src$core$bubbles$bubble.MIN_SIZE);a.height=Math.max(a.height,_a$$module$build$src$core$bubbles$bubble.MIN_SIZE);this.size=a;this.background.setAttribute("width",`${a.width}`);this.background.setAttribute("height",`${a.height}`);b?this.positionByRect(this.ownerRect):this.positionRelativeToAnchor();this.renderTail()}getColour(){return this.colour}setColour(a){this.colour=a;this.tail.setAttribute("fill",a);this.background.setAttribute("fill",a)}onMouseDown(a){let b;
+null==(b=this.workspace.getGesture(a))||b.handleBubbleStart(a,this)}positionRelativeToAnchor(){let a=this.anchor.x;a=this.workspace.RTL?a-(this.relativeLeft+this.size.width):a+this.relativeLeft;this.moveTo(a,this.relativeTop+this.anchor.y)}moveTo(a,b){this.svgRoot.setAttribute("transform",`translate(${a}, ${b})`)}positionByRect(a=new Rect$$module$build$src$core$utils$rect(0,0,0,0)){var b=this.workspace.getMetricsManager().getViewMetrics(!0),c=this.getOptimalRelativeLeft(b),d=this.getOptimalRelativeTop(b);
+const e={x:c,y:-this.size.height-this.workspace.getRenderer().getConstants().MIN_BLOCK_HEIGHT},f={x:-this.size.width-30,y:d};d={x:a.getWidth(),y:d};var g={x:c,y:a.getHeight()};c=a.getWidth()a.width)return b;a=this.getWorkspaceViewRect(a);
+if(this.workspace.RTL){var c=this.anchor.x-b;c-this.size.widtha.right&&(b=-(a.right-this.anchor.x))}else{c=b+this.anchor.x;const d=c+this.size.width;ca.right&&(b=a.right-this.anchor.x-this.size.width)}return b}getOptimalRelativeTop(a){let b=-this.size.height/4;if(this.size.height>a.height)return b;const c=this.anchor.y+b,d=c+this.size.height;a=this.getWorkspaceViewRect(a);ca.bottom&&
+(b=a.bottom-this.anchor.y-this.size.height);return b}getWorkspaceViewRect(a){const b=a.top;let c=a.top+a.height,d=a.left;a=a.left+a.width;c-=this.getScrollbarThickness();this.workspace.RTL?d-=this.getScrollbarThickness():a-=this.getScrollbarThickness();return new Rect$$module$build$src$core$utils$rect(b,c,d,a)}getScrollbarThickness(){return Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness/this.workspace.scale}renderTail(){const a=[];var b=this.size.width/2,c=this.size.height/2,d=-this.relativeLeft,
+e=-this.relativeTop;if(b===d&&c===e)a.push("M "+b+","+c);else{e-=c;d-=b;this.workspace.RTL&&(d*=-1);var f=Math.sqrt(e*e+d*d),g=Math.acos(d/f);0>e&&(g=2*Math.PI-g);var h=g+Math.PI/2;h>2*Math.PI&&(h-=2*Math.PI);var k=Math.sin(h);const m=Math.cos(h);let n=(this.size.width+this.size.height)/_a$$module$build$src$core$bubbles$bubble.TAIL_THICKNESS;n=Math.min(n,this.size.width,this.size.height)/4;h=1-_a$$module$build$src$core$bubbles$bubble.ANCHOR_RADIUS/f;d=b+h*d;e=c+h*e;h=b+n*m;const p=c+n*k;b-=n*m;c-=
+n*k;k=toRadians$$module$build$src$core$utils$math(this.workspace.RTL?-_a$$module$build$src$core$bubbles$bubble.TAIL_ANGLE:_a$$module$build$src$core$bubbles$bubble.TAIL_ANGLE);k=g+k;k>2*Math.PI&&(k-=2*Math.PI);g=Math.sin(k)*f/_a$$module$build$src$core$bubbles$bubble.TAIL_BEND;f=Math.cos(k)*f/_a$$module$build$src$core$bubbles$bubble.TAIL_BEND;a.push("M"+h+","+p);a.push("C"+(h+f)+","+(p+g)+" "+d+","+e+" "+d+","+e);a.push("C"+d+","+e+" "+(b+f)+","+(c+g)+" "+b+","+c)}a.push("z");let l;null==(l=this.tail)||
+l.setAttribute("d",a.join(" "))}bringToFront(){let a;const b=null==(a=this.svgRoot)?void 0:a.parentNode;return this.svgRoot&&(null==b?void 0:b.lastChild)!==this.svgRoot?(null==b||b.appendChild(this.svgRoot),!0):!1}getRelativeToSurfaceXY(){return new Coordinate$$module$build$src$core$utils$coordinate(this.workspace.RTL?-this.relativeLeft+this.anchor.x-this.size.width:this.anchor.x+this.relativeLeft,this.anchor.y+this.relativeTop)}getSvgRoot(){return this.svgRoot}moveDuringDrag(a){this.moveTo(a.x,a.y);
+this.relativeLeft=this.workspace.RTL?this.anchor.x-a.x-this.size.width:a.x-this.anchor.x;this.relativeTop=a.y-this.anchor.y;this.renderTail()}setDragging(a){}setDeleteStyle(a){}isDeletable(){return!1}showContextMenu(a){}};_a$$module$build$src$core$bubbles$bubble=Bubble$$module$build$src$core$bubbles$bubble;Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH=6;Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER=2*_a$$module$build$src$core$bubbles$bubble.BORDER_WIDTH;
+Bubble$$module$build$src$core$bubbles$bubble.MIN_SIZE=_a$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER;Bubble$$module$build$src$core$bubbles$bubble.TAIL_THICKNESS=1;Bubble$$module$build$src$core$bubbles$bubble.TAIL_ANGLE=20;Bubble$$module$build$src$core$bubbles$bubble.TAIL_BEND=4;Bubble$$module$build$src$core$bubbles$bubble.ANCHOR_RADIUS=8;var module$build$src$core$bubbles$bubble={};module$build$src$core$bubbles$bubble.Bubble=Bubble$$module$build$src$core$bubbles$bubble;var MiniWorkspaceBubble$$module$build$src$core$bubbles$mini_workspace_bubble=class extends Bubble$$module$build$src$core$bubbles$bubble{constructor(a,b,c,d){super(b,c,d);this.workspace=b;this.anchor=c;this.ownerRect=d;this.autoLayout=!0;b=new Options$$module$build$src$core$options(a);this.validateWorkspaceOptions(b);this.svgDialog=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.SVG,{x:Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH,y:Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH},
+this.contentContainer);a.parentWorkspace=this.workspace;this.miniWorkspace=this.newWorkspaceSvg(new Options$$module$build$src$core$options(a));this.miniWorkspace.internalIsMutator=!0;a=this.miniWorkspace.createDom("blocklyMutatorBackground");this.svgDialog.appendChild(a);b.languageTree&&(a.insertBefore(this.miniWorkspace.addFlyout(Svg$$module$build$src$core$utils$svg.G),this.miniWorkspace.getCanvas()),a=this.miniWorkspace.getFlyout(),null==a||a.init(this.miniWorkspace),null==a||a.show(b.languageTree));
+this.miniWorkspace.addChangeListener(this.onWorkspaceChange.bind(this));let e,f;null==(e=this.miniWorkspace.getFlyout())||null==(f=e.getWorkspace())||f.addChangeListener(this.onWorkspaceChange.bind(this));this.updateBubbleSize()}dispose(){this.miniWorkspace.dispose();super.dispose()}getWorkspace(){return this.miniWorkspace}addWorkspaceChangeListener(a){this.miniWorkspace.addChangeListener(a)}validateWorkspaceOptions(a){if(a.hasCategories)throw Error("The miniworkspace bubble does not support toolboxes with categories");
+if(a.hasTrashcan)throw Error("The miniworkspace bubble does not support trashcans");if(a.zoomOptions.controls||a.zoomOptions.wheel||a.zoomOptions.pinch)throw Error("The miniworkspace bubble does not support zooming");if(a.moveOptions.scrollbars||a.moveOptions.wheel||a.moveOptions.drag)throw Error("The miniworkspace bubble does not scrolling/moving the workspace");if(a.horizontalLayout)throw Error("The miniworkspace bubble does not support horizontal layouts");}onWorkspaceChange(){this.bumpBlocksIntoBounds();
+this.updateBubbleSize()}bumpBlocksIntoBounds(){if(!this.miniWorkspace.isDragging())for(const a of this.miniWorkspace.getTopBlocks(!1)){const b=a.getRelativeToSurfaceXY();20>b.y&&a.moveBy(0,20-b.y);if(a.RTL){let c=-20;const d=this.miniWorkspace.getFlyout();d&&(c-=d.getWidth());b.x>c&&a.moveBy(c-b.x,0)}else 20>b.x&&a.moveBy(20-b.x,0)}}updateBubbleSize(){if(!this.miniWorkspace.isDragging()){var a=this.getSize(),b=this.calculateWorkspaceSize();Math.abs(a.width-b.width)({kind:"block",type:c}))});return b}getAnchorLocation(){const a=SIZE$$module$build$src$core$icons$mutator_icon/2;return Coordinate$$module$build$src$core$utils$coordinate.sum(this.workspaceLocation,new Coordinate$$module$build$src$core$utils$coordinate(a,a))}getBubbleOwnerRect(){const a=this.sourceBlock.getSvgRoot().getBBox();return new Rect$$module$build$src$core$utils$rect(a.y,a.y+a.height,
+a.x,a.x+a.width)}createRootBlock(){if(!this.sourceBlock.decompose)throw Error("Blocks with mutator icons must include a decompose method");this.rootBlock=this.sourceBlock.decompose(this.miniWorkspaceBubble.getWorkspace());for(var a of this.rootBlock.getDescendants(!1))a.queueRender();this.rootBlock.setMovable(!1);this.rootBlock.setDeletable(!1);let b,c,d,e;a=null!=(e=null==(b=this.miniWorkspaceBubble)?void 0:null==(c=b.getWorkspace())?void 0:null==(d=c.getFlyout())?void 0:d.getWidth())?e:0;this.rootBlock.moveBy(this.rootBlock.RTL?
+-(a+WORKSPACE_MARGIN$$module$build$src$core$icons$mutator_icon):WORKSPACE_MARGIN$$module$build$src$core$icons$mutator_icon,WORKSPACE_MARGIN$$module$build$src$core$icons$mutator_icon)}addSaveConnectionsListener(){this.sourceBlock.saveConnections&&this.rootBlock&&(this.saveConnectionsListener=()=>{this.sourceBlock.saveConnections&&this.rootBlock&&this.sourceBlock.saveConnections(this.rootBlock)},this.saveConnectionsListener(),this.sourceBlock.workspace.addChangeListener(this.saveConnectionsListener))}createMiniWorkspaceChangeListener(){return a=>
+{$.MutatorIcon$$module$build$src$core$icons$mutator_icon.isIgnorableMutatorEvent(a)||this.updateWorkspacePid||(this.updateWorkspacePid=setTimeout(()=>{this.updateWorkspacePid=null;this.recomposeSourceBlock()},0))}}static isIgnorableMutatorEvent(a){return a.isUiEvent||a.type===$.CREATE$$module$build$src$core$events$utils||a.type===$.CHANGE$$module$build$src$core$events$utils&&"disabled"===a.element}recomposeSourceBlock(){if(this.rootBlock){if(!this.sourceBlock.compose)throw Error("Blocks with mutator icons must include a compose method");
+var a=$.getGroup$$module$build$src$core$events$utils();a||$.setGroup$$module$build$src$core$events$utils(!0);var b=BlockChange$$module$build$src$core$events$events_block_change.getExtraBlockState_(this.sourceBlock);this.sourceBlock.compose(this.rootBlock);var c=BlockChange$$module$build$src$core$events$events_block_change.getExtraBlockState_(this.sourceBlock);b!==c&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CHANGE$$module$build$src$core$events$utils))(this.sourceBlock,
+"mutation",null,b,c));$.setGroup$$module$build$src$core$events$utils(a)}}getWorkspace(){let a;return null==(a=this.miniWorkspaceBubble)?void 0:a.getWorkspace()}static reconnect(a,b,c){warn$$module$build$src$core$utils$deprecation("MutatorIcon.reconnect","v10","v11","connection.reconnect");return a?a.reconnect(b,c):!1}static findParentWs(a){warn$$module$build$src$core$utils$deprecation("MutatorIcon.findParentWs","v10","v11","workspace.getRootWorkspace");return a.getRootWorkspace()}};
+$.MutatorIcon$$module$build$src$core$icons$mutator_icon.TYPE=IconType$$module$build$src$core$icons$icon_types.MUTATOR;$.MutatorIcon$$module$build$src$core$icons$mutator_icon.WEIGHT=1;var module$build$src$core$icons$mutator_icon={};module$build$src$core$icons$mutator_icon.MutatorIcon=$.MutatorIcon$$module$build$src$core$icons$mutator_icon;var allExtensions$$module$build$src$core$extensions=Object.create(null),TEST_ONLY$$module$build$src$core$extensions={allExtensions:allExtensions$$module$build$src$core$extensions};$.register$$module$build$src$core$extensions("parent_tooltip_when_inline",extensionParentTooltip$$module$build$src$core$extensions);var module$build$src$core$extensions={TEST_ONLY:TEST_ONLY$$module$build$src$core$extensions};module$build$src$core$extensions.apply=apply$$module$build$src$core$extensions;
+module$build$src$core$extensions.buildTooltipForDropdown=$.buildTooltipForDropdown$$module$build$src$core$extensions;module$build$src$core$extensions.buildTooltipWithFieldText=$.buildTooltipWithFieldText$$module$build$src$core$extensions;module$build$src$core$extensions.isRegistered=isRegistered$$module$build$src$core$extensions;module$build$src$core$extensions.register=$.register$$module$build$src$core$extensions;module$build$src$core$extensions.registerMixin=$.registerMixin$$module$build$src$core$extensions;
+module$build$src$core$extensions.registerMutator=$.registerMutator$$module$build$src$core$extensions;module$build$src$core$extensions.runAfterPageLoad=runAfterPageLoad$$module$build$src$core$extensions;module$build$src$core$extensions.unregister=unregister$$module$build$src$core$extensions;var KeyCodes$$module$build$src$core$utils$keycodes;
+(function(a){a[a.WIN_KEY_FF_LINUX=0]="WIN_KEY_FF_LINUX";a[a.MAC_ENTER=3]="MAC_ENTER";a[a.BACKSPACE=8]="BACKSPACE";a[a.TAB=9]="TAB";a[a.NUM_CENTER=12]="NUM_CENTER";a[a.ENTER=13]="ENTER";a[a.SHIFT=16]="SHIFT";a[a.CTRL=17]="CTRL";a[a.ALT=18]="ALT";a[a.PAUSE=19]="PAUSE";a[a.CAPS_LOCK=20]="CAPS_LOCK";a[a.ESC=27]="ESC";a[a.SPACE=32]="SPACE";a[a.PAGE_UP=33]="PAGE_UP";a[a.PAGE_DOWN=34]="PAGE_DOWN";a[a.END=35]="END";a[a.HOME=36]="HOME";a[a.LEFT=37]="LEFT";a[a.UP=38]="UP";a[a.RIGHT=39]="RIGHT";a[a.DOWN=40]=
+"DOWN";a[a.PLUS_SIGN=43]="PLUS_SIGN";a[a.PRINT_SCREEN=44]="PRINT_SCREEN";a[a.INSERT=45]="INSERT";a[a.DELETE=46]="DELETE";a[a.ZERO=48]="ZERO";a[a.ONE=49]="ONE";a[a.TWO=50]="TWO";a[a.THREE=51]="THREE";a[a.FOUR=52]="FOUR";a[a.FIVE=53]="FIVE";a[a.SIX=54]="SIX";a[a.SEVEN=55]="SEVEN";a[a.EIGHT=56]="EIGHT";a[a.NINE=57]="NINE";a[a.FF_SEMICOLON=59]="FF_SEMICOLON";a[a.FF_EQUALS=61]="FF_EQUALS";a[a.FF_DASH=173]="FF_DASH";a[a.FF_HASH=163]="FF_HASH";a[a.QUESTION_MARK=63]="QUESTION_MARK";a[a.AT_SIGN=64]="AT_SIGN";
+a[a.A=65]="A";a[a.B=66]="B";a[a.C=67]="C";a[a.D=68]="D";a[a.E=69]="E";a[a.F=70]="F";a[a.G=71]="G";a[a.H=72]="H";a[a.I=73]="I";a[a.J=74]="J";a[a.K=75]="K";a[a.L=76]="L";a[a.M=77]="M";a[a.N=78]="N";a[a.O=79]="O";a[a.P=80]="P";a[a.Q=81]="Q";a[a.R=82]="R";a[a.S=83]="S";a[a.T=84]="T";a[a.U=85]="U";a[a.V=86]="V";a[a.W=87]="W";a[a.X=88]="X";a[a.Y=89]="Y";a[a.Z=90]="Z";a[a.META=91]="META";a[a.WIN_KEY_RIGHT=92]="WIN_KEY_RIGHT";a[a.CONTEXT_MENU=93]="CONTEXT_MENU";a[a.NUM_ZERO=96]="NUM_ZERO";a[a.NUM_ONE=97]=
+"NUM_ONE";a[a.NUM_TWO=98]="NUM_TWO";a[a.NUM_THREE=99]="NUM_THREE";a[a.NUM_FOUR=100]="NUM_FOUR";a[a.NUM_FIVE=101]="NUM_FIVE";a[a.NUM_SIX=102]="NUM_SIX";a[a.NUM_SEVEN=103]="NUM_SEVEN";a[a.NUM_EIGHT=104]="NUM_EIGHT";a[a.NUM_NINE=105]="NUM_NINE";a[a.NUM_MULTIPLY=106]="NUM_MULTIPLY";a[a.NUM_PLUS=107]="NUM_PLUS";a[a.NUM_MINUS=109]="NUM_MINUS";a[a.NUM_PERIOD=110]="NUM_PERIOD";a[a.NUM_DIVISION=111]="NUM_DIVISION";a[a.F1=112]="F1";a[a.F2=113]="F2";a[a.F3=114]="F3";a[a.F4=115]="F4";a[a.F5=116]="F5";a[a.F6=
+117]="F6";a[a.F7=118]="F7";a[a.F8=119]="F8";a[a.F9=120]="F9";a[a.F10=121]="F10";a[a.F11=122]="F11";a[a.F12=123]="F12";a[a.NUMLOCK=144]="NUMLOCK";a[a.SCROLL_LOCK=145]="SCROLL_LOCK";a[a.FIRST_MEDIA_KEY=166]="FIRST_MEDIA_KEY";a[a.LAST_MEDIA_KEY=183]="LAST_MEDIA_KEY";a[a.SEMICOLON=186]="SEMICOLON";a[a.DASH=189]="DASH";a[a.EQUALS=187]="EQUALS";a[a.COMMA=188]="COMMA";a[a.PERIOD=190]="PERIOD";a[a.SLASH=191]="SLASH";a[a.APOSTROPHE=192]="APOSTROPHE";a[a.TILDE=192]="TILDE";a[a.SINGLE_QUOTE=222]="SINGLE_QUOTE";
+a[a.OPEN_SQUARE_BRACKET=219]="OPEN_SQUARE_BRACKET";a[a.BACKSLASH=220]="BACKSLASH";a[a.CLOSE_SQUARE_BRACKET=221]="CLOSE_SQUARE_BRACKET";a[a.WIN_KEY=224]="WIN_KEY";a[a.MAC_FF_META=224]="MAC_FF_META";a[a.MAC_WK_CMD_LEFT=91]="MAC_WK_CMD_LEFT";a[a.MAC_WK_CMD_RIGHT=93]="MAC_WK_CMD_RIGHT";a[a.WIN_IME=229]="WIN_IME";a[a.VK_NONAME=252]="VK_NONAME";a[a.PHANTOM=255]="PHANTOM"})(KeyCodes$$module$build$src$core$utils$keycodes||(KeyCodes$$module$build$src$core$utils$keycodes={}));
+var module$build$src$core$utils$keycodes={};module$build$src$core$utils$keycodes.KeyCodes=KeyCodes$$module$build$src$core$utils$keycodes;var module$build$src$core$utils$svg_paths={};module$build$src$core$utils$svg_paths.arc=arc$$module$build$src$core$utils$svg_paths;module$build$src$core$utils$svg_paths.curve=curve$$module$build$src$core$utils$svg_paths;module$build$src$core$utils$svg_paths.line=line$$module$build$src$core$utils$svg_paths;module$build$src$core$utils$svg_paths.lineOnAxis=lineOnAxis$$module$build$src$core$utils$svg_paths;module$build$src$core$utils$svg_paths.lineTo=lineTo$$module$build$src$core$utils$svg_paths;
+module$build$src$core$utils$svg_paths.moveBy=moveBy$$module$build$src$core$utils$svg_paths;module$build$src$core$utils$svg_paths.moveTo=moveTo$$module$build$src$core$utils$svg_paths;module$build$src$core$utils$svg_paths.point=point$$module$build$src$core$utils$svg_paths;var module$build$src$core$utils={};module$build$src$core$utils.Coordinate=Coordinate$$module$build$src$core$utils$coordinate;module$build$src$core$utils.KeyCodes=KeyCodes$$module$build$src$core$utils$keycodes;module$build$src$core$utils.Rect=Rect$$module$build$src$core$utils$rect;module$build$src$core$utils.Size=Size$$module$build$src$core$utils$size;module$build$src$core$utils.Svg=Svg$$module$build$src$core$utils$svg;module$build$src$core$utils.aria=module$build$src$core$utils$aria;
+module$build$src$core$utils.array=module$build$src$core$utils$array;module$build$src$core$utils.browserEvents=module$build$src$core$browser_events;module$build$src$core$utils.colour=module$build$src$core$utils$colour;module$build$src$core$utils.deprecation=module$build$src$core$utils$deprecation;module$build$src$core$utils.dom=module$build$src$core$utils$dom;module$build$src$core$utils.extensions=module$build$src$core$extensions;module$build$src$core$utils.idGenerator=module$build$src$core$utils$idgenerator;
+module$build$src$core$utils.math=module$build$src$core$utils$math;module$build$src$core$utils.object=module$build$src$core$utils$object;module$build$src$core$utils.parsing=module$build$src$core$utils$parsing;module$build$src$core$utils.string=module$build$src$core$utils$string;module$build$src$core$utils.style=module$build$src$core$utils$style;module$build$src$core$utils.svgMath=module$build$src$core$utils$svg_math;module$build$src$core$utils.svgPaths=module$build$src$core$utils$svg_paths;
+module$build$src$core$utils.toolbox=module$build$src$core$utils$toolbox;module$build$src$core$utils.userAgent=module$build$src$core$utils$useragent;module$build$src$core$utils.xml=module$build$src$core$utils$xml;var module$build$src$core$icons$registry={};module$build$src$core$icons$registry.register=register$$module$build$src$core$icons$registry;module$build$src$core$icons$registry.unregister=unregister$$module$build$src$core$icons$registry;var TextBubble$$module$build$src$core$bubbles$text_bubble=class extends Bubble$$module$build$src$core$bubbles$bubble{constructor(a,b,c,d){super(b,c,d);this.text=a;this.workspace=b;this.anchor=c;this.ownerRect=d;this.paragraph=this.stringToSvg(a,this.contentContainer);this.updateBubbleSize()}getText(){return this.text}setText(a){this.text=a;removeNode$$module$build$src$core$utils$dom(this.paragraph);this.paragraph=this.stringToSvg(a,this.contentContainer);this.updateBubbleSize()}stringToSvg(a,b){b=
+this.createParagraph(b);a=this.createSpans(b,a);this.workspace.RTL&&this.rightAlignSpans(b.getBBox().width,a);return b}createParagraph(a){return createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.TEXT,{"class":"blocklyText blocklyBubbleText blocklyNoPointerEvents",y:Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH},a)}createSpans(a,b){return b.split("\n").map(c=>{const d=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.TSPAN,
+{dy:"1em",x:Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH},a);c=document.createTextNode(c);d.appendChild(c);return d})}rightAlignSpans(a,b){for(const c of b)c.setAttribute("text-anchor","end"),c.setAttribute("x",`${a+Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH}`)}updateBubbleSize(){const a=this.paragraph.getBBox();this.setSize(new Size$$module$build$src$core$utils$size(a.width+2*Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH,a.height+2*Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH),
+!0)}},module$build$src$core$bubbles$text_bubble={};module$build$src$core$bubbles$text_bubble.TextBubble=TextBubble$$module$build$src$core$bubbles$text_bubble;var TextInputBubble$$module$build$src$core$bubbles$textinput_bubble=class extends Bubble$$module$build$src$core$bubbles$bubble{constructor(a,b,c){super(a,b,c);this.workspace=a;this.anchor=b;this.ownerRect=c;this.resizePointerMoveListener=this.resizePointerUpListener=null;this.textChangeListeners=[];this.sizeChangeListeners=[];this.text="";this.DEFAULT_SIZE=new Size$$module$build$src$core$utils$size(160+Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER,80+Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER);
+this.MIN_SIZE=new Size$$module$build$src$core$utils$size(45+Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER,20+Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER);({inputRoot:this.inputRoot,textArea:this.textArea}=this.createEditor(this.contentContainer));this.resizeGroup=this.createResizeHandle(this.svgRoot);this.setSize(this.DEFAULT_SIZE,!0)}getText(){return this.text}setText(a){this.text=a;this.textArea.value=a;this.onTextChange()}addTextChangeListener(a){this.textChangeListeners.push(a)}addSizeChangeListener(a){this.sizeChangeListeners.push(a)}createEditor(a){a=
+createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FOREIGNOBJECT,{x:Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH,y:Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH},a);const b=document.createElementNS(HTML_NS$$module$build$src$core$utils$dom,"body");b.setAttribute("xmlns",HTML_NS$$module$build$src$core$utils$dom);b.className="blocklyMinimalBody";const c=document.createElementNS(HTML_NS$$module$build$src$core$utils$dom,"textarea");c.className=
+"blocklyCommentTextarea";c.setAttribute("dir",this.workspace.RTL?"RTL":"LTR");b.appendChild(c);a.appendChild(b);this.bindTextAreaEvents(c);setTimeout(()=>{c.focus()},0);return{inputRoot:a,textArea:c}}bindTextAreaEvents(a){conditionalBind$$module$build$src$core$browser_events(a,"wheel",this,b=>{b.stopPropagation()});conditionalBind$$module$build$src$core$browser_events(a,"focus",this,this.onStartEdit,!0);conditionalBind$$module$build$src$core$browser_events(a,"change",this,this.onTextChange)}createResizeHandle(a){a=
+createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":this.workspace.RTL?"blocklyResizeSW":"blocklyResizeSE"},a);const b=2*Bubble$$module$build$src$core$bubbles$bubble.BORDER_WIDTH;createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.POLYGON,{points:`0,${b} ${b},${b} ${b},0`},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{"class":"blocklyResizeLine",x1:b/3,y1:b-1,x2:b-1,y2:b/
+3},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{"class":"blocklyResizeLine",x1:2*b/3,y1:b-1,x2:b-1,y2:2*b/3},a);conditionalBind$$module$build$src$core$browser_events(a,"pointerdown",this,this.onResizePointerDown);return a}setSize(a,b=!1){a.width=Math.max(a.width,this.MIN_SIZE.width);a.height=Math.max(a.height,this.MIN_SIZE.height);const c=a.width-Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER,d=a.height-Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER;
+this.inputRoot.setAttribute("width",`${c}`);this.inputRoot.setAttribute("height",`${d}`);this.textArea.style.width=`${c-4}px`;this.textArea.style.height=`${d-4}px`;this.workspace.RTL?this.resizeGroup.setAttribute("transform",`translate(${Bubble$$module$build$src$core$bubbles$bubble.DOUBLE_BORDER}, ${d}) scale(-1 1)`):this.resizeGroup.setAttribute("transform",`translate(${c}, ${d})`);super.setSize(a,b);this.onSizeChange()}getSize(){return super.getSize()}onResizePointerDown(a){this.bringToFront();
+isRightButton$$module$build$src$core$browser_events(a)||(this.workspace.startDrag(a,new Coordinate$$module$build$src$core$utils$coordinate(this.workspace.RTL?-this.getSize().width:this.getSize().width,this.getSize().height)),this.resizePointerUpListener=conditionalBind$$module$build$src$core$browser_events(document,"pointerup",this,this.onResizePointerUp),this.resizePointerMoveListener=conditionalBind$$module$build$src$core$browser_events(document,"pointermove",this,this.onResizePointerMove),this.workspace.hideChaff());
+a.stopPropagation()}onResizePointerUp(a){clearTouchIdentifier$$module$build$src$core$touch();this.resizePointerUpListener&&(unbind$$module$build$src$core$browser_events(this.resizePointerUpListener),this.resizePointerUpListener=null);this.resizePointerMoveListener&&(unbind$$module$build$src$core$browser_events(this.resizePointerMoveListener),this.resizePointerMoveListener=null)}onResizePointerMove(a){a=this.workspace.moveDrag(a);this.setSize(new Size$$module$build$src$core$utils$size(this.workspace.RTL?
+-a.x:a.x,a.y),!1);this.onSizeChange()}onStartEdit(){this.bringToFront()&&this.textArea.focus()}onTextChange(){this.text=this.textArea.value;for(const a of this.textChangeListeners)a()}onSizeChange(){for(const a of this.sizeChangeListeners)a()}};register$$module$build$src$core$css("\n.blocklyCommentTextarea {\n  background-color: #fef49c;\n  border: 0;\n  display: block;\n  margin: 0;\n  outline: 0;\n  padding: 3px;\n  resize: none;\n  text-overflow: hidden;\n}\n");
+var module$build$src$core$bubbles$textinput_bubble={};module$build$src$core$bubbles$textinput_bubble.TextInputBubble=TextInputBubble$$module$build$src$core$bubbles$textinput_bubble;var SIZE$$module$build$src$core$icons$comment_icon=17,DEFAULT_BUBBLE_WIDTH$$module$build$src$core$icons$comment_icon=160,DEFAULT_BUBBLE_HEIGHT$$module$build$src$core$icons$comment_icon=80,CommentIcon$$module$build$src$core$icons$comment_icon=class extends Icon$$module$build$src$core$icons$icon{constructor(a){super(a);this.sourceBlock=a;this.textBubble=this.textInputBubble=null;this.text="";this.bubbleSize=new Size$$module$build$src$core$utils$size(DEFAULT_BUBBLE_WIDTH$$module$build$src$core$icons$comment_icon,
+DEFAULT_BUBBLE_HEIGHT$$module$build$src$core$icons$comment_icon);this.bubbleVisiblity=!1}getType(){return CommentIcon$$module$build$src$core$icons$comment_icon.TYPE}initView(a){this.svgRoot||(super.initView(a),createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CIRCLE,{"class":"blocklyIconShape",r:"8",cx:"8",cy:"8"},this.svgRoot),createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.PATH,{"class":"blocklyIconSymbol",d:"m6.8,10h2c0.003,-0.617 0.271,-0.962 0.633,-1.266 2.875,-2.4050.607,-5.534 -3.765,-3.874v1.7c3.12,-1.657 3.698,0.118 2.336,1.25-1.201,0.998 -1.201,1.528 -1.204,2.19z"},
+this.svgRoot),createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{"class":"blocklyIconSymbol",x:"6.8",y:"10.78",height:"2",width:"2"},this.svgRoot))}dispose(){super.dispose();let a;null==(a=this.textInputBubble)||a.dispose();let b;null==(b=this.textBubble)||b.dispose()}getWeight(){return CommentIcon$$module$build$src$core$icons$comment_icon.WEIGHT}getSize(){return new Size$$module$build$src$core$utils$size(SIZE$$module$build$src$core$icons$comment_icon,SIZE$$module$build$src$core$icons$comment_icon)}applyColour(){super.applyColour();
+const a=this.sourceBlock.style.colourPrimary;let b;null==(b=this.textInputBubble)||b.setColour(a);let c;null==(c=this.textBubble)||c.setColour(a)}updateEditable(){super.updateEditable();this.bubbleIsVisible()&&(this.setBubbleVisible(!1),this.setBubbleVisible(!0))}onLocationChange(a){super.onLocationChange(a);a=this.getAnchorLocation();let b;null==(b=this.textInputBubble)||b.setAnchorLocation(a);let c;null==(c=this.textBubble)||c.setAnchorLocation(a)}setText(a){this.text=a;let b;null==(b=this.textInputBubble)||
+b.setText(this.text);let c;null==(c=this.textBubble)||c.setText(this.text)}getText(){return this.text}setBubbleSize(a){this.bubbleSize=a;let b;null==(b=this.textInputBubble)||b.setSize(this.bubbleSize,!0)}getBubbleSize(){return this.bubbleSize}saveState(){return this.text?{text:this.text,pinned:this.bubbleIsVisible(),height:this.bubbleSize.height,width:this.bubbleSize.width}:null}loadState(a){let b;this.text=null!=(b=a.text)?b:"";let c,d;this.bubbleSize=new Size$$module$build$src$core$utils$size(null!=
+(c=a.width)?c:DEFAULT_BUBBLE_WIDTH$$module$build$src$core$icons$comment_icon,null!=(d=a.height)?d:DEFAULT_BUBBLE_HEIGHT$$module$build$src$core$icons$comment_icon);let e;this.bubbleVisiblity=null!=(e=a.pinned)?e:!1;setTimeout(()=>this.setBubbleVisible(this.bubbleVisiblity),1)}onClick(){super.onClick();this.setBubbleVisible(!this.bubbleIsVisible())}onTextChange(){this.textInputBubble&&(this.text=this.textInputBubble.getText())}onSizeChange(){this.textInputBubble&&(this.bubbleSize=this.textInputBubble.getSize())}bubbleIsVisible(){return this.bubbleVisiblity}setBubbleVisible(a){if(!a||
+!this.textBubble&&!this.textInputBubble)if(a||this.textBubble||this.textInputBubble)this.bubbleVisiblity=a,this.sourceBlock.rendered&&!this.sourceBlock.isInFlyout&&(a?(this.sourceBlock.isEditable()?this.showEditableBubble():this.showNonEditableBubble(),this.applyColour()):this.hideBubble(),fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(BUBBLE_OPEN$$module$build$src$core$events$utils))(this.sourceBlock,a,"comment")))}showEditableBubble(){this.textInputBubble=
+new TextInputBubble$$module$build$src$core$bubbles$textinput_bubble(this.sourceBlock.workspace,this.getAnchorLocation(),this.getBubbleOwnerRect());this.textInputBubble.setText(this.getText());this.textInputBubble.setSize(this.bubbleSize,!0);this.textInputBubble.addTextChangeListener(()=>this.onTextChange());this.textInputBubble.addSizeChangeListener(()=>this.onSizeChange())}showNonEditableBubble(){this.textBubble=new TextBubble$$module$build$src$core$bubbles$text_bubble(this.getText(),this.sourceBlock.workspace,
+this.getAnchorLocation(),this.getBubbleOwnerRect())}hideBubble(){let a;null==(a=this.textInputBubble)||a.dispose();this.textInputBubble=null;let b;null==(b=this.textBubble)||b.dispose();this.textBubble=null}getAnchorLocation(){const a=SIZE$$module$build$src$core$icons$comment_icon/2;return Coordinate$$module$build$src$core$utils$coordinate.sum(this.workspaceLocation,new Coordinate$$module$build$src$core$utils$coordinate(a,a))}getBubbleOwnerRect(){const a=this.sourceBlock.getSvgRoot().getBBox();return new Rect$$module$build$src$core$utils$rect(a.y,
+a.y+a.height,a.x,a.x+a.width)}};CommentIcon$$module$build$src$core$icons$comment_icon.TYPE=IconType$$module$build$src$core$icons$icon_types.COMMENT;CommentIcon$$module$build$src$core$icons$comment_icon.WEIGHT=3;register$$module$build$src$core$icons$registry(CommentIcon$$module$build$src$core$icons$comment_icon.TYPE,CommentIcon$$module$build$src$core$icons$comment_icon);var module$build$src$core$icons$comment_icon={};module$build$src$core$icons$comment_icon.CommentIcon=CommentIcon$$module$build$src$core$icons$comment_icon;var SIZE$$module$build$src$core$icons$warning_icon=17,WarningIcon$$module$build$src$core$icons$warning_icon=class extends Icon$$module$build$src$core$icons$icon{constructor(a){super(a);this.sourceBlock=a;this.textMap=new Map;this.textBubble=null}getType(){return WarningIcon$$module$build$src$core$icons$warning_icon.TYPE}initView(a){this.svgRoot||(super.initView(a),createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.PATH,{"class":"blocklyIconShape",d:"M2,15Q-1,15 0.5,12L6.5,1.7Q8,-1 9.5,1.7L15.5,12Q17,15 14,15z"},
+this.svgRoot),createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.PATH,{"class":"blocklyIconSymbol",d:"m7,4.8v3.16l0.27,2.27h1.46l0.27,-2.27v-3.16z"},this.svgRoot),createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{"class":"blocklyIconSymbol",x:"7",y:"11",height:"2",width:"2"},this.svgRoot))}dispose(){super.dispose();let a;null==(a=this.textBubble)||a.dispose()}getWeight(){return WarningIcon$$module$build$src$core$icons$warning_icon.WEIGHT}getSize(){return new Size$$module$build$src$core$utils$size(SIZE$$module$build$src$core$icons$warning_icon,
+SIZE$$module$build$src$core$icons$warning_icon)}applyColour(){super.applyColour();let a;null==(a=this.textBubble)||a.setColour(this.sourceBlock.style.colourPrimary)}updateCollapsed(){}isShownWhenCollapsed(){return!0}onLocationChange(a){super.onLocationChange(a);let b;null==(b=this.textBubble)||b.setAnchorLocation(this.getAnchorLocation())}addMessage(a,b){if(this.textMap.get(b)===a)return this;a?this.textMap.set(b,a):this.textMap.delete(b);let c;null==(c=this.textBubble)||c.setText(this.getText());
+return this}getText(){return[...this.textMap.values()].join("\n")}onClick(){super.onClick();this.setBubbleVisible(!this.bubbleIsVisible())}bubbleIsVisible(){return!!this.textBubble}setBubbleVisible(a){if(this.bubbleIsVisible()!==a){if(a)this.textBubble=new TextBubble$$module$build$src$core$bubbles$text_bubble(this.getText(),this.sourceBlock.workspace,this.getAnchorLocation(),this.getBubbleOwnerRect()),this.applyColour();else{let b;null==(b=this.textBubble)||b.dispose();this.textBubble=null}fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(BUBBLE_OPEN$$module$build$src$core$events$utils))(this.sourceBlock,
+a,"warning"))}}getAnchorLocation(){const a=SIZE$$module$build$src$core$icons$warning_icon/2;return Coordinate$$module$build$src$core$utils$coordinate.sum(this.workspaceLocation,new Coordinate$$module$build$src$core$utils$coordinate(a,a))}getBubbleOwnerRect(){const a=this.sourceBlock.getSvgRoot().getBBox();return new Rect$$module$build$src$core$utils$rect(a.y,a.y+a.height,a.x,a.x+a.width)}};WarningIcon$$module$build$src$core$icons$warning_icon.TYPE=IconType$$module$build$src$core$icons$icon_types.WARNING;
+WarningIcon$$module$build$src$core$icons$warning_icon.WEIGHT=2;var module$build$src$core$icons$warning_icon={};module$build$src$core$icons$warning_icon.WarningIcon=WarningIcon$$module$build$src$core$icons$warning_icon;var DuplicateIconType$$module$build$src$core$icons$exceptions=class extends Error{constructor(a){super(`Tried to append an icon of type ${a.getType()} when an icon of `+"that type already exists on the block. Use getIcon to access the existing icon.");this.icon=a}},module$build$src$core$icons$exceptions={};module$build$src$core$icons$exceptions.DuplicateIconType=DuplicateIconType$$module$build$src$core$icons$exceptions;var module$build$src$core$icons={};module$build$src$core$icons.CommentIcon=CommentIcon$$module$build$src$core$icons$comment_icon;module$build$src$core$icons.Icon=Icon$$module$build$src$core$icons$icon;module$build$src$core$icons.IconType=IconType$$module$build$src$core$icons$icon_types;module$build$src$core$icons.MutatorIcon=$.MutatorIcon$$module$build$src$core$icons$mutator_icon;module$build$src$core$icons.WarningIcon=WarningIcon$$module$build$src$core$icons$warning_icon;
+module$build$src$core$icons.exceptions=module$build$src$core$icons$exceptions;module$build$src$core$icons.registry=module$build$src$core$icons$registry;var CATEGORY_NAME$$module$build$src$core$procedures,module$build$src$core$procedures;CATEGORY_NAME$$module$build$src$core$procedures="PROCEDURE";$.DEFAULT_ARG$$module$build$src$core$procedures="x";module$build$src$core$procedures={CATEGORY_NAME:CATEGORY_NAME$$module$build$src$core$procedures,DEFAULT_ARG:$.DEFAULT_ARG$$module$build$src$core$procedures};module$build$src$core$procedures.ObservableProcedureMap=ObservableProcedureMap$$module$build$src$core$observable_procedure_map;
+module$build$src$core$procedures.allProcedures=allProcedures$$module$build$src$core$procedures;module$build$src$core$procedures.findLegalName=$.findLegalName$$module$build$src$core$procedures;module$build$src$core$procedures.flyoutCategory=flyoutCategory$$module$build$src$core$procedures;module$build$src$core$procedures.getCallers=getCallers$$module$build$src$core$procedures;module$build$src$core$procedures.getDefinition=$.getDefinition$$module$build$src$core$procedures;
+module$build$src$core$procedures.isNameUsed=isNameUsed$$module$build$src$core$procedures;module$build$src$core$procedures.isProcedureBlock=isProcedureBlock$$module$build$src$core$interfaces$i_procedure_block;module$build$src$core$procedures.mutateCallers=$.mutateCallers$$module$build$src$core$procedures;module$build$src$core$procedures.mutatorOpenListener=mutatorOpenListener$$module$build$src$core$procedures;module$build$src$core$procedures.rename=$.rename$$module$build$src$core$procedures;var TypesContainer$$module$build$src$core$renderers$measurables$types=class{constructor(){this.NONE=0;this.FIELD=1;this.HAT=2;this.ICON=4;this.SPACER=8;this.BETWEEN_ROW_SPACER=16;this.IN_ROW_SPACER=32;this.EXTERNAL_VALUE_INPUT=64;this.INPUT=128;this.INLINE_INPUT=256;this.STATEMENT_INPUT=512;this.CONNECTION=1024;this.PREVIOUS_CONNECTION=2048;this.NEXT_CONNECTION=4096;this.OUTPUT_CONNECTION=8192;this.CORNER=16384;this.LEFT_SQUARE_CORNER=32768;this.LEFT_ROUND_CORNER=65536;this.RIGHT_SQUARE_CORNER=131072;
+this.RIGHT_ROUND_CORNER=262144;this.JAGGED_EDGE=524288;this.ROW=1048576;this.TOP_ROW=2097152;this.BOTTOM_ROW=4194304;this.INPUT_ROW=8388608;this.LEFT_CORNER=this.LEFT_SQUARE_CORNER|this.LEFT_ROUND_CORNER;this.RIGHT_CORNER=this.RIGHT_SQUARE_CORNER|this.RIGHT_ROUND_CORNER;this.nextTypeValue_=16777216}getType(a){Object.prototype.hasOwnProperty.call(this,a)||(this[a]=this.nextTypeValue_,this.nextTypeValue_<<=1);return this[a]}isField(a){return a.type&this.FIELD}isHat(a){return a.type&this.HAT}isIcon(a){return a.type&
+this.ICON}isSpacer(a){return a.type&this.SPACER}isInRowSpacer(a){return a.type&this.IN_ROW_SPACER}isInput(a){return a.type&this.INPUT}isExternalInput(a){return a.type&this.EXTERNAL_VALUE_INPUT}isInlineInput(a){return a.type&this.INLINE_INPUT}isStatementInput(a){return a.type&this.STATEMENT_INPUT}isPreviousConnection(a){return a.type&this.PREVIOUS_CONNECTION}isNextConnection(a){return a.type&this.NEXT_CONNECTION}isPreviousOrNextConnection(a){return a.type&(this.PREVIOUS_CONNECTION|this.NEXT_CONNECTION)}isLeftRoundedCorner(a){return a.type&
+this.LEFT_ROUND_CORNER}isRightRoundedCorner(a){return a.type&this.RIGHT_ROUND_CORNER}isLeftSquareCorner(a){return a.type&this.LEFT_SQUARE_CORNER}isRightSquareCorner(a){return a.type&this.RIGHT_SQUARE_CORNER}isCorner(a){return a.type&this.CORNER}isJaggedEdge(a){return a.type&this.JAGGED_EDGE}isRow(a){return a.type&this.ROW}isBetweenRowSpacer(a){return a.type&this.BETWEEN_ROW_SPACER}isTopRow(a){return a.type&this.TOP_ROW}isBottomRow(a){return a.type&this.BOTTOM_ROW}isTopOrBottomRow(a){return a.type&
+(this.TOP_ROW|this.BOTTOM_ROW)}isInputRow(a){return a.type&this.INPUT_ROW}},Types$$module$build$src$core$renderers$measurables$types=new TypesContainer$$module$build$src$core$renderers$measurables$types,module$build$src$core$renderers$measurables$types={Types:Types$$module$build$src$core$renderers$measurables$types};var Measurable$$module$build$src$core$renderers$measurables$base=class{constructor(a){this.centerline=this.xPos=this.height=this.width=0;this.constants_=a;this.type=Types$$module$build$src$core$renderers$measurables$types.NONE;this.notchOffset=this.constants_.NOTCH_OFFSET_LEFT}},module$build$src$core$renderers$measurables$base={};module$build$src$core$renderers$measurables$base.Measurable=Measurable$$module$build$src$core$renderers$measurables$base;var Row$$module$build$src$core$renderers$measurables$row=class{constructor(a){this.elements=[];this.xPos=this.yPos=this.widthWithConnectedBlocks=this.minWidth=this.minHeight=this.width=this.height=0;this.hasStatement=this.hasExternalInput=!1;this.statementEdge=0;this.hasJaggedEdge=this.hasDummyInput=this.hasInlineInput=!1;this.align=null;this.constants_=a;this.type=Types$$module$build$src$core$renderers$measurables$types.ROW;this.notchOffset=this.constants_.NOTCH_OFFSET_LEFT}getLastInput(){for(let a=
+this.elements.length-1;0<=a;a--){const b=this.elements[a];if(Types$$module$build$src$core$renderers$measurables$types.isInput(b))return b}return null}measure(){throw Error("Unexpected attempt to measure a base Row.");}startsWithElemSpacer(){return!0}endsWithElemSpacer(){return!0}getFirstSpacer(){for(let a=0;arect,`,`${a} .blocklyEditableText>rect {`,`fill: ${this.FIELD_BORDER_RECT_COLOUR};`,"fill-opacity: .6;","stroke: none;","}",`${a} .blocklyNonEditableText>text,`,`${a} .blocklyEditableText>text {`,"fill: #000;","}",`${a} .blocklyFlyoutLabelText {`,"fill: #000;","}",`${a} .blocklyText.blocklyBubbleText {`,"fill: #000;","}",`${a} .blocklyEditableText:not(.editing):hover>rect {`,"stroke: #fff;","stroke-width: 2;","}",`${a} .blocklyHtmlInput {`,
+`font-family: ${this.FIELD_TEXT_FONTFAMILY};`,`font-weight: ${this.FIELD_TEXT_FONTWEIGHT};`,"}",`${a} .blocklySelected>.blocklyPath {`,"stroke: #fc3;","stroke-width: 3px;","}",`${a} .blocklyHighlightedConnectionPath {`,"stroke: #fc3;","}",`${a} .blocklyReplaceable .blocklyPath {`,"fill-opacity: .5;","}",`${a} .blocklyReplaceable .blocklyPathLight,`,`${a} .blocklyReplaceable .blocklyPathDark {`,"display: none;","}",`${a} .blocklyInsertionMarker>.blocklyPath {`,`fill-opacity: ${this.INSERTION_MARKER_OPACITY};`,
+"stroke: none;","}"]}},module$build$src$core$renderers$common$constants={};module$build$src$core$renderers$common$constants.ConstantProvider=ConstantProvider$$module$build$src$core$renderers$common$constants;module$build$src$core$renderers$common$constants.isDynamicShape=isDynamicShape$$module$build$src$core$renderers$common$constants;var Drawer$$module$build$src$core$renderers$common$drawer=class{constructor(a,b){this.inlinePath_=this.outlinePath_="";this.block_=a;this.info_=b;this.topLeft_=a.getRelativeToSurfaceXY();this.constants_=b.getRenderer().getConstants()}draw(){this.drawOutline_();this.drawInternals_();this.block_.pathObject.setPath(this.outlinePath_+"\n"+this.inlinePath_);this.info_.RTL&&this.block_.pathObject.flipRTL();this.recordSizeOnBlock_()}hideHiddenIcons_(){warn$$module$build$src$core$utils$deprecation("hideHiddenIcons_",
+"v10","v11")}recordSizeOnBlock_(){this.block_.height=this.info_.height;this.block_.width=this.info_.widthWithChildren}drawOutline_(){this.drawTop_();for(let a=1;aa||a>this.fieldRow.length)throw Error("index "+a+" out of bounds.");if(!(b||
+""===b&&c))return a;"string"===typeof b&&(b=$.fromJson$$module$build$src$core$field_registry({type:"field_label",text:b}));b.setSourceBlock(this.sourceBlock);this.sourceBlock.rendered&&(b.init(),b.applyColour());b.name=c;b.setVisible(this.isVisible());b.prefixField&&(a=this.insertFieldAt(a,b.prefixField));this.fieldRow.splice(a,0,b);a++;b.suffixField&&(a=this.insertFieldAt(a,b.suffixField));this.sourceBlock.rendered&&(this.sourceBlock.queueRender(),this.sourceBlock.bumpNeighbours());return a}removeField(a,
+b){for(let c=0,d;d=this.fieldRow[c];c++)if(d.name===a)return d.dispose(),this.fieldRow.splice(c,1),this.sourceBlock.rendered&&(this.sourceBlock.queueRender(),this.sourceBlock.bumpNeighbours()),!0;if(b)return!1;throw Error('Field "'+a+'" not found.');}isVisible(){return this.visible}setVisible(a){let b=[];if(this.visible===a)return b;this.visible=a;for(let d=0,e;e=this.fieldRow[d];d++)e.setVisible(a);if(this.connection){var c=this.connection;a?b=c.startTrackingAll():c.stopTrackingAll();if(c=c.targetBlock())c.getSvgRoot().style.display=
+a?"block":"none"}return b}markDirty(){for(let a=0,b;b=this.fieldRow[a];a++)b.markDirty()}setCheck(a){if(!this.connection)throw Error("This input does not have a connection.");this.connection.setCheck(a);return this}setAlign(a){this.align=a;this.sourceBlock.rendered&&this.sourceBlock.queueRender();return this}setShadowDom(a){if(!this.connection)throw Error("This input does not have a connection.");this.connection.setShadowDom(a);return this}getShadowDom(){if(!this.connection)throw Error("This input does not have a connection.");
+return this.connection.getShadowDom()}init(){if(this.sourceBlock.workspace.rendered)for(let a=0;a{connectionUiEffect$$module$build$src$core$block_animations(c.getSourceBlock());setTimeout(()=>{d.bringToFront()},0)})}}}update(a,b){const c=this.getCandidate(a);if((this.wouldDeleteBlock=this.shouldDelete(!!c,b))||this.shouldUpdatePreviews(c,
+a))$.disable$$module$build$src$core$events$utils(),this.maybeHidePreview(c),this.maybeShowPreview(c),$.enable$$module$build$src$core$events$utils()}createMarkerBlock(a){var b=a.type;$.disable$$module$build$src$core$events$utils();let c;try{c=this.workspace.newBlock(b);c.setInsertionMarker(!0);if(a.saveExtraState){var d=a.saveExtraState(!0);d&&c.loadExtraState&&c.loadExtraState(d)}else if(a.mutationToDom){const e=a.mutationToDom();e&&c.domToMutation&&c.domToMutation(e)}for(b=0;b{let k;null==(k=d)||k.positionNearConnection(h,f,g);let l;null==(l=d)||l.getSvgRoot().setAttribute("visibility","visible")});this.markerConnection=e}hideInsertionMarker(){if(this.markerConnection){var a=this.markerConnection,b=a.getSourceBlock(),c=b.outputConnection,d;if((null==(d=b.previousConnection)?0:d.targetConnection)||(null==c?0:c.targetConnection))b.unplug(!0);
+else{let e;null==(e=a.targetBlock())||e.unplug(!1)}if(a.targetConnection)throw Error("markerConnection still connected at the end of disconnectInsertionMarker");this.markerConnection=null;(a=b.getSvgRoot())&&a.setAttribute("visibility","hidden")}}showInsertionInputOutline(a){a=a.closest;this.highlightedBlock=a.getSourceBlock();this.highlightedBlock.highlightShapeForInput(a,!0)}hideInsertionInputOutline(){if(this.highlightedBlock){if(!this.activeCandidate)throw Error("Cannot hide the insertion marker outline because there is no active candidate");
+this.highlightedBlock.highlightShapeForInput(this.activeCandidate.closest,!1);this.highlightedBlock=null}}showReplacementFade(a){this.fadedBlock=a.closest.targetBlock();if(!this.fadedBlock)throw Error("Cannot show the replacement fade because the closest connection does not have a target block");this.fadedBlock.fadeForReplacement(!0)}hideReplacementFade(){this.fadedBlock&&(this.fadedBlock.fadeForReplacement(!1),this.fadedBlock=null)}getInsertionMarkers(){const a=[];this.firstMarker&&a.push(this.firstMarker);
+this.lastMarker&&a.push(this.lastMarker);return a}disposeInsertionMarker(a){if(a){$.disable$$module$build$src$core$events$utils();try{a.dispose()}finally{$.enable$$module$build$src$core$events$utils()}}}};
+(function(a){a=a.PREVIEW_TYPE||(a.PREVIEW_TYPE={});a[a.INSERTION_MARKER=0]="INSERTION_MARKER";a[a.INPUT_OUTLINE=1]="INPUT_OUTLINE";a[a.REPLACEMENT_FADE=2]="REPLACEMENT_FADE"})(InsertionMarkerManager$$module$build$src$core$insertion_marker_manager||(InsertionMarkerManager$$module$build$src$core$insertion_marker_manager={}));
+var PreviewType$$module$build$src$core$insertion_marker_manager=InsertionMarkerManager$$module$build$src$core$insertion_marker_manager.PREVIEW_TYPE,module$build$src$core$insertion_marker_manager={};module$build$src$core$insertion_marker_manager.InsertionMarkerManager=InsertionMarkerManager$$module$build$src$core$insertion_marker_manager;module$build$src$core$insertion_marker_manager.PreviewType=PreviewType$$module$build$src$core$insertion_marker_manager;var Renderer$$module$build$src$core$renderers$common$renderer=class{constructor(a){this.overrides=null;this.name=a}getClassName(){return this.name+"-renderer"}init(a,b){this.constants_=this.makeConstants_();b&&(this.overrides=b,Object.assign(this.constants_,b));this.constants_.setTheme(a);this.constants_.init()}createDom(a,b){this.constants_.createDom(a,this.name+"-"+b.name,"."+this.getClassName()+"."+b.getClassName())}refreshDom(a,b){const c=this.getConstants();c.dispose();this.constants_=this.makeConstants_();
+this.overrides&&Object.assign(this.constants_,this.overrides);this.constants_.randomIdentifier=c.randomIdentifier;this.constants_.setTheme(b);this.constants_.init();this.createDom(a,b)}dispose(){this.constants_&&this.constants_.dispose()}makeConstants_(){return new ConstantProvider$$module$build$src$core$renderers$common$constants}makeRenderInfo_(a){return new RenderInfo$$module$build$src$core$renderers$common$info(this,a)}makeDrawer_(a,b){return new Drawer$$module$build$src$core$renderers$common$drawer(a,
+b)}makeMarkerDrawer(a,b){return new MarkerSvg$$module$build$src$core$renderers$common$marker_svg(a,this.getConstants(),b)}makePathObject(a,b){return new PathObject$$module$build$src$core$renderers$common$path_object(a,b,this.constants_)}getConstants(){return this.constants_}shouldHighlightConnection(a){return!0}orphanCanConnectAtEnd(a,b,c){return!!Connection$$module$build$src$core$connection.getConnectionForOrphanedConnection(a,c===ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE?
+b.outputConnection:b.previousConnection)}getConnectionPreviewMethod(a,b,c){return b.type===ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE||b.type===ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT?!a.isConnected()||this.orphanCanConnectAtEnd(c,a.targetBlock(),b.type)?InsertionMarkerManager$$module$build$src$core$insertion_marker_manager.PREVIEW_TYPE.INSERTION_MARKER:InsertionMarkerManager$$module$build$src$core$insertion_marker_manager.PREVIEW_TYPE.REPLACEMENT_FADE:
+InsertionMarkerManager$$module$build$src$core$insertion_marker_manager.PREVIEW_TYPE.INSERTION_MARKER}render(a){const b=this.makeRenderInfo_(a);b.measure();this.makeDrawer_(a,b).draw()}},module$build$src$core$renderers$common$renderer={};module$build$src$core$renderers$common$renderer.Renderer=Renderer$$module$build$src$core$renderers$common$renderer;var module$build$src$core$renderers$common$block_rendering={};module$build$src$core$renderers$common$block_rendering.BottomRow=BottomRow$$module$build$src$core$renderers$measurables$bottom_row;module$build$src$core$renderers$common$block_rendering.Connection=Connection$$module$build$src$core$renderers$measurables$connection;module$build$src$core$renderers$common$block_rendering.ConstantProvider=ConstantProvider$$module$build$src$core$renderers$common$constants;
+module$build$src$core$renderers$common$block_rendering.Drawer=Drawer$$module$build$src$core$renderers$common$drawer;module$build$src$core$renderers$common$block_rendering.ExternalValueInput=ExternalValueInput$$module$build$src$core$renderers$measurables$external_value_input;module$build$src$core$renderers$common$block_rendering.Field=Field$$module$build$src$core$renderers$measurables$field;module$build$src$core$renderers$common$block_rendering.Hat=Hat$$module$build$src$core$renderers$measurables$hat;
+module$build$src$core$renderers$common$block_rendering.Icon=Icon$$module$build$src$core$renderers$measurables$icon;module$build$src$core$renderers$common$block_rendering.InRowSpacer=InRowSpacer$$module$build$src$core$renderers$measurables$in_row_spacer;module$build$src$core$renderers$common$block_rendering.InlineInput=InlineInput$$module$build$src$core$renderers$measurables$inline_input;module$build$src$core$renderers$common$block_rendering.InputConnection=InputConnection$$module$build$src$core$renderers$measurables$input_connection;
+module$build$src$core$renderers$common$block_rendering.InputRow=InputRow$$module$build$src$core$renderers$measurables$input_row;module$build$src$core$renderers$common$block_rendering.JaggedEdge=JaggedEdge$$module$build$src$core$renderers$measurables$jagged_edge;module$build$src$core$renderers$common$block_rendering.MarkerSvg=MarkerSvg$$module$build$src$core$renderers$common$marker_svg;module$build$src$core$renderers$common$block_rendering.Measurable=Measurable$$module$build$src$core$renderers$measurables$base;
+module$build$src$core$renderers$common$block_rendering.NextConnection=NextConnection$$module$build$src$core$renderers$measurables$next_connection;module$build$src$core$renderers$common$block_rendering.OutputConnection=OutputConnection$$module$build$src$core$renderers$measurables$output_connection;module$build$src$core$renderers$common$block_rendering.PathObject=PathObject$$module$build$src$core$renderers$common$path_object;
+module$build$src$core$renderers$common$block_rendering.PreviousConnection=PreviousConnection$$module$build$src$core$renderers$measurables$previous_connection;module$build$src$core$renderers$common$block_rendering.RenderInfo=RenderInfo$$module$build$src$core$renderers$common$info;module$build$src$core$renderers$common$block_rendering.Renderer=Renderer$$module$build$src$core$renderers$common$renderer;module$build$src$core$renderers$common$block_rendering.RoundCorner=RoundCorner$$module$build$src$core$renderers$measurables$round_corner;
+module$build$src$core$renderers$common$block_rendering.Row=Row$$module$build$src$core$renderers$measurables$row;module$build$src$core$renderers$common$block_rendering.SpacerRow=SpacerRow$$module$build$src$core$renderers$measurables$spacer_row;module$build$src$core$renderers$common$block_rendering.SquareCorner=SquareCorner$$module$build$src$core$renderers$measurables$square_corner;module$build$src$core$renderers$common$block_rendering.StatementInput=StatementInput$$module$build$src$core$renderers$measurables$statement_input;
+module$build$src$core$renderers$common$block_rendering.TopRow=TopRow$$module$build$src$core$renderers$measurables$top_row;module$build$src$core$renderers$common$block_rendering.Types=Types$$module$build$src$core$renderers$measurables$types;module$build$src$core$renderers$common$block_rendering.init=init$$module$build$src$core$renderers$common$block_rendering;module$build$src$core$renderers$common$block_rendering.register=register$$module$build$src$core$renderers$common$block_rendering;
+module$build$src$core$renderers$common$block_rendering.unregister=unregister$$module$build$src$core$renderers$common$block_rendering;var ThemeManager$$module$build$src$core$theme_manager=class{constructor(a,b){this.workspace=a;this.theme=b;this.subscribedWorkspaces_=[];this.componentDB=new Map}getTheme(){return this.theme}setTheme(a){var b=this.theme;this.theme=a;if(a=this.workspace.getInjectionDiv())b&&(b=b.getClassName())&&removeClass$$module$build$src$core$utils$dom(a,b),(b=this.theme.getClassName())&&addClass$$module$build$src$core$utils$dom(a,b);for(let c=0,d;d=this.subscribedWorkspaces_[c];c++)d.refreshTheme();for(const [c,
+d]of this.componentDB)for(const e of d){a=e.element;b=e.propertyName;const f=this.theme&&this.theme.getComponentStyle(c);a.style.setProperty(b,f||"")}for(const c of this.subscribedWorkspaces_)c.hideChaff()}subscribeWorkspace(a){this.subscribedWorkspaces_.push(a)}unsubscribeWorkspace(a){if(!removeElem$$module$build$src$core$utils$array(this.subscribedWorkspaces_,a))throw Error("Cannot unsubscribe a workspace that hasn't been subscribed.");}subscribe(a,b,c){this.componentDB.has(b)||this.componentDB.set(b,
+[]);this.componentDB.get(b).push({element:a,propertyName:c});b=this.theme&&this.theme.getComponentStyle(b);a.style.setProperty(c,b||"")}unsubscribe(a){if(a)for(const [b,c]of this.componentDB){for(let d=c.length-1;0<=d;d--)c[d].element===a&&c.splice(d,1);c.length||this.componentDB.delete(b)}}dispose(){this.subscribedWorkspaces_.length=0;this.componentDB.clear()}},module$build$src$core$theme_manager={};module$build$src$core$theme_manager.ThemeManager=ThemeManager$$module$build$src$core$theme_manager;var CATEGORY_NAME$$module$build$src$core$variables_dynamic="VARIABLE_DYNAMIC",onCreateVariableButtonClick_String$$module$build$src$core$variables_dynamic=stringButtonClickHandler$$module$build$src$core$variables_dynamic,onCreateVariableButtonClick_Number$$module$build$src$core$variables_dynamic=numberButtonClickHandler$$module$build$src$core$variables_dynamic,onCreateVariableButtonClick_Colour$$module$build$src$core$variables_dynamic=colourButtonClickHandler$$module$build$src$core$variables_dynamic,
+module$build$src$core$variables_dynamic={CATEGORY_NAME:CATEGORY_NAME$$module$build$src$core$variables_dynamic};module$build$src$core$variables_dynamic.flyoutCategory=flyoutCategory$$module$build$src$core$variables_dynamic;module$build$src$core$variables_dynamic.flyoutCategoryBlocks=flyoutCategoryBlocks$$module$build$src$core$variables_dynamic;module$build$src$core$variables_dynamic.onCreateVariableButtonClick_Colour=colourButtonClickHandler$$module$build$src$core$variables_dynamic;
+module$build$src$core$variables_dynamic.onCreateVariableButtonClick_Number=numberButtonClickHandler$$module$build$src$core$variables_dynamic;module$build$src$core$variables_dynamic.onCreateVariableButtonClick_String=stringButtonClickHandler$$module$build$src$core$variables_dynamic;var ConnectionChecker$$module$build$src$core$connection_checker=class{canConnect(a,b,c,d){return this.canConnectWithReason(a,b,c,d)===Connection$$module$build$src$core$connection.CAN_CONNECT}canConnectWithReason(a,b,c,d){const e=this.doSafetyChecks(a,b);return e!==Connection$$module$build$src$core$connection.CAN_CONNECT?e:this.doTypeChecks(a,b)?c&&!this.doDragChecks(a,b,d||0)?Connection$$module$build$src$core$connection.REASON_DRAG_CHECKS_FAILED:Connection$$module$build$src$core$connection.CAN_CONNECT:
+Connection$$module$build$src$core$connection.REASON_CHECKS_FAILED}getErrorMessage(a,b,c){switch(a){case Connection$$module$build$src$core$connection.REASON_SELF_CONNECTION:return"Attempted to connect a block to itself.";case Connection$$module$build$src$core$connection.REASON_DIFFERENT_WORKSPACES:return"Blocks not on same workspace.";case Connection$$module$build$src$core$connection.REASON_WRONG_TYPE:return"Attempt to connect incompatible types.";case Connection$$module$build$src$core$connection.REASON_TARGET_NULL:return"Target connection is null.";
+case Connection$$module$build$src$core$connection.REASON_CHECKS_FAILED:return"Connection checks failed. "+(b+" expected "+b.getCheck()+", found "+c.getCheck());case Connection$$module$build$src$core$connection.REASON_SHADOW_PARENT:return"Connecting non-shadow to shadow block.";case Connection$$module$build$src$core$connection.REASON_DRAG_CHECKS_FAILED:return"Drag checks failed.";case Connection$$module$build$src$core$connection.REASON_PREVIOUS_AND_OUTPUT:return"Block would have an output and a previous connection.";
+default:return"Unknown connection failure: this should never happen!"}}doSafetyChecks(a,b){if(!a||!b)return Connection$$module$build$src$core$connection.REASON_TARGET_NULL;let c,d,e;a.isSuperior()?(c=a.getSourceBlock(),d=b.getSourceBlock(),e=b):(d=a.getSourceBlock(),c=b.getSourceBlock(),e=a,a=b);return c===d?Connection$$module$build$src$core$connection.REASON_SELF_CONNECTION:e.type!==OPPOSITE_TYPE$$module$build$src$core$internal_constants[a.type]?Connection$$module$build$src$core$connection.REASON_WRONG_TYPE:
+c.workspace!==d.workspace?Connection$$module$build$src$core$connection.REASON_DIFFERENT_WORKSPACES:c.isShadow()&&!d.isShadow()?Connection$$module$build$src$core$connection.REASON_SHADOW_PARENT:e.type===ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE&&d.previousConnection&&d.previousConnection.isConnected()||e.type===ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT&&d.outputConnection&&d.outputConnection.isConnected()?Connection$$module$build$src$core$connection.REASON_PREVIOUS_AND_OUTPUT:
+Connection$$module$build$src$core$connection.CAN_CONNECT}doTypeChecks(a,b){a=a.getCheck();b=b.getCheck();if(!a||!b)return!0;for(let c=0;cc||b.getSourceBlock().isInsertionMarker())return!1;switch(b.type){case ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT:return this.canConnectToPrevious_(a,b);case ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE:if(b.isConnected()&&
+!b.targetBlock().isInsertionMarker()||a.isConnected())return!1;break;case ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE:if(b.isConnected()&&!b.targetBlock().isMovable()&&!b.targetBlock().isShadow())return!1;break;case ConnectionType$$module$build$src$core$connection_type.NEXT_STATEMENT:if(b.isConnected()&&!a.getSourceBlock().nextConnection&&!b.targetBlock().isShadow()&&b.targetBlock().nextConnection||b.targetBlock()&&!b.targetBlock().isMovable()&&!b.targetBlock().isShadow())return!1;
+break;default:return!1}return-1!==draggingConnections$$module$build$src$core$common.indexOf(b)?!1:!0}canConnectToPrevious_(a,b){if(a.targetConnection||-1!==draggingConnections$$module$build$src$core$common.indexOf(b))return!1;if(!b.targetConnection)return!0;a=b.targetBlock();return a.isInsertionMarker()?!a.getPreviousBlock():!1}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.CONNECTION_CHECKER,DEFAULT$$module$build$src$core$registry,ConnectionChecker$$module$build$src$core$connection_checker);
+var module$build$src$core$connection_checker={};module$build$src$core$connection_checker.ConnectionChecker=ConnectionChecker$$module$build$src$core$connection_checker;var VarDelete$$module$build$src$core$events$events_var_delete=class extends VarBase$$module$build$src$core$events$events_var_base{constructor(a){super(a);this.type=VAR_DELETE$$module$build$src$core$events$utils;a&&(this.varType=a.type,this.varName=a.name)}toJson(){const a=super.toJson();if(void 0===this.varType)throw Error("The var type is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.varName)throw Error("The var name is undefined. Either pass a variable to the constructor, or call fromJson");
+a.varType=this.varType;a.varName=this.varName;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new VarDelete$$module$build$src$core$events$events_var_delete);b.varType=a.varType;b.varName=a.varName;return b}run(a){const b=this.getEventWorkspace_();if(!this.varId)throw Error("The var ID is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.varName)throw Error("The var name is undefined. Either pass a variable to the constructor, or call fromJson");a?b.deleteVariableById(this.varId):
+b.createVariable(this.varName,this.varType,this.varId)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,VAR_DELETE$$module$build$src$core$events$utils,VarDelete$$module$build$src$core$events$events_var_delete);var module$build$src$core$events$events_var_delete={};module$build$src$core$events$events_var_delete.VarDelete=VarDelete$$module$build$src$core$events$events_var_delete;var VarRename$$module$build$src$core$events$events_var_rename=class extends VarBase$$module$build$src$core$events$events_var_base{constructor(a,b){super(a);this.type=VAR_RENAME$$module$build$src$core$events$utils;a&&(this.oldName=a.name,this.newName="undefined"===typeof b?"":b)}toJson(){const a=super.toJson();if(!this.oldName)throw Error("The old var name is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.newName)throw Error("The new var name is undefined. Either pass a value to the constructor, or call fromJson");
+a.oldName=this.oldName;a.newName=this.newName;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new VarRename$$module$build$src$core$events$events_var_rename);b.oldName=a.oldName;b.newName=a.newName;return b}run(a){const b=this.getEventWorkspace_();if(!this.varId)throw Error("The var ID is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.oldName)throw Error("The old var name is undefined. Either pass a variable to the constructor, or call fromJson");if(!this.newName)throw Error("The new var name is undefined. Either pass a value to the constructor, or call fromJson");
+a?b.renameVariableById(this.varId,this.newName):b.renameVariableById(this.varId,this.oldName)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,VAR_RENAME$$module$build$src$core$events$utils,VarRename$$module$build$src$core$events$events_var_rename);var module$build$src$core$events$events_var_rename={};module$build$src$core$events$events_var_rename.VarRename=VarRename$$module$build$src$core$events$events_var_rename;var VariableMap$$module$build$src$core$variable_map=class{constructor(a){this.workspace=a;this.variableMap=new Map}clear(){for(const a of this.variableMap.values())for(;0{e&&b&&this.deleteVariableInternal(b,d)})):this.deleteVariableInternal(b,d)}else console.warn("Can't delete non-existent variable: "+a)}deleteVariableInternal(a,
+b){const c=$.getGroup$$module$build$src$core$events$utils();c||$.setGroup$$module$build$src$core$events$utils(!0);try{for(let d=0;da.name)}getVariableUsesById(a){const b=[],c=this.workspace.getAllBlocks(!1);
+for(let d=0;dthis.remainingCapacityOfType(c))return!1;b+=a[c]}return b>this.remainingCapacity()?!1:!0}hasBlockLimits(){return Infinity!==this.options.maxBlocks||!!this.options.maxInstances}getUndoStack(){return this.undoStack_}getRedoStack(){return this.redoStack_}undo(a){var b=
+a?this.redoStack_:this.undoStack_,c=a?this.undoStack_:this.redoStack_;const d=b.pop();if(d){for(var e=[d];b.length&&d.group&&d.group===b[b.length-1].group;){const f=b.pop();f&&e.push(f)}for(b=0;bthis.MAX_UNDO&&0<=this.MAX_UNDO;)this.undoStack_.shift();for(let b=0;bMath.abs(b-this.oldTop)&&1>Math.abs(c-this.oldLeft))){var d=new (get$$module$build$src$core$events$utils(VIEWPORT_CHANGE$$module$build$src$core$events$utils))(b,c,a,this.id,this.oldScale);this.oldScale=a;this.oldTop=b;this.oldLeft=c;fire$$module$build$src$core$events$utils(d)}}}translate(a,b){const c="translate("+a+","+b+") scale("+this.scale+")";this.svgBlockCanvas_.setAttribute("transform",c);this.svgBubbleCanvas_.setAttribute("transform",
+c);this.grid&&this.grid.moveTo(a,b);this.maybeFireViewportChangeEvent()}getWidth(){const a=this.getMetrics();return a?a.viewWidth/this.scale:0}setVisible(a){this.isVisible_=a;this.svgGroup_&&(this.scrollbar&&this.scrollbar.setContainerVisible(a),this.getFlyout()&&this.getFlyout().setContainerVisible(a),this.getParentSvg().style.display=a?"block":"none",this.toolbox_&&this.toolbox_.setVisible(a),a||this.hideChaff(!0))}render(){var a=this.getAllBlocks(!1);for(var b=a.length-1;0<=b;b--)a[b].queueRender();
+if(this.currentGesture_)for(a=this.currentGesture_.getInsertionMarkers(),b=0;bvoid this.markerManager.updateMarkers())}highlightBlock(a,b){if(void 0===b){for(let c=0,d;d=this.highlightedBlocks[c];c++)d.setHighlighted(!1);this.highlightedBlocks.length=0}if(a=a?this.getBlockById(a):null)(b=void 0===b||b)?-1===this.highlightedBlocks.indexOf(a)&&this.highlightedBlocks.push(a):removeElem$$module$build$src$core$utils$array(this.highlightedBlocks,
+a),a.setHighlighted(b)}paste(a){warn$$module$build$src$core$utils$deprecation("Blockly.WorkspaceSvg.prototype.paste","v10","v11","Blockly.clipboard.paste");if(!this.rendered||!a.type&&!a.tagName)return null;this.currentGesture_&&this.currentGesture_.cancel();const b=$.getGroup$$module$build$src$core$events$utils();b||$.setGroup$$module$build$src$core$events$utils(!0);let c;try{c=a.type?this.pasteBlock_(null,a):"comment"===a.tagName.toLowerCase()?this.pasteWorkspaceComment_(a):this.pasteBlock_(a,null)}finally{$.setGroup$$module$build$src$core$events$utils(b)}return c}pasteBlock_(a,
+b){$.disable$$module$build$src$core$events$utils();let c;try{let d=0,e=0;if(a){c=domToBlockInternal$$module$build$src$core$xml(a,this);let f;d=parseInt(null!=(f=a.getAttribute("x"))?f:"0");this.RTL&&(d=-d);let g;e=parseInt(null!=(g=a.getAttribute("y"))?g:"0")}else b&&(c=append$$module$build$src$core$serialization$blocks(b,this),d=b.x||10,this.RTL&&(d=this.getWidth()-d),e=b.y||10);if(!isNaN(d)&&!isNaN(e)){let f;do{f=!1;const g=this.getAllBlocks(!1);for(let h=0,k;k=g[h];h++){const l=k.getRelativeToSurfaceXY();
+if(1>=Math.abs(d-l.x)&&1>=Math.abs(e-l.y)){f=!0;break}}if(!f){const h=c.getConnections_(!1);for(let k=0,l;l=h[k];k++)if(l.closest($.config$$module$build$src$core$config.snapRadius,new Coordinate$$module$build$src$core$utils$coordinate(d,e)).connection){f=!0;break}}f&&(d=this.RTL?d-$.config$$module$build$src$core$config.snapRadius:d+$.config$$module$build$src$core$config.snapRadius,e+=2*$.config$$module$build$src$core$config.snapRadius)}while(f);c.moveTo(new Coordinate$$module$build$src$core$utils$coordinate(d,
+e))}}finally{$.enable$$module$build$src$core$events$utils()}isEnabled$$module$build$src$core$events$utils()&&!c.isShadow()&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CREATE$$module$build$src$core$events$utils))(c));c.select();return c}pasteWorkspaceComment_(a){$.disable$$module$build$src$core$events$utils();let b;try{b=WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.fromXmlRendered(a,this);let c,d=parseInt(null!=(c=a.getAttribute("x"))?
+c:"0"),e,f=parseInt(null!=(e=a.getAttribute("y"))?e:"0");isNaN(d)||isNaN(f)||(this.RTL&&(d=-d),b.moveBy(d+50,f+50))}finally{$.enable$$module$build$src$core$events$utils()}isEnabled$$module$build$src$core$events$utils()&&WorkspaceComment$$module$build$src$core$workspace_comment.fireCreateEvent(b);b.select();return b}refreshToolboxSelection(){const a=this.isFlyout?this.targetWorkspace:this;a&&!a.currentGesture_&&a.toolbox_&&a.toolbox_.getFlyout()&&a.toolbox_.refreshSelection()}renameVariableById(a,
+b){super.renameVariableById(a,b);this.refreshToolboxSelection()}deleteVariableById(a){super.deleteVariableById(a);this.refreshToolboxSelection()}createVariable(a,b,c){a=super.createVariable(a,b,c);this.refreshToolboxSelection();return a}recordDragTargets(){const a=this.componentManager.getComponents(ComponentManager$$module$build$src$core$component_manager.Capability.DRAG_TARGET,!0);this.dragTargetAreas=[];for(let b=0,c;c=a[b];b++){const d=c.getClientRect();d&&this.dragTargetAreas.push({component:c,
+clientRect:d})}}newBlock(a,b){throw Error("The implementation of newBlock should be monkey-patched in by blockly.ts");}getDragTarget(a){for(let b=0,c;c=this.dragTargetAreas[b];b++)if(c.clientRect.contains(a.clientX,a.clientY))return c.component;return null}onMouseDown_(a){const b=this.getGesture(a);b&&b.handleWsStart(a,this)}startDrag(a,b){a=mouseToSvg$$module$build$src$core$browser_events(a,this.getParentSvg(),this.getInverseScreenCTM());a.x/=this.scale;a.y/=this.scale;this.dragDeltaXY=Coordinate$$module$build$src$core$utils$coordinate.difference(b,
+a)}moveDrag(a){a=mouseToSvg$$module$build$src$core$browser_events(a,this.getParentSvg(),this.getInverseScreenCTM());a.x/=this.scale;a.y/=this.scale;return Coordinate$$module$build$src$core$utils$coordinate.sum(this.dragDeltaXY,a)}isDragging(){return null!==this.currentGesture_&&this.currentGesture_.isDragging()}isDraggable(){return this.options.moveOptions&&this.options.moveOptions.drag}isMovable(){return this.options.moveOptions&&!!this.options.moveOptions.scrollbars||this.options.moveOptions&&this.options.moveOptions.wheel||
+this.options.moveOptions&&this.options.moveOptions.drag||this.options.zoomOptions&&this.options.zoomOptions.wheel||this.options.zoomOptions&&this.options.zoomOptions.pinch}isMovableHorizontally(){const a=!!this.scrollbar;return this.isMovable()&&(!a||a&&this.scrollbar.canScrollHorizontally())}isMovableVertically(){const a=!!this.scrollbar;return this.isMovable()&&(!a||a&&this.scrollbar.canScrollVertically())}onMouseWheel_(a){if(Gesture$$module$build$src$core$gesture.inProgress())a.preventDefault(),
+a.stopPropagation();else{var b=this.options.zoomOptions&&this.options.zoomOptions.wheel,c=this.options.moveOptions&&this.options.moveOptions.wheel;if(b||c){var d=getScrollDeltaPixels$$module$build$src$core$browser_events(a);if(MAC$$module$build$src$core$utils$useragent)var e=a.metaKey;b&&(a.ctrlKey||e||!c)?(d=-d.y/50,b=mouseToSvg$$module$build$src$core$browser_events(a,this.getParentSvg(),this.getInverseScreenCTM()),this.zoom(b.x,b.y,d)):(b=this.scrollX-d.x,c=this.scrollY-d.y,a.shiftKey&&!d.x&&(b=
+this.scrollX-d.y,c=this.scrollY),this.scroll(b,c));a.preventDefault()}}}getBlocksBoundingBox(){const a=this.getTopBoundedElements();if(!a.length)return new Rect$$module$build$src$core$utils$rect(0,0,0,0);const b=a[0].getBoundingRectangle();for(let d=1;db.bottom&&(b.bottom=c.bottom),c.leftb.right&&(b.right=c.right))}return b}cleanUp(){this.setResizesEnabled(!1);
+$.setGroup$$module$build$src$core$events$utils(!0);const a=this.getTopBlocks(!0);let b=0;for(let c=0,d;d=a[c];c++){if(!d.isMovable())continue;const e=d.getRelativeToSurfaceXY();d.moveBy(-e.x,b-e.y,["cleanup"]);d.snapToGrid();b=d.getRelativeToSurfaceXY().y+d.getHeightWidth().height+this.renderer.getConstants().MIN_BLOCK_HEIGHT}$.setGroup$$module$build$src$core$events$utils(!1);this.setResizesEnabled(!0)}showContextMenu(a){if(!this.options.readOnly&&!this.isFlyout){var b=ContextMenuRegistry$$module$build$src$core$contextmenu_registry.registry.getContextMenuOptions(ContextMenuRegistry$$module$build$src$core$contextmenu_registry.ScopeType.WORKSPACE,
+{workspace:this});this.configureContextMenu&&this.configureContextMenu(b,a);show$$module$build$src$core$contextmenu(a,b,this.RTL)}}updateToolbox(a){if(a=convertToolboxDefToJson$$module$build$src$core$utils$toolbox(a)){if(!this.options.languageTree)throw Error("Existing toolbox is null.  Can't create new toolbox.");if(hasCategories$$module$build$src$core$utils$toolbox(a)){if(!this.toolbox_)throw Error("Existing toolbox has no categories.  Can't change mode.");this.options.languageTree=a;this.toolbox_.render(a)}else{if(!this.flyout)throw Error("Existing toolbox has categories.  Can't change mode.");
+this.options.languageTree=a;this.flyout.show(a)}}else if(this.options.languageTree)throw Error("Can't nullify an existing toolbox.");}markFocused(){this.options.parentWorkspace?this.options.parentWorkspace.markFocused():(setMainWorkspace$$module$build$src$core$common(this),this.getParentSvg().focus({preventScroll:!0}))}zoom(a,b,c){c=Math.pow(this.options.zoomOptions.scaleSpeed,c);const d=this.scale*c;if(this.scale!==d){d>this.options.zoomOptions.maxScale?c=this.options.zoomOptions.maxScale/this.scale:
+dthis.options.zoomOptions.maxScale?a=this.options.zoomOptions.maxScale:this.options.zoomOptions.minScale&&ab.autoHide(a))}static setTopLevelWorkspaceMetrics_(a){const b=this.getMetrics();"number"===typeof a.x&&(this.scrollX=-(b.scrollLeft+(b.scrollWidth-b.viewWidth)*a.x));"number"===typeof a.y&&(this.scrollY=-(b.scrollTop+(b.scrollHeight-b.viewHeight)*a.y));this.translate(this.scrollX+b.absoluteLeft,this.scrollY+b.absoluteTop)}},module$build$src$core$workspace_svg={};module$build$src$core$workspace_svg.WorkspaceSvg=WorkspaceSvg$$module$build$src$core$workspace_svg;
+module$build$src$core$workspace_svg.resizeSvgContents=resizeSvgContents$$module$build$src$core$workspace_svg;var TrashcanOpen$$module$build$src$core$events$events_trashcan_open=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b){super(b);this.type=TRASHCAN_OPEN$$module$build$src$core$events$utils;this.isOpen=a}toJson(){const a=super.toJson();if(void 0===this.isOpen)throw Error("Whether this is already open or not is undefined. Either pass a value to the constructor, or call fromJson");a.isOpen=this.isOpen;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new TrashcanOpen$$module$build$src$core$events$events_trashcan_open);
+b.isOpen=a.isOpen;return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,TRASHCAN_OPEN$$module$build$src$core$events$utils,TrashcanOpen$$module$build$src$core$events$events_trashcan_open);var module$build$src$core$events$events_trashcan_open={};module$build$src$core$events$events_trashcan_open.TrashcanOpen=TrashcanOpen$$module$build$src$core$events$events_trashcan_open;var BlockDelete$$module$build$src$core$events$events_block_delete=class extends BlockBase$$module$build$src$core$events$events_block_base{constructor(a){super(a);this.type=$.DELETE$$module$build$src$core$events$utils;if(a){if(a.getParent())throw Error("Connected blocks cannot be deleted.");a.isShadow()&&(this.recordUndo=!1);this.oldXml=blockToDomWithXY$$module$build$src$core$xml(a);this.ids=getDescendantIds$$module$build$src$core$events$utils(a);this.wasShadow=a.isShadow();this.oldJson=save$$module$build$src$core$serialization$blocks(a,
+{addCoordinates:!0})}}toJson(){const a=super.toJson();if(!this.oldXml)throw Error("The old block XML is undefined. Either pass a block to the constructor, or call fromJson");if(!this.ids)throw Error("The block IDs are undefined. Either pass a block to the constructor, or call fromJson");if(void 0===this.wasShadow)throw Error("Whether the block was a shadow is undefined. Either pass a block to the constructor, or call fromJson");if(!this.oldJson)throw Error("The old block JSON is undefined. Either pass a block to the constructor, or call fromJson");
+a.oldXml=domToText$$module$build$src$core$xml(this.oldXml);a.ids=this.ids;a.wasShadow=this.wasShadow;a.oldJson=this.oldJson;this.recordUndo||(a.recordUndo=this.recordUndo);return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BlockDelete$$module$build$src$core$events$events_block_delete);b.oldXml=$.textToDom$$module$build$src$core$utils$xml(a.oldXml);b.ids=a.ids;b.wasShadow=a.wasShadow||"shadow"===b.oldXml.tagName.toLowerCase();b.oldJson=a.oldJson;void 0!==a.recordUndo&&(b.recordUndo=
+a.recordUndo);return b}run(a){const b=this.getEventWorkspace_();if(!this.ids)throw Error("The block IDs are undefined. Either pass a block to the constructor, or call fromJson");if(!this.oldJson)throw Error("The old block JSON is undefined. Either pass a block to the constructor, or call fromJson");if(a)for(a=0;aa.disposeInternal()),
+this.inputList.forEach(a=>a.dispose()),this.inputList.length=0,this.getConnections_(!0).forEach(a=>a.dispose()),this.disposed=!0)}isDeadOrDying(){return this.disposing||this.disposed}initModel(){for(const a of this.inputList)for(const b of a.fieldRow)b.initModel&&b.initModel()}unplug(a){this.outputConnection&&this.unplugFromRow_(a);this.previousConnection&&this.unplugFromStack_(a)}unplugFromRow_(a){let b=null,c;if(null==(c=this.outputConnection)?0:c.isConnected())b=this.outputConnection.targetConnection,
+this.outputConnection.disconnect();b&&a&&(a=this.getOnlyValueConnection_())&&a.isConnected()&&!a.targetBlock().isShadow()&&(a=a.targetConnection,null==a||a.disconnect(),this.workspace.connectionChecker.canConnect(a,b,!1)?b.connect(a):null==a||a.onFailedConnect(b))}getOnlyValueConnection_(){let a=null;for(let b=0;b{d=d+("("===c||")"===e?"":" ")+e;c=e[e.length-1];return d},"");b=b.trim()||"???";a&&b.length>a&&(b=b.substring(0,a-3)+"...");return b}toTokens(a="?"){const b=[];for(const d of this.inputList)if(d.name!=COLLAPSED_INPUT_NAME$$module$build$src$core$constants){for(const e of d.fieldRow)b.push(e.getText());if(d.connection){const e=d.connection.targetBlock();if(e){var c=d.connection;let f=c.getCheck();!f&&c.targetConnection&&(f=c.targetConnection.getCheck());
+(c=!!f&&(-1!==f.indexOf("Boolean")||-1!==f.indexOf("Number")))&&b.push("(");b.push(...e.toTokens(a));c&&b.push(")")}else b.push(a)}}return b}appendValueInput(a){return this.appendInput(new $.ValueInput$$module$build$src$core$inputs$value_input(a,this))}appendStatementInput(a){this.statementInputCount++;return this.appendInput(new StatementInput$$module$build$src$core$inputs$statement_input(a,this))}appendDummyInput(a=""){return this.appendInput(new DummyInput$$module$build$src$core$inputs$dummy_input(a,
+this))}appendEndRowInput(a=""){return this.appendInput(new EndRowInput$$module$build$src$core$inputs$end_row_input(a,this))}appendInput(a){this.inputList.push(a);return a}appendInputFromRegistry(a,b){return(a=getClass$$module$build$src$core$registry(Type$$module$build$src$core$registry.INPUT,a,!1))?this.appendInput(new a(b,this)):null}jsonInit(a){var b=a.type?'Block "'+a.type+'": ':"";if(a.output&&a.previousStatement)throw Error(b+"Must not have both an output and a previousStatement.");a.style&&
+a.style.hat&&(this.hat=a.style.hat,a.style=null);if(a.style&&a.colour)throw Error(b+"Must not have both a colour and a style.");a.style?this.jsonInitStyle_(a,b):this.jsonInitColour_(a,b);for(var c=0;void 0!==a["message"+c];)this.interpolate_(a["message"+c],a["args"+c]||[],a["implicitAlign"+c]||a["lastDummyAlign"+c],b),c++;void 0!==a.inputsInline&&($.disable$$module$build$src$core$events$utils(),this.setInputsInline(a.inputsInline),$.enable$$module$build$src$core$events$utils());void 0!==a.output&&
+this.setOutput(!0,a.output);void 0!==a.outputShape&&this.setOutputShape(a.outputShape);void 0!==a.previousStatement&&this.setPreviousStatement(!0,a.previousStatement);void 0!==a.nextStatement&&this.setNextStatement(!0,a.nextStatement);void 0!==a.tooltip&&(c=replaceMessageReferences$$module$build$src$core$utils$parsing(a.tooltip),this.setTooltip(c));void 0!==a.enableContextMenu&&(this.contextMenu=!!a.enableContextMenu);void 0!==a.suppressPrefixSuffix&&(this.suppressPrefixSuffix=!!a.suppressPrefixSuffix);
+void 0!==a.helpUrl&&(c=replaceMessageReferences$$module$build$src$core$utils$parsing(a.helpUrl),this.setHelpUrl(c));"string"===typeof a.extensions&&(console.warn(b+"JSON attribute 'extensions' should be an array of strings. Found raw string in JSON for '"+a.type+"' block."),a.extensions=[a.extensions]);void 0!==a.mutator&&apply$$module$build$src$core$extensions(a.mutator,this,!0);a=a.extensions;if(Array.isArray(a))for(b=0;b
+f||f>b)throw Error('Block "'+this.type+'": Message index %'+f+" out of range.");if(c[f])throw Error('Block "'+this.type+'": Message index %'+f+" duplicated.");c[f]=!0;d++}}if(d!==b)throw Error('Block "'+this.type+'": Message does not reference all '+b+" arg(s).");}interpolateArguments_(a,b,c){const d=[];for(let f=0;f=this.inputList.length)throw RangeError("Input index "+a+" out of bounds.");if(b>this.inputList.length)throw RangeError("Reference input "+b+" out of bounds.");const c=this.inputList[a];this.inputList.splice(a,1);ab.getWeight()-c.getWeight());return a}removeIcon(a){if(!this.hasIcon(a))return!1;let b;null==(b=this.getIcon(a))||b.dispose();this.icons=this.icons.filter(c=>!c.getType().equals(a));return!0}hasIcon(a){return this.icons.some(b=>
+b.getType().equals(a))}getIcon(a){return a instanceof IconType$$module$build$src$core$icons$icon_types?this.icons.find(b=>b.getType().equals(a)):this.icons.find(b=>b.getType().toString()===a)}getIcons(){return[...this.icons]}getRelativeToSurfaceXY(){return this.xy_}moveBy(a,b,c){if(this.parentBlock_)throw Error("Block has parent");const d=new (get$$module$build$src$core$events$utils($.MOVE$$module$build$src$core$events$utils))(this);c&&d.setReason(c);this.xy_.translate(a,b);d.recordNew();fire$$module$build$src$core$events$utils(d)}makeConnection_(a){return new Connection$$module$build$src$core$connection(this,
+a)}allInputsFilled(a){void 0===a&&(a=!0);if(!a&&this.isShadow())return!1;for(let c=0,d;d=this.inputList[c];c++)if(d.connection){var b=d.connection.targetBlock();if(!b||!b.allInputsFilled(a))return!1}return(b=this.getNextBlock())?b.allInputsFilled(a):!0}toDevString(){let a=this.type?'"'+this.type+'" block':"Block";this.id&&(a+=' (id="'+this.id+'")');return a}};Block$$module$build$src$core$block.COLLAPSED_INPUT_NAME=COLLAPSED_INPUT_NAME$$module$build$src$core$constants;
+Block$$module$build$src$core$block.COLLAPSED_FIELD_NAME=COLLAPSED_FIELD_NAME$$module$build$src$core$constants;var module$build$src$core$block={};module$build$src$core$block.Block=Block$$module$build$src$core$block;var Marker$$module$build$src$core$keyboard_nav$marker=class{constructor(){this.drawer=this.curNode=this.colour=null;this.type="marker"}setDrawer(a){this.drawer=a}getDrawer(){return this.drawer}getCurNode(){return this.curNode}setCurNode(a){const b=this.curNode;this.curNode=a;this.drawer&&this.drawer.draw(b,this.curNode)}draw(){this.drawer&&this.drawer.draw(this.curNode,this.curNode)}hide(){this.drawer&&this.drawer.hide()}dispose(){this.getDrawer()&&this.getDrawer().dispose()}},module$build$src$core$keyboard_nav$marker=
+{};module$build$src$core$keyboard_nav$marker.Marker=Marker$$module$build$src$core$keyboard_nav$marker;var Cursor$$module$build$src$core$keyboard_nav$cursor=class extends Marker$$module$build$src$core$keyboard_nav$marker{constructor(){super();this.type="cursor"}next(){var a=this.getCurNode();if(!a)return null;for(a=a.next();a&&a.next()&&(a.getType()===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.NEXT||a.getType()===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.BLOCK);)a=a.next();a&&this.setCurNode(a);return a}in(){var a=this.getCurNode();if(!a)return null;if(a.getType()===
+ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.PREVIOUS||a.getType()===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.OUTPUT)a=a.next();let b,c;(a=null!=(c=null==(b=a)?void 0:b.in())?c:null)&&this.setCurNode(a);return a}prev(){var a=this.getCurNode();if(!a)return null;for(a=a.prev();a&&a.prev()&&(a.getType()===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.NEXT||a.getType()===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.BLOCK);)a=a.prev();a&&this.setCurNode(a);
+return a}out(){var a=this.getCurNode();if(!a)return null;(a=a.out())&&a.getType()===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.BLOCK&&(a=a.prev()||a);a&&this.setCurNode(a);return a}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.CURSOR,DEFAULT$$module$build$src$core$registry,Cursor$$module$build$src$core$keyboard_nav$cursor);var module$build$src$core$keyboard_nav$cursor={};module$build$src$core$keyboard_nav$cursor.Cursor=Cursor$$module$build$src$core$keyboard_nav$cursor;var BasicCursor$$module$build$src$core$keyboard_nav$basic_cursor=class extends Cursor$$module$build$src$core$keyboard_nav$cursor{constructor(){super()}next(){var a=this.getCurNode();if(!a)return null;(a=this.getNextNode_(a,this.validNode_))&&this.setCurNode(a);return a}in(){return this.next()}prev(){var a=this.getCurNode();if(!a)return null;(a=this.getPreviousNode_(a,this.validNode_))&&this.setCurNode(a);return a}out(){return this.prev()}getNextNode_(a,b){if(!a)return null;const c=a.in()||a.next();
+if(b(c))return c;if(c)return this.getNextNode_(c,b);a=this.findSiblingOrParent(a.out());return b(a)?a:a?this.getNextNode_(a,b):null}getPreviousNode_(a,b){if(!a)return null;let c=a.prev();c=c?this.getRightMostChild(c):a.out();return b(c)?c:c?this.getPreviousNode_(c,b):null}validNode_(a){let b=!1;a=a&&a.getType();if(a===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.OUTPUT||a===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.INPUT||a===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.FIELD||
+a===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.NEXT||a===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.PREVIOUS||a===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.WORKSPACE)b=!0;return b}findSiblingOrParent(a){if(!a)return null;const b=a.next();return b?b:this.findSiblingOrParent(a.out())}getRightMostChild(a){if(!a.in())return a;for(a=a.in();a&&a.next();)a=a.next();return this.getRightMostChild(a)}};
+BasicCursor$$module$build$src$core$keyboard_nav$basic_cursor.registrationName="basicCursor";register$$module$build$src$core$registry(Type$$module$build$src$core$registry.CURSOR,BasicCursor$$module$build$src$core$keyboard_nav$basic_cursor.registrationName,BasicCursor$$module$build$src$core$keyboard_nav$basic_cursor);var module$build$src$core$keyboard_nav$basic_cursor={};module$build$src$core$keyboard_nav$basic_cursor.BasicCursor=BasicCursor$$module$build$src$core$keyboard_nav$basic_cursor;var TabNavigateCursor$$module$build$src$core$keyboard_nav$tab_navigate_cursor=class extends BasicCursor$$module$build$src$core$keyboard_nav$basic_cursor{validNode_(a){let b=!1;const c=a&&a.getType();a&&(a=a.getLocation(),c===ASTNode$$module$build$src$core$keyboard_nav$ast_node.types.FIELD&&a&&a.isTabNavigable()&&a.isClickable()&&(b=!0));return b}},module$build$src$core$keyboard_nav$tab_navigate_cursor={};module$build$src$core$keyboard_nav$tab_navigate_cursor.TabNavigateCursor=TabNavigateCursor$$module$build$src$core$keyboard_nav$tab_navigate_cursor;var BUMP_RANDOMNESS$$module$build$src$core$rendered_connection=10,RenderedConnection$$module$build$src$core$rendered_connection=class extends Connection$$module$build$src$core$connection{constructor(a,b){super(a,b);this.targetConnection=this.highlightPath=null;this.db=a.workspace.connectionDBList[b];this.dbOpposite=a.workspace.connectionDBList[OPPOSITE_TYPE$$module$build$src$core$internal_constants[b]];this.offsetInBlock=new Coordinate$$module$build$src$core$utils$coordinate(0,0);this.trackedState=
+RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.WILL_TRACK}dispose(){super.dispose();this.trackedState===RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.TRACKED&&this.db.removeConnection(this,this.y);this.highlightPath&&(removeNode$$module$build$src$core$utils$dom(this.highlightPath),this.highlightPath=null)}getSourceBlock(){return super.getSourceBlock()}targetBlock(){return super.targetBlock()}distanceFrom(a){const b=this.x-a.x;a=this.y-a.y;return Math.sqrt(b*
+b+a*a)}bumpAwayFrom(a){if(!this.sourceBlock_.workspace.isDragging()){var b=this.sourceBlock_.getRootBlock();if(!b.isInFlyout){var c=!1;if(!b.isMovable()){b=a.getSourceBlock().getRootBlock();if(!b.isMovable())return;a=this;c=!0}var d=getSelected$$module$build$src$core$common()==b;d||b.addSelect();var e=a.x+$.config$$module$build$src$core$config.snapRadius+Math.floor(Math.random()*BUMP_RANDOMNESS$$module$build$src$core$rendered_connection)-this.x,f=a.y+$.config$$module$build$src$core$config.snapRadius+
+Math.floor(Math.random()*BUMP_RANDOMNESS$$module$build$src$core$rendered_connection)-this.y;c&&(f=-f);b.RTL&&(e=a.x-$.config$$module$build$src$core$config.snapRadius-Math.floor(Math.random()*BUMP_RANDOMNESS$$module$build$src$core$rendered_connection)-this.x);b.moveBy(e,f,["bump"]);d||b.removeSelect()}}}moveTo(a,b){let c=!1;this.trackedState===RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.WILL_TRACK?(this.db.addConnection(this,b),this.trackedState=RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.TRACKED,
+c=!0):this.trackedState===RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.TRACKED&&(this.db.removeConnection(this,this.y),this.db.addConnection(this,b),c=!0);this.x=a;this.y=b;return c}moveBy(a,b){return this.moveTo(this.x+a,this.y+b)}moveToOffset(a){return this.moveTo(a.x+this.offsetInBlock.x,a.y+this.offsetInBlock.y)}setOffsetInBlock(a,b){this.offsetInBlock.x=a;this.offsetInBlock.y=b}getOffsetInBlock(){return this.offsetInBlock}tighten(){const a=this.targetConnection.x-
+this.x,b=this.targetConnection.y-this.y;if(0!==a||0!==b){const d=this.targetBlock();var c=d.getSvgRoot();if(!c)throw Error("block is not rendered.");c=getRelativeXY$$module$build$src$core$utils$svg_math(c);d.translate(c.x-a,c.y-b);d.moveConnections(-a,-b)}}tightenEfficiently(){var a=this.targetConnection;const b=this.targetBlock();a&&b&&(a=Coordinate$$module$build$src$core$utils$coordinate.difference(this.offsetInBlock,a.offsetInBlock),b.translate(a.x,a.y))}closest(a,b){return this.dbOpposite.searchForClosest(this,
+a,b)}highlight(){if(!this.highlightPath){var a=this.sourceBlock_.workspace.getRenderer().getConstants();var b=a.shapeFor(this);this.type===ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE||this.type===ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE?(a=a.TAB_OFFSET_FROM_TOP,b=moveBy$$module$build$src$core$utils$svg_paths(0,-a)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",a)+b.pathDown+lineOnAxis$$module$build$src$core$utils$svg_paths("v",a)):(a=a.NOTCH_OFFSET_LEFT-
+a.CORNER_RADIUS,b=moveBy$$module$build$src$core$utils$svg_paths(-a,0)+lineOnAxis$$module$build$src$core$utils$svg_paths("h",a)+b.pathLeft+lineOnAxis$$module$build$src$core$utils$svg_paths("h",a));a=this.offsetInBlock;this.highlightPath=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.PATH,{"class":"blocklyHighlightedConnectionPath",d:b,transform:`translate(${a.x}, ${a.y})`+(this.sourceBlock_.RTL?" scale(-1 1)":"")},this.sourceBlock_.getSvgRoot())}}unhighlight(){this.highlightPath&&
+(removeNode$$module$build$src$core$utils$dom(this.highlightPath),this.highlightPath=null)}setTracking(a){a&&this.trackedState===RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.TRACKED||!a&&this.trackedState===RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.UNTRACKED||this.sourceBlock_.isInFlyout||(a?(this.db.addConnection(this,this.y),this.trackedState=RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.TRACKED):(this.trackedState===
+RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.TRACKED&&this.db.removeConnection(this,this.y),this.trackedState=RenderedConnection$$module$build$src$core$rendered_connection.TrackedState.UNTRACKED))}stopTrackingAll(){this.setTracking(!1);if(this.targetConnection){const a=this.targetBlock().getDescendants(!1);for(let b=0;bclearTimeout(a)),this.warningTextDb.clear(),this.getIcons().forEach(a=>a.dispose()))}checkAndDelete(){this.workspace.isFlyout||($.setGroup$$module$build$src$core$events$utils(!0),this.workspace.hideChaff(),this.outputConnection?this.dispose(!1,!0):this.dispose(!0,!0),$.setGroup$$module$build$src$core$events$utils(!1))}toCopyData(){return this.isInsertionMarker_?
+null:{paster:BlockPaster$$module$build$src$core$clipboard$block_paster.TYPE,blockState:save$$module$build$src$core$serialization$blocks(this,{addCoordinates:!0,addNextBlocks:!1}),typeCounts:getBlockTypeCounts$$module$build$src$core$common(this,!0)}}applyColour(){this.pathObject.applyColour(this);const a=this.getIcons();for(let b=0;b{this.isDeadOrDying()||(this.warningTextDb.delete(b),this.setWarningText(a,b))},100));else if(this.isInFlyout&&(a=null),c=this.getIcon(WarningIcon$$module$build$src$core$icons$warning_icon.TYPE),a){let d=this.getSurroundParent(),e=null;for(;d;)d.isCollapsed()&&(e=d),d=d.getSurroundParent();e&&e.setWarningText($.Msg$$module$build$src$core$msg.COLLAPSED_WARNINGS_WARNING,
+BlockSvg$$module$build$src$core$block_svg.COLLAPSED_WARNING_ID);c?c.addMessage(a,b):this.addIcon((new WarningIcon$$module$build$src$core$icons$warning_icon(this)).addMessage(a,b))}else c&&(b?(c.addMessage("",b),c.getText()||this.removeIcon(WarningIcon$$module$build$src$core$icons$warning_icon.TYPE)):this.removeIcon(WarningIcon$$module$build$src$core$icons$warning_icon.TYPE))}setMutator(a){this.removeIcon($.MutatorIcon$$module$build$src$core$icons$mutator_icon.TYPE);a&&this.addIcon(a)}addIcon(a){super.addIcon(a);
+a instanceof WarningIcon$$module$build$src$core$icons$warning_icon&&(this.warning=a);a instanceof $.MutatorIcon$$module$build$src$core$icons$mutator_icon&&(this.mutator=a);this.rendered&&(a.initView(this.createIconPointerDownListener(a)),a.applyColour(),a.updateEditable(),this.queueRender(),triggerQueuedRenders$$module$build$src$core$render_management(),this.bumpNeighbours());return a}createIconPointerDownListener(a){return b=>{this.isDeadOrDying()||(b=this.workspace.getGesture(b))&&b.setStartIcon(a)}}removeIcon(a){const b=
+super.removeIcon(a);a.equals(WarningIcon$$module$build$src$core$icons$warning_icon.TYPE)&&(this.warning=null);a.equals($.MutatorIcon$$module$build$src$core$icons$mutator_icon.TYPE)&&(this.mutator=null);this.rendered&&(this.queueRender(),triggerQueuedRenders$$module$build$src$core$render_management(),this.bumpNeighbours());return b}setEnabled(a){this.isEnabled()!==a&&(super.setEnabled(a),this.rendered&&!this.getInheritedDisabled()&&this.updateDisabled())}setHighlighted(a){this.rendered&&this.pathObject.updateHighlighted(a)}addSelect(){this.pathObject.updateSelected(!0)}removeSelect(){this.pathObject.updateSelected(!1)}setDeleteStyle(a){this.pathObject.updateDraggingDelete(a)}getColour(){return this.style.colourPrimary}setColour(a){super.setColour(a);
+a=this.workspace.getRenderer().getConstants().getBlockStyleForColour(this.colour_);this.pathObject.setStyle(a.style);this.style=a.style;this.styleName_=a.name;this.applyColour()}setStyle(a){const b=this.workspace.getRenderer().getConstants().getBlockStyle(a);this.styleName_=a;if(b)this.hat=b.hat,this.pathObject.setStyle(b),this.colour_=b.colourPrimary,this.style=b,this.applyColour();else throw Error("Invalid style name: "+a);}bringToFront(a=!1){let b=this;do{const c=b.getSvgRoot(),d=c.parentNode,
+e=d.childNodes;e[e.length-1]!==c&&d.appendChild(c);if(a)break;b=b.getParent()}while(b)}setPreviousStatement(a,b){super.setPreviousStatement(a,b);this.rendered&&(this.queueRender(),this.bumpNeighbours())}setNextStatement(a,b){super.setNextStatement(a,b);this.rendered&&(this.queueRender(),this.bumpNeighbours())}setOutput(a,b){super.setOutput(a,b);this.rendered&&(this.queueRender(),this.bumpNeighbours())}setInputsInline(a){super.setInputsInline(a);this.rendered&&(this.queueRender(),this.bumpNeighbours())}removeInput(a,
+b){a=super.removeInput(a,b);this.rendered&&(this.queueRender(),this.bumpNeighbours());return a}moveNumberedInputBefore(a,b){super.moveNumberedInputBefore(a,b);this.rendered&&(this.queueRender(),this.bumpNeighbours())}appendInput(a){super.appendInput(a);this.rendered&&(this.queueRender(),this.bumpNeighbours());return a}setConnectionTracking(a){this.previousConnection&&this.previousConnection.setTracking(a);this.outputConnection&&this.outputConnection.setTracking(a);if(this.nextConnection){this.nextConnection.setTracking(a);
+var b=this.nextConnection.targetBlock();b&&b.setConnectionTracking(a)}if(!this.collapsed_)for(b=0;b{const b=
+$.getGroup$$module$build$src$core$events$utils();$.setGroup$$module$build$src$core$events$utils(a);this.getRootBlock().bumpNeighboursInternal();$.setGroup$$module$build$src$core$events$utils(b);this.bumpNeighboursPid=0},$.config$$module$build$src$core$config.bumpDelay)}}bumpNeighboursInternal(){const a=this.getRootBlock();if(!(this.isDeadOrDying()||this.workspace.isDragging()||a.isInFlyout))for(const b of this.getConnections_(!1)){if(b.isSuperior()){let c;null==(c=b.targetBlock())||c.bumpNeighboursInternal()}for(const c of b.neighbours($.config$$module$build$src$core$config.snapRadius))c.getSourceBlock().getRootBlock()!==
+a&&(b.isConnected()&&c.isConnected()||(b.isSuperior()?c.bumpAwayFrom(b):b.bumpAwayFrom(c)))}}scheduleSnapAndBump(){const a=$.getGroup$$module$build$src$core$events$utils();setTimeout(()=>{$.setGroup$$module$build$src$core$events$utils(a);this.snapToGrid();$.setGroup$$module$build$src$core$events$utils(!1)},$.config$$module$build$src$core$config.bumpDelay/2);this.bumpNeighbours()}positionNearConnection(a,b,c){if(a.type===ConnectionType$$module$build$src$core$connection_type.NEXT_STATEMENT||a.type===
+ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE){let d=b.x;b=b.y;d+=c.x-a.getOffsetInBlock().x;b+=c.y-a.getOffsetInBlock().y;this.moveBy(d,b)}}getChildren(a){return super.getChildren(a)}queueRender(){return queueRender$$module$build$src$core$render_management(this)}render(){this.queueRender();triggerQueuedRenders$$module$build$src$core$render_management()}renderEfficiently(){this.rendered=!0;startTextWidthCache$$module$build$src$core$utils$dom();this.isCollapsed()&&this.updateCollapsed_();
+this.workspace.getRenderer().render(this);this.tightenChildrenEfficiently();stopTextWidthCache$$module$build$src$core$utils$dom();this.updateMarkers_()}tightenChildrenEfficiently(){for(const a of this.inputList){const b=a.connection;b&&b.tightenEfficiently()}this.nextConnection&&this.nextConnection.tightenEfficiently()}updateMarkers_(){this.workspace.keyboardAccessibilityMode&&this.pathObject.cursorSvg&&this.workspace.getCursor().draw();this.workspace.keyboardAccessibilityMode&&this.pathObject.markerSvg&&
+this.workspace.getMarker(MarkerManager$$module$build$src$core$marker_manager.LOCAL_MARKER).draw();for(const a of this.inputList)for(const b of a.fieldRow)b.updateMarkers_()}updateConnectionAndIconLocations(){const a=this.getRelativeToSurfaceXY();this.previousConnection&&this.previousConnection.moveToOffset(a);this.outputConnection&&this.outputConnection.moveToOffset(a);for(let b=0;b=this.workspace.options.maxTrashcanContents||(a=new Options$$module$build$src$core$options({scrollbars:!0,parentWorkspace:this.workspace,rtl:this.workspace.RTL,oneBasedIndex:this.workspace.options.oneBasedIndex,
+renderer:this.workspace.options.renderer,rendererOverrides:this.workspace.options.rendererOverrides,move:{scrollbars:!0}}),this.workspace.horizontalLayout?(a.toolboxPosition=this.workspace.toolboxPosition===Position$$module$build$src$core$utils$toolbox.TOP?Position$$module$build$src$core$utils$toolbox.BOTTOM:Position$$module$build$src$core$utils$toolbox.TOP,this.flyout=new (getClassFromOptions$$module$build$src$core$registry(Type$$module$build$src$core$registry.FLYOUTS_HORIZONTAL_TOOLBOX,this.workspace.options,
+!0))(a)):(a.toolboxPosition=this.workspace.toolboxPosition===Position$$module$build$src$core$utils$toolbox.RIGHT?Position$$module$build$src$core$utils$toolbox.LEFT:Position$$module$build$src$core$utils$toolbox.RIGHT,this.flyout=new (getClassFromOptions$$module$build$src$core$registry(Type$$module$build$src$core$registry.FLYOUTS_VERTICAL_TOOLBOX,this.workspace.options,!0))(a)),this.workspace.addChangeListener(this.onDelete.bind(this)))}createDom(){this.svgGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,
+{"class":"blocklyTrash"});let a;const b=String(Math.random()).substring(2);a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CLIPPATH,{id:"blocklyTrashBodyClipPath"+b},this.svgGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{width:WIDTH$$module$build$src$core$trashcan,height:BODY_HEIGHT$$module$build$src$core$trashcan,y:LID_HEIGHT$$module$build$src$core$trashcan},a);const c=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.IMAGE,
+{width:SPRITE$$module$build$src$core$sprites.width,x:-SPRITE_LEFT$$module$build$src$core$trashcan,height:SPRITE$$module$build$src$core$sprites.height,y:-SPRITE_TOP$$module$build$src$core$trashcan,"clip-path":"url(#blocklyTrashBodyClipPath"+b+")"},this.svgGroup);c.setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.workspace.options.pathToMedia+SPRITE$$module$build$src$core$sprites.url);a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.CLIPPATH,
+{id:"blocklyTrashLidClipPath"+b},this.svgGroup);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{width:WIDTH$$module$build$src$core$trashcan,height:LID_HEIGHT$$module$build$src$core$trashcan},a);this.svgLid=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.IMAGE,{width:SPRITE$$module$build$src$core$sprites.width,x:-SPRITE_LEFT$$module$build$src$core$trashcan,height:SPRITE$$module$build$src$core$sprites.height,y:-SPRITE_TOP$$module$build$src$core$trashcan,
+"clip-path":"url(#blocklyTrashLidClipPath"+b+")"},this.svgGroup);this.svgLid.setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.workspace.options.pathToMedia+SPRITE$$module$build$src$core$sprites.url);bind$$module$build$src$core$browser_events(this.svgGroup,"pointerdown",this,this.blockMouseDownWhenOpenable);bind$$module$build$src$core$browser_events(this.svgGroup,"pointerup",this,this.click);bind$$module$build$src$core$browser_events(c,"pointerover",this,this.mouseOver);bind$$module$build$src$core$browser_events(c,
+"pointerout",this,this.mouseOut);this.animateLid();return this.svgGroup}init(){0{let c;null==(c=this.flyout)||c.show(a);b.cursor="";let d;null==(d=this.workspace.scrollbar)||d.setVisible(!1)},10);this.fireUiEvent(!0)}}closeFlyout(){if(this.contentsIsOpen()){var a;null==(a=this.flyout)||a.hide();var b;null==(b=this.workspace.scrollbar)||b.setVisible(!0);this.fireUiEvent(!1);this.workspace.recordDragTargets()}}autoHide(a){!a&&this.flyout&&this.closeFlyout()}emptyContents(){this.hasContents()&&
+(this.contents.length=0,this.setMinOpenness(0),this.closeFlyout())}position(a,b){if(this.initialized){var c=getCornerOppositeToolbox$$module$build$src$core$positionable_helpers(this.workspace,a);a=getStartPositionRect$$module$build$src$core$positionable_helpers(c,new Size$$module$build$src$core$utils$size(WIDTH$$module$build$src$core$trashcan,BODY_HEIGHT$$module$build$src$core$trashcan+LID_HEIGHT$$module$build$src$core$trashcan),MARGIN_HORIZONTAL$$module$build$src$core$trashcan,MARGIN_VERTICAL$$module$build$src$core$trashcan,
+a,this.workspace);b=bumpPositionRect$$module$build$src$core$positionable_helpers(a,MARGIN_VERTICAL$$module$build$src$core$trashcan,c.vertical===verticalPosition$$module$build$src$core$positionable_helpers.TOP?bumpDirection$$module$build$src$core$positionable_helpers.DOWN:bumpDirection$$module$build$src$core$positionable_helpers.UP,b);this.top=b.top;this.left=b.left;var d;null==(d=this.svgGroup)||d.setAttribute("transform","translate("+this.left+","+this.top+")")}}getBoundingRectangle(){return new Rect$$module$build$src$core$utils$rect(this.top,
+this.top+BODY_HEIGHT$$module$build$src$core$trashcan+LID_HEIGHT$$module$build$src$core$trashcan,this.left,this.left+WIDTH$$module$build$src$core$trashcan)}getClientRect(){if(!this.svgGroup)return null;var a=this.svgGroup.getBoundingClientRect();const b=a.top+SPRITE_TOP$$module$build$src$core$trashcan-MARGIN_HOTSPOT$$module$build$src$core$trashcan;a=a.left+SPRITE_LEFT$$module$build$src$core$trashcan-MARGIN_HOTSPOT$$module$build$src$core$trashcan;return new Rect$$module$build$src$core$utils$rect(b,
+b+LID_HEIGHT$$module$build$src$core$trashcan+BODY_HEIGHT$$module$build$src$core$trashcan+2*MARGIN_HOTSPOT$$module$build$src$core$trashcan,a,a+WIDTH$$module$build$src$core$trashcan+2*MARGIN_HOTSPOT$$module$build$src$core$trashcan)}onDragOver(a){this.setLidOpen(this.wouldDelete_)}onDragExit(a){this.setLidOpen(!1)}onDrop(a){setTimeout(this.setLidOpen.bind(this,!1),100)}setLidOpen(a){this.isLidOpen!==a&&(this.lidTask&&clearTimeout(this.lidTask),this.isLidOpen=a,this.animateLid())}animateLid(){const a=
+ANIMATION_FRAMES$$module$build$src$core$trashcan;var b=1/(a+1);this.lidOpen+=this.isLidOpen?b:-b;this.lidOpen=Math.min(Math.max(this.lidOpen,this.minOpenness),1);this.setLidAngle(this.lidOpen*MAX_LID_ANGLE$$module$build$src$core$trashcan);b=OPACITY_MIN$$module$build$src$core$trashcan+this.lidOpen*(OPACITY_MAX$$module$build$src$core$trashcan-OPACITY_MIN$$module$build$src$core$trashcan);this.svgGroup&&(this.svgGroup.style.opacity=`${b}`);this.lidOpen>this.minOpenness&&1>this.lidOpen&&(this.lidTask=
+setTimeout(this.animateLid.bind(this),ANIMATION_LENGTH$$module$build$src$core$trashcan/a))}setLidAngle(a){const b=this.workspace.toolboxPosition===Position$$module$build$src$core$utils$toolbox.RIGHT||this.workspace.horizontalLayout&&this.workspace.RTL;let c;null==(c=this.svgLid)||c.setAttribute("transform","rotate("+(b?-a:a)+","+(b?4:WIDTH$$module$build$src$core$trashcan-4)+","+(LID_HEIGHT$$module$build$src$core$trashcan-2)+")")}setMinOpenness(a){this.minOpenness=a;this.isLidOpen||this.setLidAngle(a*
+MAX_LID_ANGLE$$module$build$src$core$trashcan)}closeLid(){this.setLidOpen(!1)}click(){this.hasContents()&&this.openFlyout()}fireUiEvent(a){a=new (get$$module$build$src$core$events$utils(TRASHCAN_OPEN$$module$build$src$core$events$utils))(a,this.workspace.id);fire$$module$build$src$core$events$utils(a)}blockMouseDownWhenOpenable(a){!this.contentsIsOpen()&&this.hasContents()&&a.stopPropagation()}mouseOver(){this.hasContents()&&this.setLidOpen(!0)}mouseOut(){this.setLidOpen(!1)}onDelete(a){if(!(0>=this.workspace.options.maxTrashcanContents||
+a.type!==$.DELETE$$module$build$src$core$events$utils||a.type!==$.DELETE$$module$build$src$core$events$utils||a.wasShadow)){if(!a.oldJson)throw Error("Encountered a delete event without proper oldJson");a=JSON.stringify(this.cleanBlockJson(a.oldJson));if(-1===this.contents.indexOf(a)){for(this.contents.unshift(a);this.contents.length>this.workspace.options.maxTrashcanContents;)this.contents.pop();this.setMinOpenness(HAS_BLOCKS_LID_ANGLE$$module$build$src$core$trashcan)}}}cleanBlockJson(a){function b(c){if(c){delete c.id;
+delete c.x;delete c.y;delete c.enabled;if(c.icons&&c.icons.comment){var d=c.icons.comment;delete d.height;delete d.width;delete d.pinned}d=c.inputs;for(var e in d){var f=d[e];const g=f.block;f=f.shadow;g&&b(g);f&&b(f)}c.next&&(e=c.next,c=e.block,e=e.shadow,c&&b(c),e&&b(e))}}a=JSON.parse(JSON.stringify(a));b(a);return Object.assign({},{kind:"BLOCK"},a)}},WIDTH$$module$build$src$core$trashcan=47,BODY_HEIGHT$$module$build$src$core$trashcan=44,LID_HEIGHT$$module$build$src$core$trashcan=16,MARGIN_VERTICAL$$module$build$src$core$trashcan=
+20,MARGIN_HORIZONTAL$$module$build$src$core$trashcan=20,MARGIN_HOTSPOT$$module$build$src$core$trashcan=10,SPRITE_LEFT$$module$build$src$core$trashcan=0,SPRITE_TOP$$module$build$src$core$trashcan=32,HAS_BLOCKS_LID_ANGLE$$module$build$src$core$trashcan=.1,ANIMATION_LENGTH$$module$build$src$core$trashcan=80,ANIMATION_FRAMES$$module$build$src$core$trashcan=4,OPACITY_MIN$$module$build$src$core$trashcan=.4,OPACITY_MAX$$module$build$src$core$trashcan=.8,MAX_LID_ANGLE$$module$build$src$core$trashcan=45,module$build$src$core$trashcan=
+{};module$build$src$core$trashcan.Trashcan=Trashcan$$module$build$src$core$trashcan;var ShortcutRegistry$$module$build$src$core$shortcut_registry=class{constructor(){this.shortcuts=new Map;this.keyMap=new Map;this.reset()}reset(){this.shortcuts.clear();this.keyMap.clear()}register(a,b){if(this.shortcuts.get(a.name)&&!b)throw Error(`Shortcut named "${a.name}" already exists.`);this.shortcuts.set(a.name,a);if((b=a.keyCodes)&&0saveProcedure$$module$build$src$core$serialization$procedures(b));return a.length?a:null}load(a,b){const c=b.getProcedureMap();for(const d of a)c.add(loadProcedure$$module$build$src$core$serialization$procedures(this.procedureModelClass,
+this.parameterModelClass,d,b))}clear(a){a.getProcedureMap().clear()}},module$build$src$core$serialization$procedures={};module$build$src$core$serialization$procedures.ProcedureSerializer=ProcedureSerializer$$module$build$src$core$serialization$procedures;module$build$src$core$serialization$procedures.loadParameter=loadParameter$$module$build$src$core$serialization$procedures;module$build$src$core$serialization$procedures.loadProcedure=loadProcedure$$module$build$src$core$serialization$procedures;
+module$build$src$core$serialization$procedures.saveParameter=saveParameter$$module$build$src$core$serialization$procedures;module$build$src$core$serialization$procedures.saveProcedure=saveProcedure$$module$build$src$core$serialization$procedures;var VariableSerializer$$module$build$src$core$serialization$variables=class{constructor(){this.priority=VARIABLES$$module$build$src$core$serialization$priorities}save(a){const b=[];for(const c of a.getAllVariables())a={name:c.name,id:c.getId()},c.type&&(a.type=c.type),b.push(a);return b.length?b:null}load(a,b){for(const c of a)b.createVariable(c.name,c.type,c.id)}clear(a){a.getVariableMap().clear()}};register$$module$build$src$core$serialization$registry("variables",new VariableSerializer$$module$build$src$core$serialization$variables);
+var module$build$src$core$serialization$variables={};module$build$src$core$serialization$variables.VariableSerializer=VariableSerializer$$module$build$src$core$serialization$variables;var module$build$src$core$serialization$workspaces={};module$build$src$core$serialization$workspaces.load=load$$module$build$src$core$serialization$workspaces;module$build$src$core$serialization$workspaces.save=save$$module$build$src$core$serialization$workspaces;var module$build$src$core$serialization={blocks:module$build$src$core$serialization$blocks,exceptions:module$build$src$core$serialization$exceptions,priorities:module$build$src$core$serialization$priorities,procedures:module$build$src$core$serialization$procedures,registry:module$build$src$core$serialization$registry,variables:module$build$src$core$serialization$variables,workspaces:module$build$src$core$serialization$workspaces};var ScrollbarPair$$module$build$src$core$scrollbar_pair=class{constructor(a,b,c,d,e){this.workspace=a;this.oldHostMetrics_=this.corner_=this.vScroll=this.hScroll=null;b=void 0===b?!0:b;c=void 0===c?!0:c;const f=b&&c;b&&(this.hScroll=new Scrollbar$$module$build$src$core$scrollbar(a,!0,f,d,e));c&&(this.vScroll=new Scrollbar$$module$build$src$core$scrollbar(a,!1,f,d,e));f&&(this.corner_=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.RECT,{height:Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness,
+width:Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness,"class":"blocklyScrollbarBackground"}),insertAfter$$module$build$src$core$utils$dom(this.corner_,a.getBubbleCanvas()))}dispose(){removeNode$$module$build$src$core$utils$dom(this.corner_);this.oldHostMetrics_=this.corner_=null;this.hScroll&&(this.hScroll.dispose(),this.hScroll=null);this.vScroll&&(this.vScroll.dispose(),this.vScroll=null)}resize(){const a=this.workspace.getMetrics();if(a){var b=!1,c=!1;this.oldHostMetrics_&&this.oldHostMetrics_.viewWidth===
+a.viewWidth&&this.oldHostMetrics_.viewHeight===a.viewHeight&&this.oldHostMetrics_.absoluteTop===a.absoluteTop&&this.oldHostMetrics_.absoluteLeft===a.absoluteLeft?(this.oldHostMetrics_&&this.oldHostMetrics_.scrollWidth===a.scrollWidth&&this.oldHostMetrics_.viewLeft===a.viewLeft&&this.oldHostMetrics_.scrollLeft===a.scrollLeft||(b=!0),this.oldHostMetrics_&&this.oldHostMetrics_.scrollHeight===a.scrollHeight&&this.oldHostMetrics_.viewTop===a.viewTop&&this.oldHostMetrics_.scrollTop===a.scrollTop||(c=!0)):
+c=b=!0;if(b||c){try{$.disable$$module$build$src$core$events$utils(),this.hScroll&&b&&this.hScroll.resize(a),this.vScroll&&c&&this.vScroll.resize(a)}finally{$.enable$$module$build$src$core$events$utils()}this.workspace.maybeFireViewportChangeEvent()}if(this.hScroll&&this.vScroll){if(!this.oldHostMetrics_||this.oldHostMetrics_.viewWidth!==a.viewWidth||this.oldHostMetrics_.absoluteLeft!==a.absoluteLeft){let d;null==(d=this.corner_)||d.setAttribute("x",String(this.vScroll.position.x))}if(!this.oldHostMetrics_||
+this.oldHostMetrics_.viewHeight!==a.viewHeight||this.oldHostMetrics_.absoluteTop!==a.absoluteTop){let d;null==(d=this.corner_)||d.setAttribute("y",String(this.hScroll.position.y))}}this.oldHostMetrics_=a}}canScrollHorizontally(){return!!this.hScroll}canScrollVertically(){return!!this.vScroll}setOrigin(a,b){this.hScroll&&this.hScroll.setOrigin(a,b);this.vScroll&&this.vScroll.setOrigin(a,b)}set(a,b,c){this.hScroll&&this.hScroll.set(a,!1);this.vScroll&&this.vScroll.set(b,!1);if(c||void 0===c)a={},this.hScroll&&
+(a.x=this.hScroll.getRatio_()),this.vScroll&&(a.y=this.vScroll.getRatio_()),this.workspace.setMetrics(a)}setX(a){this.hScroll&&this.hScroll.set(a,!0)}setY(a){this.vScroll&&this.vScroll.set(a,!0)}setContainerVisible(a){this.hScroll&&this.hScroll.setContainerVisible(a);this.vScroll&&this.vScroll.setContainerVisible(a)}isVisible(){let a=!1;this.hScroll&&(a=this.hScroll.isVisible());this.vScroll&&(a=a||this.vScroll.isVisible());return a}setVisible(a){this.hScroll&&this.hScroll.setVisibleInternal(a);this.vScroll&&
+this.vScroll.setVisibleInternal(a)}resizeContent(a){this.hScroll&&this.hScroll.resizeContentHorizontal(a);this.vScroll&&this.vScroll.resizeContentVertical(a)}resizeView(a){this.hScroll&&this.hScroll.resizeViewHorizontal(a);this.vScroll&&this.vScroll.resizeViewVertical(a)}},module$build$src$core$scrollbar_pair={};module$build$src$core$scrollbar_pair.ScrollbarPair=ScrollbarPair$$module$build$src$core$scrollbar_pair;var MetricsManager$$module$build$src$core$metrics_manager=class{constructor(a){this.workspace_=a}getDimensionsPx_(a){let b=0,c=0;a&&(b=a.getWidth(),c=a.getHeight());return new Size$$module$build$src$core$utils$size(b,c)}getFlyoutMetrics(a){a=this.getDimensionsPx_(this.workspace_.getFlyout(a));return{width:a.width,height:a.height,position:this.workspace_.toolboxPosition}}getToolboxMetrics(){const a=this.getDimensionsPx_(this.workspace_.getToolbox());return{width:a.width,height:a.height,position:this.workspace_.toolboxPosition}}getSvgMetrics(){return this.workspace_.getCachedParentSvgSize()}getAbsoluteMetrics(){let a=
+0;const b=this.getToolboxMetrics(),c=this.getFlyoutMetrics(!0),d=!!this.workspace_.getToolbox(),e=!!this.workspace_.getFlyout(!0);var f=d?b.position:c.position,g=f===Position$$module$build$src$core$utils$toolbox.LEFT;f=f===Position$$module$build$src$core$utils$toolbox.TOP;d&&g?a=b.width:e&&g&&(a=c.width);g=0;d&&f?g=b.height:e&&f&&(g=c.height);return{top:g,left:a}}getViewMetrics(a){a=a?this.workspace_.scale:1;const b=this.getSvgMetrics(),c=this.getToolboxMetrics(),d=this.getFlyoutMetrics(!0),e=this.workspace_.getToolbox()?
+c.position:d.position;if(this.workspace_.getToolbox())if(e===Position$$module$build$src$core$utils$toolbox.TOP||e===Position$$module$build$src$core$utils$toolbox.BOTTOM)b.height-=c.height;else{if(e===Position$$module$build$src$core$utils$toolbox.LEFT||e===Position$$module$build$src$core$utils$toolbox.RIGHT)b.width-=c.width}else if(this.workspace_.getFlyout(!0))if(e===Position$$module$build$src$core$utils$toolbox.TOP||e===Position$$module$build$src$core$utils$toolbox.BOTTOM)b.height-=d.height;else if(e===
+Position$$module$build$src$core$utils$toolbox.LEFT||e===Position$$module$build$src$core$utils$toolbox.RIGHT)b.width-=d.width;return{height:b.height/a,width:b.width/a,top:-this.workspace_.scrollY/a,left:-this.workspace_.scrollX/a}}getContentMetrics(a){a=a?1:this.workspace_.scale;const b=this.workspace_.getBlocksBoundingBox();return{height:(b.bottom-b.top)*a,width:(b.right-b.left)*a,top:b.top*a,left:b.left*a}}hasFixedEdges(){return!this.workspace_.isMovableHorizontally()||!this.workspace_.isMovableVertically()}getComputedFixedEdges_(a){if(!this.hasFixedEdges())return{};
+const b=this.workspace_.isMovableHorizontally(),c=this.workspace_.isMovableVertically();a=a||this.getViewMetrics(!1);const d={};c||(d.top=a.top,d.bottom=a.top+a.height);b||(d.left=a.left,d.right=a.left+a.width);return d}getPaddedContent_(a,b){const c=b.top+b.height,d=b.left+b.width,e=a.width;a=a.height;const f=e/2,g=a/2;return{top:Math.min(b.top-g,c-a),bottom:Math.max(c+g,b.top+a),left:Math.min(b.left-f,d-e),right:Math.max(d+f,b.left+e)}}getScrollMetrics(a,b,c){a=a?this.workspace_.scale:1;b=b||this.getViewMetrics(!1);
+var d=c||this.getContentMetrics();c=this.getComputedFixedEdges_(b);b=this.getPaddedContent_(b,d);d=void 0!==c.top?c.top:b.top;const e=void 0!==c.left?c.left:b.left;return{top:d/a,left:e/a,width:((void 0!==c.right?c.right:b.right)-e)/a,height:((void 0!==c.bottom?c.bottom:b.bottom)-d)/a}}getUiMetrics(){return{viewMetrics:this.getViewMetrics(),absoluteMetrics:this.getAbsoluteMetrics(),toolboxMetrics:this.getToolboxMetrics()}}getMetrics(){const a=this.getToolboxMetrics(),b=this.getFlyoutMetrics(!0),c=
+this.getSvgMetrics(),d=this.getAbsoluteMetrics(),e=this.getViewMetrics(),f=this.getContentMetrics(),g=this.getScrollMetrics(!1,e,f);return{contentHeight:f.height,contentWidth:f.width,contentTop:f.top,contentLeft:f.left,scrollHeight:g.height,scrollWidth:g.width,scrollTop:g.top,scrollLeft:g.left,viewHeight:e.height,viewWidth:e.width,viewTop:e.top,viewLeft:e.left,absoluteTop:d.top,absoluteLeft:d.left,svgHeight:c.height,svgWidth:c.width,toolboxWidth:a.width,toolboxHeight:a.height,toolboxPosition:a.position,
+flyoutWidth:b.width,flyoutHeight:b.height}}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.METRICS_MANAGER,DEFAULT$$module$build$src$core$registry,MetricsManager$$module$build$src$core$metrics_manager);var module$build$src$core$metrics_manager={};module$build$src$core$metrics_manager.MetricsManager=MetricsManager$$module$build$src$core$metrics_manager;var FinishedLoading$$module$build$src$core$events$workspace_events=class extends Abstract$$module$build$src$core$events$events_abstract{constructor(a){super();this.isBlank=!0;this.recordUndo=!1;this.type=FINISHED_LOADING$$module$build$src$core$events$utils;this.isBlank=!!a;a&&(this.workspaceId=a.id)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,FINISHED_LOADING$$module$build$src$core$events$utils,FinishedLoading$$module$build$src$core$events$workspace_events);
+var module$build$src$core$events$workspace_events={};module$build$src$core$events$workspace_events.FinishedLoading=FinishedLoading$$module$build$src$core$events$workspace_events;var BlockDrag$$module$build$src$core$events$events_block_drag=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b,c){super(a?a.workspace.id:void 0);this.type=BLOCK_DRAG$$module$build$src$core$events$utils;a&&(this.blockId=a.id,this.isStart=b,this.blocks=c)}toJson(){const a=super.toJson();if(void 0===this.isStart)throw Error("Whether this event is the start of a drag is undefined. Either pass the value to the constructor, or call fromJson");if(void 0===this.blockId)throw Error("The block ID is undefined. Either pass a block to the constructor, or call fromJson");
+a.isStart=this.isStart;a.blockId=this.blockId;a.blocks=this.blocks;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BlockDrag$$module$build$src$core$events$events_block_drag);b.isStart=a.isStart;b.blockId=a.blockId;b.blocks=a.blocks;return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,BLOCK_DRAG$$module$build$src$core$events$utils,BlockDrag$$module$build$src$core$events$events_block_drag);
+var module$build$src$core$events$events_block_drag={};module$build$src$core$events$events_block_drag.BlockDrag=BlockDrag$$module$build$src$core$events$events_block_drag;var bumpIntoBounds$$module$build$src$core$bump_objects=bumpObjectIntoBounds$$module$build$src$core$bump_objects,module$build$src$core$bump_objects={};module$build$src$core$bump_objects.bumpIntoBounds=bumpObjectIntoBounds$$module$build$src$core$bump_objects;module$build$src$core$bump_objects.bumpIntoBoundsHandler=bumpIntoBoundsHandler$$module$build$src$core$bump_objects;module$build$src$core$bump_objects.bumpTopObjectsIntoBounds=bumpTopObjectsIntoBounds$$module$build$src$core$bump_objects;var BlockDragger$$module$build$src$core$block_dragger=class{constructor(a,b){this.dragTarget_=null;this.wouldDeleteBlock_=!1;this.draggingBlock_=a;this.draggedConnectionManager_=new InsertionMarkerManager$$module$build$src$core$insertion_marker_manager(this.draggingBlock_);this.workspace_=b;this.startXY_=this.draggingBlock_.getRelativeToSurfaceXY();this.dragIconData_=initIconData$$module$build$src$core$block_dragger(a,this.startXY_)}dispose(){this.dragIconData_.length=0;this.draggedConnectionManager_&&
+this.draggedConnectionManager_.dispose()}startDrag(a,b){$.getGroup$$module$build$src$core$events$utils()||$.setGroup$$module$build$src$core$events$utils(!0);this.fireDragStartEvent_();this.draggingBlock_.bringToFront(!0);startTextWidthCache$$module$build$src$core$utils$dom();this.workspace_.setResizesEnabled(!1);disconnectUiStop$$module$build$src$core$block_animations();this.shouldDisconnect_(b)&&this.disconnectBlock_(b,a);this.draggingBlock_.setDragging(!0)}shouldDisconnect_(a){return!!(this.draggingBlock_.getParent()||
+a&&this.draggingBlock_.nextConnection&&this.draggingBlock_.nextConnection.targetBlock())}disconnectBlock_(a,b){this.draggingBlock_.unplug(a);a=this.pixelsToWorkspaceUnits_(b);a=Coordinate$$module$build$src$core$utils$coordinate.sum(this.startXY_,a);this.draggingBlock_.translate(a.x,a.y);disconnectUiEffect$$module$build$src$core$block_animations(this.draggingBlock_);this.draggedConnectionManager_.updateAvailableConnections()}fireDragStartEvent_(){const a=new (get$$module$build$src$core$events$utils(BLOCK_DRAG$$module$build$src$core$events$utils))(this.draggingBlock_,
+!0,this.draggingBlock_.getDescendants(!1));fire$$module$build$src$core$events$utils(a)}drag(a,b){b=this.pixelsToWorkspaceUnits_(b);var c=Coordinate$$module$build$src$core$utils$coordinate.sum(this.startXY_,b);this.draggingBlock_.moveDuringDrag(c);this.dragIcons_(b);c=this.dragTarget_;this.dragTarget_=this.workspace_.getDragTarget(a);this.draggedConnectionManager_.update(b,this.dragTarget_);a=this.wouldDeleteBlock_;this.wouldDeleteBlock_=this.draggedConnectionManager_.wouldDeleteBlock;a!==this.wouldDeleteBlock_&&
+this.updateCursorDuringBlockDrag_();this.dragTarget_!==c&&(c&&c.onDragExit(this.draggingBlock_),this.dragTarget_&&this.dragTarget_.onDragEnter(this.draggingBlock_));this.dragTarget_&&this.dragTarget_.onDragOver(this.draggingBlock_)}endDrag(a,b){this.drag(a,b);this.dragIconData_=[];this.fireDragEndEvent_();stopTextWidthCache$$module$build$src$core$utils$dom();disconnectUiStop$$module$build$src$core$block_animations();a=null;this.dragTarget_&&this.dragTarget_.shouldPreventMove(this.draggingBlock_)||
+(a=this.getNewLocationAfterDrag_(b).delta);if(this.dragTarget_)this.dragTarget_.onDrop(this.draggingBlock_);this.maybeDeleteBlock_()||(this.draggingBlock_.setDragging(!1),a?this.updateBlockAfterMove_():bumpObjectIntoBounds$$module$build$src$core$bump_objects(this.draggingBlock_.workspace,this.workspace_.getMetricsManager().getScrollMetrics(!0),this.draggingBlock_));this.workspace_.setResizesEnabled(!0);$.setGroup$$module$build$src$core$events$utils(!1)}getNewLocationAfterDrag_(a){a=this.pixelsToWorkspaceUnits_(a);
+const b=Coordinate$$module$build$src$core$utils$coordinate.sum(this.startXY_,a);return{delta:a,newLocation:b}}maybeDeleteBlock_(){return this.wouldDeleteBlock_?(this.fireMoveEvent_(),this.draggingBlock_.dispose(!1,!0),draggingConnections$$module$build$src$core$common.length=0,!0):!1}updateBlockAfterMove_(){this.fireMoveEvent_();this.draggedConnectionManager_.wouldConnectBlock()?this.draggedConnectionManager_.applyConnections():this.draggingBlock_.queueRender();this.draggingBlock_.scheduleSnapAndBump()}fireDragEndEvent_(){const a=
+new (get$$module$build$src$core$events$utils(BLOCK_DRAG$$module$build$src$core$events$utils))(this.draggingBlock_,!1,this.draggingBlock_.getDescendants(!1));fire$$module$build$src$core$events$utils(a)}updateToolboxStyle_(a){const b=this.workspace_.getToolbox();if(b){const c=this.draggingBlock_.isDeletable()?"blocklyToolboxDelete":"blocklyToolboxGrab";a&&"function"===typeof b.removeStyle?b.removeStyle(c):a||"function"!==typeof b.addStyle||b.addStyle(c)}}fireMoveEvent_(){if(!this.draggingBlock_.isDeadOrDying()){var a=
+new (get$$module$build$src$core$events$utils($.MOVE$$module$build$src$core$events$utils))(this.draggingBlock_);a.setReason(["drag"]);a.oldCoordinate=this.startXY_;a.recordNew();fire$$module$build$src$core$events$utils(a)}}updateCursorDuringBlockDrag_(){this.draggingBlock_.setDeleteStyle(this.wouldDeleteBlock_)}pixelsToWorkspaceUnits_(a){a=new Coordinate$$module$build$src$core$utils$coordinate(a.x/this.workspace_.scale,a.y/this.workspace_.scale);this.workspace_.isMutator&&a.scale(1/this.workspace_.options.parentWorkspace.scale);
+return a}dragIcons_(a){for(const b of this.dragIconData_)b.icon.onLocationChange(Coordinate$$module$build$src$core$utils$coordinate.sum(b.location,a))}getInsertionMarkers(){return this.draggedConnectionManager_&&this.draggedConnectionManager_.getInsertionMarkers?this.draggedConnectionManager_.getInsertionMarkers():[]}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.BLOCK_DRAGGER,DEFAULT$$module$build$src$core$registry,BlockDragger$$module$build$src$core$block_dragger);
+var module$build$src$core$block_dragger={};module$build$src$core$block_dragger.BlockDragger=BlockDragger$$module$build$src$core$block_dragger;var module$build$src$core$bubbles={};module$build$src$core$bubbles.Bubble=Bubble$$module$build$src$core$bubbles$bubble;module$build$src$core$bubbles.MiniWorkspaceBubble=MiniWorkspaceBubble$$module$build$src$core$bubbles$mini_workspace_bubble;module$build$src$core$bubbles.TextBubble=TextBubble$$module$build$src$core$bubbles$text_bubble;module$build$src$core$bubbles.TextInputBubble=TextInputBubble$$module$build$src$core$bubbles$textinput_bubble;var BlockFieldIntermediateChange$$module$build$src$core$events$events_block_field_intermediate_change=class extends BlockBase$$module$build$src$core$events$events_block_base{constructor(a,b,c,d){super(a);this.type=BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils;this.recordUndo=!1;a&&(this.name=b,this.oldValue=c,this.newValue=d)}toJson(){const a=super.toJson();if(!this.name)throw Error("The changed field name is undefined. Either pass a name to the constructor, or call fromJson.");
+a.name=this.name;a.oldValue=this.oldValue;a.newValue=this.newValue;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BlockFieldIntermediateChange$$module$build$src$core$events$events_block_field_intermediate_change);b.name=a.name;b.oldValue=a.oldValue;b.newValue=a.newValue;return b}isNull(){return this.oldValue===this.newValue}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils,BlockFieldIntermediateChange$$module$build$src$core$events$events_block_field_intermediate_change);
+var module$build$src$core$events$events_block_field_intermediate_change={};module$build$src$core$events$events_block_field_intermediate_change.BlockFieldIntermediateChange=BlockFieldIntermediateChange$$module$build$src$core$events$events_block_field_intermediate_change;var BlockMove$$module$build$src$core$events$events_block_move=class extends BlockBase$$module$build$src$core$events$events_block_base{constructor(a){super(a);this.type=$.MOVE$$module$build$src$core$events$utils;a&&(a.isShadow()&&(this.recordUndo=!1),a=this.currentLocation_(),this.oldParentId=a.parentId,this.oldInputName=a.inputName,this.oldCoordinate=a.coordinate)}toJson(){const a=super.toJson();a.oldParentId=this.oldParentId;a.oldInputName=this.oldInputName;this.oldCoordinate&&(a.oldCoordinate=`${Math.round(this.oldCoordinate.x)}, `+
+`${Math.round(this.oldCoordinate.y)}`);a.newParentId=this.newParentId;a.newInputName=this.newInputName;this.newCoordinate&&(a.newCoordinate=`${Math.round(this.newCoordinate.x)}, `+`${Math.round(this.newCoordinate.y)}`);this.reason&&(a.reason=this.reason);this.recordUndo||(a.recordUndo=this.recordUndo);return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BlockMove$$module$build$src$core$events$events_block_move);b.oldParentId=a.oldParentId;b.oldInputName=a.oldInputName;a.oldCoordinate&&
+(c=a.oldCoordinate.split(","),b.oldCoordinate=new Coordinate$$module$build$src$core$utils$coordinate(Number(c[0]),Number(c[1])));b.newParentId=a.newParentId;b.newInputName=a.newInputName;a.newCoordinate&&(c=a.newCoordinate.split(","),b.newCoordinate=new Coordinate$$module$build$src$core$utils$coordinate(Number(c[0]),Number(c[1])));void 0!==a.reason&&(b.reason=a.reason);void 0!==a.recordUndo&&(b.recordUndo=a.recordUndo);return b}recordNew(){const a=this.currentLocation_();this.newParentId=a.parentId;
+this.newInputName=a.inputName;this.newCoordinate=a.coordinate}setReason(a){this.reason=a}currentLocation_(){var a=this.getEventWorkspace_();if(!this.blockId)throw Error("The block ID is undefined. Either pass a block to the constructor, or call fromJson");var b=a.getBlockById(this.blockId);if(!b)throw Error("The block associated with the block move event could not be found");a={};const c=b.getParent();if(c){if(a.parentId=c.id,b=c.getInputWithBlock(b))a.inputName=b.name}else a.coordinate=b.getRelativeToSurfaceXY();
+return a}isNull(){return this.oldParentId===this.newParentId&&this.oldInputName===this.newInputName&&Coordinate$$module$build$src$core$utils$coordinate.equals(this.oldCoordinate,this.newCoordinate)}run(a){var b=this.getEventWorkspace_();if(!this.blockId)throw Error("The block ID is undefined. Either pass a block to the constructor, or call fromJson");var c=b.getBlockById(this.blockId);if(c){var d=a?this.newParentId:this.oldParentId,e=a?this.newInputName:this.oldInputName;a=a?this.newCoordinate:this.oldCoordinate;
+if(d){var f=b.getBlockById(d);if(!f){console.warn("Can't connect to non-existent block: "+d);return}}c.getParent()&&c.unplug();if(a)e=c.getRelativeToSurfaceXY(),c.moveBy(a.x-e.x,a.y-e.y,this.reason);else{b=c.outputConnection;if(!b||c.previousConnection&&c.previousConnection.isConnected())b=c.previousConnection;let g,h;c=null==(h=b)?void 0:h.type;if(e){if(c=f.getInput(e))g=c.connection}else c===ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT&&(g=f.nextConnection);g&&b?b.connect(g):
+console.warn("Can't connect to non-existent input: "+e)}}else console.warn("Can't move non-existent block: "+this.blockId)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,$.MOVE$$module$build$src$core$events$utils,BlockMove$$module$build$src$core$events$events_block_move);var module$build$src$core$events$events_block_move={};module$build$src$core$events$events_block_move.BlockMove=BlockMove$$module$build$src$core$events$events_block_move;var BubbleOpen$$module$build$src$core$events$events_bubble_open=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b,c){super(a?a.workspace.id:void 0);this.type=BUBBLE_OPEN$$module$build$src$core$events$utils;a&&(this.blockId=a.id,this.isOpen=b,this.bubbleType=c)}toJson(){const a=super.toJson();if(void 0===this.isOpen)throw Error("Whether this event is for opening the bubble is undefined. Either pass the value to the constructor, or call fromJson");if(!this.bubbleType)throw Error("The type of bubble is undefined. Either pass the value to the constructor, or call fromJson");
+a.isOpen=this.isOpen;a.bubbleType=this.bubbleType;a.blockId=this.blockId||"";return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new BubbleOpen$$module$build$src$core$events$events_bubble_open);b.isOpen=a.isOpen;b.bubbleType=a.bubbleType;b.blockId=a.blockId;return b}},BubbleType$$module$build$src$core$events$events_bubble_open;
+(function(a){a.MUTATOR="mutator";a.COMMENT="comment";a.WARNING="warning"})(BubbleType$$module$build$src$core$events$events_bubble_open||(BubbleType$$module$build$src$core$events$events_bubble_open={}));register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,BUBBLE_OPEN$$module$build$src$core$events$utils,BubbleOpen$$module$build$src$core$events$events_bubble_open);var module$build$src$core$events$events_bubble_open={};
+module$build$src$core$events$events_bubble_open.BubbleOpen=BubbleOpen$$module$build$src$core$events$events_bubble_open;module$build$src$core$events$events_bubble_open.BubbleType=BubbleType$$module$build$src$core$events$events_bubble_open;var CommentBase$$module$build$src$core$events$events_comment_base=class extends Abstract$$module$build$src$core$events$events_abstract{constructor(a){super();this.isBlank=!a;a&&(this.commentId=a.id,this.workspaceId=a.workspace.id,this.group=$.getGroup$$module$build$src$core$events$utils(),this.recordUndo=getRecordUndo$$module$build$src$core$events$utils())}toJson(){const a=super.toJson();if(!this.commentId)throw Error("The comment ID is undefined. Either pass a comment to the constructor, or call fromJson");
+a.commentId=this.commentId;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new CommentBase$$module$build$src$core$events$events_comment_base);b.commentId=a.commentId;return b}static CommentCreateDeleteHelper(a,b){var c=a.getEventWorkspace_();if(b){b=$.createElement$$module$build$src$core$utils$xml("xml");if(!a.xml)throw Error("Ecountered a comment event without proper xml");b.appendChild(a.xml);$.domToWorkspace$$module$build$src$core$xml(b,c)}else{if(!a.commentId)throw Error("The comment ID is undefined. Either pass a comment to the constructor, or call fromJson");
+(c=c.getCommentById(a.commentId))?c.dispose():console.warn("Can't uncreate non-existent comment: "+a.commentId)}}},module$build$src$core$events$events_comment_base={};module$build$src$core$events$events_comment_base.CommentBase=CommentBase$$module$build$src$core$events$events_comment_base;var CommentChange$$module$build$src$core$events$events_comment_change=class extends CommentBase$$module$build$src$core$events$events_comment_base{constructor(a,b,c){super(a);this.type=COMMENT_CHANGE$$module$build$src$core$events$utils;a&&(this.oldContents_="undefined"===typeof b?"":b,this.newContents_="undefined"===typeof c?"":c)}toJson(){const a=super.toJson();if(!this.oldContents_)throw Error("The old contents is undefined. Either pass a value to the constructor, or call fromJson");if(!this.newContents_)throw Error("The new contents is undefined. Either pass a value to the constructor, or call fromJson");
+a.oldContents=this.oldContents_;a.newContents=this.newContents_;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new CommentChange$$module$build$src$core$events$events_comment_change);b.oldContents_=a.oldContents;b.newContents_=a.newContents;return b}isNull(){return this.oldContents_===this.newContents_}run(a){var b=this.getEventWorkspace_();if(!this.commentId)throw Error("The comment ID is undefined. Either pass a comment to the constructor, or call fromJson");if(b=b.getCommentById(this.commentId)){var c=
+a?this.newContents_:this.oldContents_;if(!c){if(a)throw Error("The new contents is undefined. Either pass a value to the constructor, or call fromJson");throw Error("The old contents is undefined. Either pass a value to the constructor, or call fromJson");}b.setContent(c)}else console.warn("Can't change non-existent comment: "+this.commentId)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,COMMENT_CHANGE$$module$build$src$core$events$utils,CommentChange$$module$build$src$core$events$events_comment_change);
+var module$build$src$core$events$events_comment_change={};module$build$src$core$events$events_comment_change.CommentChange=CommentChange$$module$build$src$core$events$events_comment_change;var CommentCreate$$module$build$src$core$events$events_comment_create=class extends CommentBase$$module$build$src$core$events$events_comment_base{constructor(a){super(a);this.type=COMMENT_CREATE$$module$build$src$core$events$utils;a&&(this.xml=a.toXmlWithXY())}toJson(){const a=super.toJson();if(!this.xml)throw Error("The comment XML is undefined. Either pass a comment to the constructor, or call fromJson");a.xml=domToText$$module$build$src$core$xml(this.xml);return a}static fromJson(a,b,c){b=super.fromJson(a,
+b,null!=c?c:new CommentCreate$$module$build$src$core$events$events_comment_create);b.xml=$.textToDom$$module$build$src$core$utils$xml(a.xml);return b}run(a){CommentBase$$module$build$src$core$events$events_comment_base.CommentCreateDeleteHelper(this,a)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,COMMENT_CREATE$$module$build$src$core$events$utils,CommentCreate$$module$build$src$core$events$events_comment_create);
+var module$build$src$core$events$events_comment_create={};module$build$src$core$events$events_comment_create.CommentCreate=CommentCreate$$module$build$src$core$events$events_comment_create;var CommentDelete$$module$build$src$core$events$events_comment_delete=class extends CommentBase$$module$build$src$core$events$events_comment_base{constructor(a){super(a);this.type=COMMENT_DELETE$$module$build$src$core$events$utils;a&&(this.xml=a.toXmlWithXY())}run(a){CommentBase$$module$build$src$core$events$events_comment_base.CommentCreateDeleteHelper(this,!a)}toJson(){const a=super.toJson();if(!this.xml)throw Error("The comment XML is undefined. Either pass a comment to the constructor, or call fromJson");
+a.xml=domToText$$module$build$src$core$xml(this.xml);return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new CommentDelete$$module$build$src$core$events$events_comment_delete);b.xml=$.textToDom$$module$build$src$core$utils$xml(a.xml);return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,COMMENT_DELETE$$module$build$src$core$events$utils,CommentDelete$$module$build$src$core$events$events_comment_delete);
+var module$build$src$core$events$events_comment_delete={};module$build$src$core$events$events_comment_delete.CommentDelete=CommentDelete$$module$build$src$core$events$events_comment_delete;var CommentMove$$module$build$src$core$events$events_comment_move=class extends CommentBase$$module$build$src$core$events$events_comment_base{constructor(a){super(a);this.type=COMMENT_MOVE$$module$build$src$core$events$utils;a&&(this.comment_=a,this.oldCoordinate_=a.getRelativeToSurfaceXY())}recordNew(){if(this.newCoordinate_)throw Error("Tried to record the new position of a comment on the same event twice.");if(!this.comment_)throw Error("The comment is undefined. Pass a comment to the constructor if you want to use the record functionality");
+this.newCoordinate_=this.comment_.getRelativeToSurfaceXY()}setOldCoordinate(a){this.oldCoordinate_=a}toJson(){const a=super.toJson();if(!this.oldCoordinate_)throw Error("The old comment position is undefined. Either pass a comment to the constructor, or call fromJson");if(!this.newCoordinate_)throw Error("The new comment position is undefined. Either call recordNew, or call fromJson");a.oldCoordinate=`${Math.round(this.oldCoordinate_.x)}, `+`${Math.round(this.oldCoordinate_.y)}`;a.newCoordinate=Math.round(this.newCoordinate_.x)+
+","+Math.round(this.newCoordinate_.y);return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new CommentMove$$module$build$src$core$events$events_comment_move);c=a.oldCoordinate.split(",");b.oldCoordinate_=new Coordinate$$module$build$src$core$utils$coordinate(Number(c[0]),Number(c[1]));c=a.newCoordinate.split(",");b.newCoordinate_=new Coordinate$$module$build$src$core$utils$coordinate(Number(c[0]),Number(c[1]));return b}isNull(){return Coordinate$$module$build$src$core$utils$coordinate.equals(this.oldCoordinate_,
+this.newCoordinate_)}run(a){var b=this.getEventWorkspace_();if(!this.commentId)throw Error("The comment ID is undefined. Either pass a comment to the constructor, or call fromJson");if(b=b.getCommentById(this.commentId)){a=a?this.newCoordinate_:this.oldCoordinate_;if(!a)throw Error("Either oldCoordinate_ or newCoordinate_ is undefined. Either pass a comment to the constructor and call recordNew, or call fromJson");var c=b.getRelativeToSurfaceXY();b.moveBy(a.x-c.x,a.y-c.y)}else console.warn("Can't move non-existent comment: "+
+this.commentId)}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,COMMENT_MOVE$$module$build$src$core$events$utils,CommentMove$$module$build$src$core$events$events_comment_move);var module$build$src$core$events$events_comment_move={};module$build$src$core$events$events_comment_move.CommentMove=CommentMove$$module$build$src$core$events$events_comment_move;var ToolboxItemSelect$$module$build$src$core$events$events_toolbox_item_select=class extends UiBase$$module$build$src$core$events$events_ui_base{constructor(a,b,c){super(c);this.type=TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils;this.oldItem=null!=a?a:void 0;this.newItem=null!=b?b:void 0}toJson(){const a=super.toJson();a.oldItem=this.oldItem;a.newItem=this.newItem;return a}static fromJson(a,b,c){b=super.fromJson(a,b,null!=c?c:new ToolboxItemSelect$$module$build$src$core$events$events_toolbox_item_select);
+b.oldItem=a.oldItem;b.newItem=a.newItem;return b}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.EVENT,TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils,ToolboxItemSelect$$module$build$src$core$events$events_toolbox_item_select);var module$build$src$core$events$events_toolbox_item_select={};module$build$src$core$events$events_toolbox_item_select.ToolboxItemSelect=ToolboxItemSelect$$module$build$src$core$events$events_toolbox_item_select;var BLOCK_CHANGE$$module$build$src$core$events$events=$.CHANGE$$module$build$src$core$events$utils,BLOCK_CREATE$$module$build$src$core$events$events=$.CREATE$$module$build$src$core$events$utils,BLOCK_DELETE$$module$build$src$core$events$events=$.DELETE$$module$build$src$core$events$utils,BLOCK_DRAG$$module$build$src$core$events$events=BLOCK_DRAG$$module$build$src$core$events$utils,BLOCK_MOVE$$module$build$src$core$events$events=$.MOVE$$module$build$src$core$events$utils,BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$events=
+BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils,BUBBLE_OPEN$$module$build$src$core$events$events=BUBBLE_OPEN$$module$build$src$core$events$utils,BUMP_EVENTS$$module$build$src$core$events$events=BUMP_EVENTS$$module$build$src$core$events$utils,CHANGE$$module$build$src$core$events$events=$.CHANGE$$module$build$src$core$events$utils,CLICK$$module$build$src$core$events$events=CLICK$$module$build$src$core$events$utils,COMMENT_CHANGE$$module$build$src$core$events$events=COMMENT_CHANGE$$module$build$src$core$events$utils,
+COMMENT_CREATE$$module$build$src$core$events$events=COMMENT_CREATE$$module$build$src$core$events$utils,COMMENT_DELETE$$module$build$src$core$events$events=COMMENT_DELETE$$module$build$src$core$events$utils,COMMENT_MOVE$$module$build$src$core$events$events=COMMENT_MOVE$$module$build$src$core$events$utils,CREATE$$module$build$src$core$events$events=$.CREATE$$module$build$src$core$events$utils,DELETE$$module$build$src$core$events$events=$.DELETE$$module$build$src$core$events$utils,FINISHED_LOADING$$module$build$src$core$events$events=
+FINISHED_LOADING$$module$build$src$core$events$utils,MARKER_MOVE$$module$build$src$core$events$events=MARKER_MOVE$$module$build$src$core$events$utils,MOVE$$module$build$src$core$events$events=$.MOVE$$module$build$src$core$events$utils,SELECTED$$module$build$src$core$events$events=SELECTED$$module$build$src$core$events$utils,THEME_CHANGE$$module$build$src$core$events$events=THEME_CHANGE$$module$build$src$core$events$utils,TOOLBOX_ITEM_SELECT$$module$build$src$core$events$events=TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils,
+TRASHCAN_OPEN$$module$build$src$core$events$events=TRASHCAN_OPEN$$module$build$src$core$events$utils,UI$$module$build$src$core$events$events=UI$$module$build$src$core$events$utils,VAR_CREATE$$module$build$src$core$events$events=VAR_CREATE$$module$build$src$core$events$utils,VAR_DELETE$$module$build$src$core$events$events=VAR_DELETE$$module$build$src$core$events$utils,VAR_RENAME$$module$build$src$core$events$events=VAR_RENAME$$module$build$src$core$events$utils,VIEWPORT_CHANGE$$module$build$src$core$events$events=
+VIEWPORT_CHANGE$$module$build$src$core$events$utils,clearPendingUndo$$module$build$src$core$events$events=clearPendingUndo$$module$build$src$core$events$utils,disable$$module$build$src$core$events$events=$.disable$$module$build$src$core$events$utils,enable$$module$build$src$core$events$events=$.enable$$module$build$src$core$events$utils,filter$$module$build$src$core$events$events=filter$$module$build$src$core$events$utils,fire$$module$build$src$core$events$events=fire$$module$build$src$core$events$utils,
+fromJson$$module$build$src$core$events$events=fromJson$$module$build$src$core$events$utils,getDescendantIds$$module$build$src$core$events$events=getDescendantIds$$module$build$src$core$events$utils,get$$module$build$src$core$events$events=get$$module$build$src$core$events$utils,getGroup$$module$build$src$core$events$events=$.getGroup$$module$build$src$core$events$utils,getRecordUndo$$module$build$src$core$events$events=getRecordUndo$$module$build$src$core$events$utils,isEnabled$$module$build$src$core$events$events=
+isEnabled$$module$build$src$core$events$utils,setGroup$$module$build$src$core$events$events=$.setGroup$$module$build$src$core$events$utils,setRecordUndo$$module$build$src$core$events$events=setRecordUndo$$module$build$src$core$events$utils,disableOrphans$$module$build$src$core$events$events=disableOrphans$$module$build$src$core$events$utils,module$build$src$core$events$events={};module$build$src$core$events$events.Abstract=Abstract$$module$build$src$core$events$events_abstract;
+module$build$src$core$events$events.BLOCK_CHANGE=$.CHANGE$$module$build$src$core$events$utils;module$build$src$core$events$events.BLOCK_CREATE=$.CREATE$$module$build$src$core$events$utils;module$build$src$core$events$events.BLOCK_DELETE=$.DELETE$$module$build$src$core$events$utils;module$build$src$core$events$events.BLOCK_DRAG=BLOCK_DRAG$$module$build$src$core$events$utils;module$build$src$core$events$events.BLOCK_FIELD_INTERMEDIATE_CHANGE=BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils;
+module$build$src$core$events$events.BLOCK_MOVE=$.MOVE$$module$build$src$core$events$utils;module$build$src$core$events$events.BUBBLE_OPEN=BUBBLE_OPEN$$module$build$src$core$events$utils;module$build$src$core$events$events.BUMP_EVENTS=BUMP_EVENTS$$module$build$src$core$events$utils;module$build$src$core$events$events.BlockBase=BlockBase$$module$build$src$core$events$events_block_base;module$build$src$core$events$events.BlockChange=BlockChange$$module$build$src$core$events$events_block_change;
+module$build$src$core$events$events.BlockCreate=BlockCreate$$module$build$src$core$events$events_block_create;module$build$src$core$events$events.BlockDelete=BlockDelete$$module$build$src$core$events$events_block_delete;module$build$src$core$events$events.BlockDrag=BlockDrag$$module$build$src$core$events$events_block_drag;module$build$src$core$events$events.BlockFieldIntermediateChange=BlockFieldIntermediateChange$$module$build$src$core$events$events_block_field_intermediate_change;
+module$build$src$core$events$events.BlockMove=BlockMove$$module$build$src$core$events$events_block_move;module$build$src$core$events$events.BubbleOpen=BubbleOpen$$module$build$src$core$events$events_bubble_open;module$build$src$core$events$events.BubbleType=BubbleType$$module$build$src$core$events$events_bubble_open;module$build$src$core$events$events.CHANGE=$.CHANGE$$module$build$src$core$events$utils;module$build$src$core$events$events.CLICK=CLICK$$module$build$src$core$events$utils;
+module$build$src$core$events$events.COMMENT_CHANGE=COMMENT_CHANGE$$module$build$src$core$events$utils;module$build$src$core$events$events.COMMENT_CREATE=COMMENT_CREATE$$module$build$src$core$events$utils;module$build$src$core$events$events.COMMENT_DELETE=COMMENT_DELETE$$module$build$src$core$events$utils;module$build$src$core$events$events.COMMENT_MOVE=COMMENT_MOVE$$module$build$src$core$events$utils;module$build$src$core$events$events.CREATE=$.CREATE$$module$build$src$core$events$utils;
+module$build$src$core$events$events.Click=Click$$module$build$src$core$events$events_click;module$build$src$core$events$events.ClickTarget=ClickTarget$$module$build$src$core$events$events_click;module$build$src$core$events$events.CommentBase=CommentBase$$module$build$src$core$events$events_comment_base;module$build$src$core$events$events.CommentChange=CommentChange$$module$build$src$core$events$events_comment_change;module$build$src$core$events$events.CommentCreate=CommentCreate$$module$build$src$core$events$events_comment_create;
+module$build$src$core$events$events.CommentDelete=CommentDelete$$module$build$src$core$events$events_comment_delete;module$build$src$core$events$events.CommentMove=CommentMove$$module$build$src$core$events$events_comment_move;module$build$src$core$events$events.DELETE=$.DELETE$$module$build$src$core$events$utils;module$build$src$core$events$events.FINISHED_LOADING=FINISHED_LOADING$$module$build$src$core$events$utils;module$build$src$core$events$events.FinishedLoading=FinishedLoading$$module$build$src$core$events$workspace_events;
+module$build$src$core$events$events.MARKER_MOVE=MARKER_MOVE$$module$build$src$core$events$utils;module$build$src$core$events$events.MOVE=$.MOVE$$module$build$src$core$events$utils;module$build$src$core$events$events.MarkerMove=MarkerMove$$module$build$src$core$events$events_marker_move;module$build$src$core$events$events.SELECTED=SELECTED$$module$build$src$core$events$utils;module$build$src$core$events$events.Selected=Selected$$module$build$src$core$events$events_selected;
+module$build$src$core$events$events.THEME_CHANGE=THEME_CHANGE$$module$build$src$core$events$utils;module$build$src$core$events$events.TOOLBOX_ITEM_SELECT=TOOLBOX_ITEM_SELECT$$module$build$src$core$events$utils;module$build$src$core$events$events.TRASHCAN_OPEN=TRASHCAN_OPEN$$module$build$src$core$events$utils;module$build$src$core$events$events.ThemeChange=ThemeChange$$module$build$src$core$events$events_theme_change;module$build$src$core$events$events.ToolboxItemSelect=ToolboxItemSelect$$module$build$src$core$events$events_toolbox_item_select;
+module$build$src$core$events$events.TrashcanOpen=TrashcanOpen$$module$build$src$core$events$events_trashcan_open;module$build$src$core$events$events.UI=UI$$module$build$src$core$events$utils;module$build$src$core$events$events.UiBase=UiBase$$module$build$src$core$events$events_ui_base;module$build$src$core$events$events.VAR_CREATE=VAR_CREATE$$module$build$src$core$events$utils;module$build$src$core$events$events.VAR_DELETE=VAR_DELETE$$module$build$src$core$events$utils;
+module$build$src$core$events$events.VAR_RENAME=VAR_RENAME$$module$build$src$core$events$utils;module$build$src$core$events$events.VIEWPORT_CHANGE=VIEWPORT_CHANGE$$module$build$src$core$events$utils;module$build$src$core$events$events.VarBase=VarBase$$module$build$src$core$events$events_var_base;module$build$src$core$events$events.VarCreate=VarCreate$$module$build$src$core$events$events_var_create;module$build$src$core$events$events.VarDelete=VarDelete$$module$build$src$core$events$events_var_delete;
+module$build$src$core$events$events.VarRename=VarRename$$module$build$src$core$events$events_var_rename;module$build$src$core$events$events.ViewportChange=ViewportChange$$module$build$src$core$events$events_viewport;module$build$src$core$events$events.clearPendingUndo=clearPendingUndo$$module$build$src$core$events$utils;module$build$src$core$events$events.disable=$.disable$$module$build$src$core$events$utils;module$build$src$core$events$events.disableOrphans=disableOrphans$$module$build$src$core$events$utils;
+module$build$src$core$events$events.enable=$.enable$$module$build$src$core$events$utils;module$build$src$core$events$events.filter=filter$$module$build$src$core$events$utils;module$build$src$core$events$events.fire=fire$$module$build$src$core$events$utils;module$build$src$core$events$events.fromJson=fromJson$$module$build$src$core$events$utils;module$build$src$core$events$events.get=get$$module$build$src$core$events$utils;module$build$src$core$events$events.getDescendantIds=getDescendantIds$$module$build$src$core$events$utils;
+module$build$src$core$events$events.getGroup=$.getGroup$$module$build$src$core$events$utils;module$build$src$core$events$events.getRecordUndo=getRecordUndo$$module$build$src$core$events$utils;module$build$src$core$events$events.isEnabled=isEnabled$$module$build$src$core$events$utils;module$build$src$core$events$events.setGroup=$.setGroup$$module$build$src$core$events$utils;module$build$src$core$events$events.setRecordUndo=setRecordUndo$$module$build$src$core$events$utils;var ConstantProvider$$module$build$src$core$renderers$zelos$constants=class extends ConstantProvider$$module$build$src$core$renderers$common$constants{constructor(){super();this.GRID_UNIT=4;this.CURSOR_COLOUR="#ffa200";this.CURSOR_RADIUS=5;this.JAGGED_TEETH_WIDTH=this.JAGGED_TEETH_HEIGHT=0;this.START_HAT_HEIGHT=22;this.START_HAT_WIDTH=96;this.SHAPES={HEXAGONAL:1,ROUND:2,SQUARE:3,PUZZLE:4,NOTCH:5};this.SHAPE_IN_SHAPE_PADDING={1:{0:5*this.GRID_UNIT,1:2*this.GRID_UNIT,2:5*this.GRID_UNIT,3:5*this.GRID_UNIT},
+2:{0:3*this.GRID_UNIT,1:3*this.GRID_UNIT,2:1*this.GRID_UNIT,3:2*this.GRID_UNIT},3:{0:2*this.GRID_UNIT,1:2*this.GRID_UNIT,2:2*this.GRID_UNIT,3:2*this.GRID_UNIT}};this.FULL_BLOCK_FIELDS=!0;this.FIELD_TEXT_FONTWEIGHT="bold";this.FIELD_TEXT_FONTFAMILY='"Helvetica Neue", "Segoe UI", Helvetica, sans-serif';this.FIELD_COLOUR_FULL_BLOCK=this.FIELD_TEXTINPUT_BOX_SHADOW=this.FIELD_DROPDOWN_SVG_ARROW=this.FIELD_DROPDOWN_COLOURED_DIV=this.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW=!0;this.SELECTED_GLOW_COLOUR="#fff200";
+this.SELECTED_GLOW_SIZE=.5;this.REPLACEMENT_GLOW_COLOUR="#fff200";this.REPLACEMENT_GLOW_SIZE=2;this.selectedGlowFilterId="";this.selectedGlowFilter=null;this.replacementGlowFilterId="";this.SQUARED=this.ROUNDED=this.HEXAGONAL=this.replacementGlowFilter=null;this.SMALL_PADDING=this.GRID_UNIT;this.MEDIUM_PADDING=2*this.GRID_UNIT;this.MEDIUM_LARGE_PADDING=3*this.GRID_UNIT;this.LARGE_PADDING=4*this.GRID_UNIT;this.CORNER_RADIUS=1*this.GRID_UNIT;this.NOTCH_WIDTH=9*this.GRID_UNIT;this.NOTCH_HEIGHT=2*this.GRID_UNIT;
+this.STATEMENT_INPUT_NOTCH_OFFSET=this.NOTCH_OFFSET_LEFT=3*this.GRID_UNIT;this.MIN_BLOCK_WIDTH=2*this.GRID_UNIT;this.MIN_BLOCK_HEIGHT=12*this.GRID_UNIT;this.EMPTY_STATEMENT_INPUT_HEIGHT=6*this.GRID_UNIT;this.TOP_ROW_MIN_HEIGHT=this.CORNER_RADIUS;this.TOP_ROW_PRECEDES_STATEMENT_MIN_HEIGHT=this.LARGE_PADDING;this.BOTTOM_ROW_MIN_HEIGHT=this.CORNER_RADIUS;this.BOTTOM_ROW_AFTER_STATEMENT_MIN_HEIGHT=6*this.GRID_UNIT;this.STATEMENT_BOTTOM_SPACER=-this.NOTCH_HEIGHT;this.STATEMENT_INPUT_SPACER_MIN_WIDTH=40*
+this.GRID_UNIT;this.STATEMENT_INPUT_PADDING_LEFT=4*this.GRID_UNIT;this.EMPTY_INLINE_INPUT_PADDING=4*this.GRID_UNIT;this.EMPTY_INLINE_INPUT_HEIGHT=8*this.GRID_UNIT;this.DUMMY_INPUT_MIN_HEIGHT=8*this.GRID_UNIT;this.DUMMY_INPUT_SHADOW_MIN_HEIGHT=6*this.GRID_UNIT;this.CURSOR_WS_WIDTH=20*this.GRID_UNIT;this.FIELD_TEXT_FONTSIZE=3*this.GRID_UNIT;this.FIELD_BORDER_RECT_RADIUS=this.CORNER_RADIUS;this.FIELD_BORDER_RECT_X_PADDING=2*this.GRID_UNIT;this.FIELD_BORDER_RECT_Y_PADDING=1.625*this.GRID_UNIT;this.FIELD_BORDER_RECT_HEIGHT=
+8*this.GRID_UNIT;this.FIELD_DROPDOWN_BORDER_RECT_HEIGHT=8*this.GRID_UNIT;this.FIELD_DROPDOWN_SVG_ARROW_PADDING=this.FIELD_BORDER_RECT_X_PADDING;this.FIELD_COLOUR_DEFAULT_WIDTH=6*this.GRID_UNIT;this.FIELD_COLOUR_DEFAULT_HEIGHT=8*this.GRID_UNIT;this.FIELD_CHECKBOX_X_OFFSET=1*this.GRID_UNIT;this.MAX_DYNAMIC_CONNECTION_SHAPE_WIDTH=12*this.GRID_UNIT}setFontConstants_(a){super.setFontConstants_(a);this.FIELD_DROPDOWN_BORDER_RECT_HEIGHT=this.FIELD_BORDER_RECT_HEIGHT=this.FIELD_TEXT_HEIGHT+2*this.FIELD_BORDER_RECT_Y_PADDING}init(){super.init();
+this.HEXAGONAL=this.makeHexagonal();this.ROUNDED=this.makeRounded();this.SQUARED=this.makeSquared();this.STATEMENT_INPUT_NOTCH_OFFSET=this.NOTCH_OFFSET_LEFT+this.INSIDE_CORNERS.rightWidth}setDynamicProperties_(a){super.setDynamicProperties_(a);this.SELECTED_GLOW_COLOUR=a.getComponentStyle("selectedGlowColour")||this.SELECTED_GLOW_COLOUR;const b=Number(a.getComponentStyle("selectedGlowSize"));this.SELECTED_GLOW_SIZE=b&&!isNaN(b)?b:this.SELECTED_GLOW_SIZE;this.REPLACEMENT_GLOW_COLOUR=a.getComponentStyle("replacementGlowColour")||
+this.REPLACEMENT_GLOW_COLOUR;this.REPLACEMENT_GLOW_SIZE=(a=Number(a.getComponentStyle("replacementGlowSize")))&&!isNaN(a)?a:this.REPLACEMENT_GLOW_SIZE}dispose(){super.dispose();this.selectedGlowFilter&&removeNode$$module$build$src$core$utils$dom(this.selectedGlowFilter);this.replacementGlowFilter&&removeNode$$module$build$src$core$utils$dom(this.replacementGlowFilter)}makeStartHat(){const a=this.START_HAT_HEIGHT,b=this.START_HAT_WIDTH,c=curve$$module$build$src$core$utils$svg_paths("c",[point$$module$build$src$core$utils$svg_paths(25,
+-a),point$$module$build$src$core$utils$svg_paths(71,-a),point$$module$build$src$core$utils$svg_paths(b,0)]);return{height:a,width:b,path:c}}makeHexagonal(){function a(c,d,e){var f=c/2;f=f>b?b:f;e=e?-1:1;c=(d?-1:1)*c/2;return lineTo$$module$build$src$core$utils$svg_paths(-e*f,c)+lineTo$$module$build$src$core$utils$svg_paths(e*f,c)}const b=this.MAX_DYNAMIC_CONNECTION_SHAPE_WIDTH;return{type:this.SHAPES.HEXAGONAL,isDynamic:!0,width(c){c/=2;return c>b?b:c},height(c){return c},connectionOffsetY(c){return c/
+2},connectionOffsetX(c){return-c},pathDown(c){return a(c,!1,!1)},pathUp(c){return a(c,!0,!1)},pathRightDown(c){return a(c,!1,!0)},pathRightUp(c){return a(c,!1,!0)}}}makeRounded(){function a(d,e,f){const g=d>c?d-c:0;d=(d>c?c:d)/2;return arc$$module$build$src$core$utils$svg_paths("a","0 0,1",d,point$$module$build$src$core$utils$svg_paths((e?-1:1)*d,(e?-1:1)*d))+lineOnAxis$$module$build$src$core$utils$svg_paths("v",(f?1:-1)*g)+arc$$module$build$src$core$utils$svg_paths("a","0 0,1",d,point$$module$build$src$core$utils$svg_paths((e?
+1:-1)*d,(e?-1:1)*d))}const b=this.MAX_DYNAMIC_CONNECTION_SHAPE_WIDTH,c=2*b;return{type:this.SHAPES.ROUND,isDynamic:!0,width(d){d/=2;return d>b?b:d},height(d){return d},connectionOffsetY(d){return d/2},connectionOffsetX(d){return-d},pathDown(d){return a(d,!1,!1)},pathUp(d){return a(d,!0,!1)},pathRightDown(d){return a(d,!1,!0)},pathRightUp(d){return a(d,!1,!0)}}}makeSquared(){function a(c,d,e){c-=2*b;return arc$$module$build$src$core$utils$svg_paths("a","0 0,1",b,point$$module$build$src$core$utils$svg_paths((d?
+-1:1)*b,(d?-1:1)*b))+lineOnAxis$$module$build$src$core$utils$svg_paths("v",(e?1:-1)*c)+arc$$module$build$src$core$utils$svg_paths("a","0 0,1",b,point$$module$build$src$core$utils$svg_paths((d?1:-1)*b,(d?-1:1)*b))}const b=this.CORNER_RADIUS;return{type:this.SHAPES.SQUARE,isDynamic:!0,width(c){return b},height(c){return c},connectionOffsetY(c){return c/2},connectionOffsetX(c){return-c},pathDown(c){return a(c,!1,!1)},pathUp(c){return a(c,!0,!1)},pathRightDown(c){return a(c,!1,!0)},pathRightUp(c){return a(c,
+!1,!0)}}}shapeFor(a){let b=a.getCheck();!b&&a.targetConnection&&(b=a.targetConnection.getCheck());switch(a.type){case ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE:case ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE:a=a.getSourceBlock().getOutputShape();if(null!==a)switch(a){case this.SHAPES.HEXAGONAL:return this.HEXAGONAL;case this.SHAPES.ROUND:return this.ROUNDED;case this.SHAPES.SQUARE:return this.SQUARED}if(b&&-1!==b.indexOf("Boolean"))return this.HEXAGONAL;
+if(b&&-1!==b.indexOf("Number"))return this.ROUNDED;b&&b.indexOf("String");return this.ROUNDED;case ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT:case ConnectionType$$module$build$src$core$connection_type.NEXT_STATEMENT:return this.NOTCH;default:throw Error("Unknown type");}}makeNotch(){function a(l){return curve$$module$build$src$core$utils$svg_paths("c",[point$$module$build$src$core$utils$svg_paths(l*e/2,0),point$$module$build$src$core$utils$svg_paths(l*e*3/4,g/2),point$$module$build$src$core$utils$svg_paths(l*
+e,g)])+line$$module$build$src$core$utils$svg_paths([point$$module$build$src$core$utils$svg_paths(l*e,f)])+curve$$module$build$src$core$utils$svg_paths("c",[point$$module$build$src$core$utils$svg_paths(l*e/4,g/2),point$$module$build$src$core$utils$svg_paths(l*e/2,g),point$$module$build$src$core$utils$svg_paths(l*e,g)])+lineOnAxis$$module$build$src$core$utils$svg_paths("h",l*d)+curve$$module$build$src$core$utils$svg_paths("c",[point$$module$build$src$core$utils$svg_paths(l*e/2,0),point$$module$build$src$core$utils$svg_paths(l*
+e*3/4,-(g/2)),point$$module$build$src$core$utils$svg_paths(l*e,-g)])+line$$module$build$src$core$utils$svg_paths([point$$module$build$src$core$utils$svg_paths(l*e,-f)])+curve$$module$build$src$core$utils$svg_paths("c",[point$$module$build$src$core$utils$svg_paths(l*e/4,-(g/2)),point$$module$build$src$core$utils$svg_paths(l*e/2,-g),point$$module$build$src$core$utils$svg_paths(l*e,-g)])}const b=this.NOTCH_WIDTH,c=this.NOTCH_HEIGHT,d=b/3,e=d/3,f=c/2,g=f/2,h=a(1),k=a(-1);return{type:this.SHAPES.NOTCH,
+width:b,height:c,pathLeft:h,pathRight:k}}makeInsideCorners(){const a=this.CORNER_RADIUS,b=arc$$module$build$src$core$utils$svg_paths("a","0 0,0",a,point$$module$build$src$core$utils$svg_paths(-a,a)),c=arc$$module$build$src$core$utils$svg_paths("a","0 0,1",a,point$$module$build$src$core$utils$svg_paths(-a,a)),d=arc$$module$build$src$core$utils$svg_paths("a","0 0,0",a,point$$module$build$src$core$utils$svg_paths(a,a)),e=arc$$module$build$src$core$utils$svg_paths("a","0 0,1",a,point$$module$build$src$core$utils$svg_paths(a,
+a));return{width:a,height:a,pathTop:b,pathBottom:d,rightWidth:a,rightHeight:a,pathTopRight:c,pathBottomRight:e}}generateSecondaryColour_(a){return blend$$module$build$src$core$utils$colour("#000",a,.15)||a}generateTertiaryColour_(a){return blend$$module$build$src$core$utils$colour("#000",a,.25)||a}createDom(a,b,c){super.createDom(a,b,c);a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.DEFS,{},a);b=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FILTER,
+{id:"blocklySelectedGlowFilter"+this.randomIdentifier,height:"160%",width:"180%",y:"-30%",x:"-40%"},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FEGAUSSIANBLUR,{"in":"SourceGraphic",stdDeviation:this.SELECTED_GLOW_SIZE},b);c=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FECOMPONENTTRANSFER,{result:"outBlur"},b);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FEFUNCA,{type:"table",
+tableValues:"0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1"},c);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FEFLOOD,{"flood-color":this.SELECTED_GLOW_COLOUR,"flood-opacity":1,result:"outColor"},b);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FECOMPOSITE,{"in":"outColor",in2:"outBlur",operator:"in",result:"outGlow"},b);this.selectedGlowFilterId=b.id;this.selectedGlowFilter=b;a=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FILTER,
+{id:"blocklyReplacementGlowFilter"+this.randomIdentifier,height:"160%",width:"180%",y:"-30%",x:"-40%"},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FEGAUSSIANBLUR,{"in":"SourceGraphic",stdDeviation:this.REPLACEMENT_GLOW_SIZE},a);b=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FECOMPONENTTRANSFER,{result:"outBlur"},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FEFUNCA,{type:"table",
+tableValues:"0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1"},b);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FEFLOOD,{"flood-color":this.REPLACEMENT_GLOW_COLOUR,"flood-opacity":1,result:"outColor"},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FECOMPOSITE,{"in":"outColor",in2:"outBlur",operator:"in",result:"outGlow"},a);createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.FECOMPOSITE,{"in":"SourceGraphic",
+in2:"outGlow",operator:"over"},a);this.replacementGlowFilterId=a.id;this.replacementGlowFilter=a}getCSS_(a){return[`${a} .blocklyText,`,`${a} .blocklyFlyoutLabelText {`,`font: ${this.FIELD_TEXT_FONTWEIGHT} ${this.FIELD_TEXT_FONTSIZE}`+`pt ${this.FIELD_TEXT_FONTFAMILY};`,"}",`${a} .blocklyText {`,"fill: #fff;","}",`${a} .blocklyNonEditableText>rect:not(.blocklyDropdownRect),`,`${a} .blocklyEditableText>rect:not(.blocklyDropdownRect) {`,`fill: ${this.FIELD_BORDER_RECT_COLOUR};`,"}",`${a} .blocklyNonEditableText>text,`,
+`${a} .blocklyEditableText>text,`,`${a} .blocklyNonEditableText>g>text,`,`${a} .blocklyEditableText>g>text {`,"fill: #575E75;","}",`${a} .blocklyFlyoutLabelText {`,"fill: #575E75;","}",`${a} .blocklyText.blocklyBubbleText {`,"fill: #575E75;","}",`${a} .blocklyDraggable:not(.blocklyDisabled)`," .blocklyEditableText:not(.editing):hover>rect,",`${a} .blocklyDraggable:not(.blocklyDisabled)`," .blocklyEditableText:not(.editing):hover>.blocklyPath {","stroke: #fff;","stroke-width: 2;","}",`${a} .blocklyHtmlInput {`,
+`font-family: ${this.FIELD_TEXT_FONTFAMILY};`,`font-weight: ${this.FIELD_TEXT_FONTWEIGHT};`,"color: #575E75;","}",`${a} .blocklyDropdownText {`,"fill: #fff !important;","}",`${a}.blocklyWidgetDiv .goog-menuitem,`,`${a}.blocklyDropDownDiv .goog-menuitem {`,`font-family: ${this.FIELD_TEXT_FONTFAMILY};`,"}",`${a}.blocklyDropDownDiv .goog-menuitem-content {`,"color: #fff;","}",`${a} .blocklyHighlightedConnectionPath {`,`stroke: ${this.SELECTED_GLOW_COLOUR};`,"}",`${a} .blocklyDisabled > .blocklyOutlinePath {`,
+`fill: url(#blocklyDisabledPattern${this.randomIdentifier})`,"}",`${a} .blocklyInsertionMarker>.blocklyPath {`,`fill-opacity: ${this.INSERTION_MARKER_OPACITY};`,"stroke: none;","}"]}},module$build$src$core$renderers$zelos$constants={};module$build$src$core$renderers$zelos$constants.ConstantProvider=ConstantProvider$$module$build$src$core$renderers$zelos$constants;var Drawer$$module$build$src$core$renderers$zelos$drawer=class extends Drawer$$module$build$src$core$renderers$common$drawer{constructor(a,b){super(a,b)}draw(){const a=this.block_.pathObject;a.beginDrawing();this.drawOutline_();this.drawInternals_();a.setPath(this.outlinePath_+"\n"+this.inlinePath_);this.info_.RTL&&a.flipRTL();this.recordSizeOnBlock_();this.info_.outputConnection&&(a.outputShapeType=this.info_.outputConnection.shape.type);a.endDrawing()}drawOutline_(){this.info_.outputConnection&&
+this.info_.outputConnection.isDynamicShape&&!this.info_.hasStatementInput&&!this.info_.bottomRow.hasNextConnection?(this.drawFlatTop_(),this.drawRightDynamicConnection_(),this.drawFlatBottom_(),this.drawLeftDynamicConnection_()):super.drawOutline_()}drawLeft_(){this.info_.outputConnection&&this.info_.outputConnection.isDynamicShape?this.drawLeftDynamicConnection_():super.drawLeft_()}drawRightSideRow_(a){if(!(0>=a.height)){if(Types$$module$build$src$core$renderers$measurables$types.isSpacer(a)){const d=
+a.precedesStatement;var b=a.followsStatement;if(d||b){const e=this.constants_.INSIDE_CORNERS;var c=e.rightHeight;c=a.height-(d?c:0);b=b?e.pathBottomRight:"";a=0=c||0>=b)throw Error("Height and width values of an image field must be greater than 0.");this.size_=new Size$$module$build$src$core$utils$size(b,c+FieldImage$$module$build$src$core$field_image.Y_PADDING);this.imageHeight=c;"function"===typeof e&&(this.clickHandler=e);a!==Field$$module$build$src$core$field.SKIP_SETUP&&(g?this.configure_(g):(this.flipRtl=!!f,this.altText=replaceMessageReferences$$module$build$src$core$utils$parsing(d)||""),this.setValue(replaceMessageReferences$$module$build$src$core$utils$parsing(a)))}configure_(a){super.configure_(a);
+a.flipRtl&&(this.flipRtl=a.flipRtl);a.alt&&(this.altText=replaceMessageReferences$$module$build$src$core$utils$parsing(a.alt))}initView(){this.imageElement=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.IMAGE,{height:this.imageHeight+"px",width:this.size_.width+"px",alt:this.altText},this.fieldGroup_);this.imageElement.setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.value_);this.clickHandler&&(this.imageElement.style.cursor="pointer")}updateSize_(){}doClassValidation_(a){return"string"!==
+typeof a?null:a}doValueUpdate_(a){this.value_=a;this.imageElement&&this.imageElement.setAttributeNS(XLINK_NS$$module$build$src$core$utils$dom,"xlink:href",this.value_)}getFlipRtl(){return this.flipRtl}setAlt(a){a!==this.altText&&(this.altText=a||"",this.imageElement&&this.imageElement.setAttribute("alt",this.altText))}showEditor_(){this.clickHandler&&this.clickHandler(this)}setOnClickHandler(a){this.clickHandler=a}getText_(){return this.altText}static fromJson(a){if(!a.src||!a.width||!a.height)throw Error("src, width, and height values for an image field arerequired. The width and height must be non-zero.");
+return new this(a.src,a.width,a.height,void 0,void 0,void 0,a)}};FieldImage$$module$build$src$core$field_image.Y_PADDING=1;register$$module$build$src$core$field_registry("field_image",FieldImage$$module$build$src$core$field_image);FieldImage$$module$build$src$core$field_image.prototype.DEFAULT_VALUE="";var module$build$src$core$field_image={};module$build$src$core$field_image.FieldImage=FieldImage$$module$build$src$core$field_image;var FieldInput$$module$build$src$core$field_input=class extends Field$$module$build$src$core$field{constructor(a,b,c){super(Field$$module$build$src$core$field.SKIP_SETUP);this.spellcheck_=!0;this.htmlInput_=null;this.isTextValid_=this.isBeingEdited_=!1;this.onKeyInputWrapper_=this.onKeyDownWrapper_=this.valueWhenEditorWasOpened_=null;this.fullBlockClickTarget_=!1;this.workspace_=null;this.SERIALIZABLE=!0;this.CURSOR="text";a!==Field$$module$build$src$core$field.SKIP_SETUP&&(c&&this.configure_(c),
+this.setValue(a),b&&this.setValidator(b))}configure_(a){super.configure_(a);void 0!==a.spellcheck&&(this.spellcheck_=a.spellcheck)}initView(){if(!this.getSourceBlock())throw new UnattachedFieldError$$module$build$src$core$field;super.initView();this.isFullBlockField()&&(this.clickTarget_=this.sourceBlock_.getSvgRoot())}isFullBlockField(){const a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;let b;return this.fullBlockClickTarget_=!(null==(b=this.getConstants())||
+!b.FULL_BLOCK_FIELDS)&&a.isSimpleReporter()}doValueInvalid_(a){this.isBeingEdited_&&(this.isDirty_=!0,this.isTextValid_=!1,a=this.value_,this.value_=this.htmlInput_.getAttribute("data-untyped-default-value"),this.sourceBlock_&&isEnabled$$module$build$src$core$events$utils()&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CHANGE$$module$build$src$core$events$utils))(this.sourceBlock_,"field",this.name||null,a,this.value_)))}doValueUpdate_(a){this.isTextValid_=
+this.isDirty_=!0;this.value_=a}applyColour(){const a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;this.getConstants().FULL_BLOCK_FIELDS&&this.fieldGroup_&&(!this.isFullBlockField()&&this.borderRect_?(this.borderRect_.style.display="block",this.borderRect_.setAttribute("stroke",a.style.colourTertiary)):(this.borderRect_.style.display="none",a.pathObject.svgPath.setAttribute("fill",this.getConstants().FIELD_BORDER_RECT_COLOUR)))}getSize(){let a;if(null==(a=
+this.getConstants())?0:a.FULL_BLOCK_FIELDS)this.render_(),this.isDirty_=!1;return super.getSize()}render_(){super.render_();if(this.isBeingEdited_){this.resizeEditor_();var a=this.htmlInput_;this.isTextValid_?(removeClass$$module$build$src$core$utils$dom(a,"blocklyInvalidInput"),setState$$module$build$src$core$utils$aria(a,State$$module$build$src$core$utils$aria.INVALID,!1)):(addClass$$module$build$src$core$utils$dom(a,"blocklyInvalidInput"),setState$$module$build$src$core$utils$aria(a,State$$module$build$src$core$utils$aria.INVALID,
+!0))}a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;this.getConstants().FULL_BLOCK_FIELDS&&a.applyColour()}setSpellcheck(a){a!==this.spellcheck_&&(this.spellcheck_=a,this.htmlInput_&&this.htmlInput_.setAttribute("spellcheck",this.spellcheck_))}showEditor_(a,b=!1){this.workspace_=this.sourceBlock_.workspace;!b&&this.workspace_.options.modalInputs&&(MOBILE$$module$build$src$core$utils$useragent||ANDROID$$module$build$src$core$utils$useragent||IPAD$$module$build$src$core$utils$useragent)?
+this.showPromptEditor_():this.showInlineEditor_(b)}showPromptEditor_(){prompt$$module$build$src$core$dialog($.Msg$$module$build$src$core$msg.CHANGE_VALUE_TITLE,this.getText(),a=>{null!==a&&this.setValue(this.getValueFromEditorText_(a));this.onFinishEditing_(this.value_)})}showInlineEditor_(a){const b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;show$$module$build$src$core$widgetdiv(this,b.RTL,this.widgetDispose_.bind(this));this.htmlInput_=this.widgetCreate_();
+this.isBeingEdited_=!0;this.valueWhenEditorWasOpened_=this.value_;a||(this.htmlInput_.focus({preventScroll:!0}),this.htmlInput_.select())}widgetCreate_(){var a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;$.setGroup$$module$build$src$core$events$utils(!0);const b=getDiv$$module$build$src$core$widgetdiv();var c=this.getClickTarget_();if(!c)throw Error("A click target has not been set.");addClass$$module$build$src$core$utils$dom(c,"editing");c=document.createElement("input");
+c.className="blocklyHtmlInput";c.setAttribute("spellcheck",this.spellcheck_);const d=this.workspace_.getScale();var e=this.getConstants().FIELD_TEXT_FONTSIZE*d+"pt";b.style.fontSize=e;c.style.fontSize=e;e=FieldInput$$module$build$src$core$field_input.BORDERRADIUS*d+"px";this.isFullBlockField()&&(e=this.getScaledBBox(),e=(e.bottom-e.top)/2+"px",a=a.getParent()?a.getParent().style.colourTertiary:this.sourceBlock_.style.colourTertiary,c.style.border=1*d+"px solid "+a,b.style.borderRadius=e,b.style.transition=
+"box-shadow 0.25s ease 0s",this.getConstants().FIELD_TEXTINPUT_BOX_SHADOW&&(b.style.boxShadow="rgba(255, 255, 255, 0.3) 0 0 0 "+4*d+"px"));c.style.borderRadius=e;b.appendChild(c);c.value=c.defaultValue=this.getEditorText_(this.value_);c.setAttribute("data-untyped-default-value",String(this.value_));this.resizeEditor_();this.bindInputEvents_(c);return c}widgetDispose_(){this.isBeingEdited_=!1;this.isTextValid_=!0;this.forceRerender();this.onFinishEditing_(this.value_);this.sourceBlock_&&isEnabled$$module$build$src$core$events$utils()&&
+null!==this.valueWhenEditorWasOpened_&&this.valueWhenEditorWasOpened_!==this.value_&&(fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils($.CHANGE$$module$build$src$core$events$utils))(this.sourceBlock_,"field",this.name||null,this.valueWhenEditorWasOpened_,this.value_)),this.valueWhenEditorWasOpened_=null);$.setGroup$$module$build$src$core$events$utils(!1);this.unbindInputEvents_();var a=getDiv$$module$build$src$core$widgetdiv().style;a.width="auto";a.height="auto";
+a.fontSize="";a.transition="";a.boxShadow="";this.htmlInput_=null;a=this.getClickTarget_();if(!a)throw Error("A click target has not been set.");removeClass$$module$build$src$core$utils$dom(a,"editing")}onFinishEditing_(a){}bindInputEvents_(a){this.onKeyDownWrapper_=conditionalBind$$module$build$src$core$browser_events(a,"keydown",this,this.onHtmlInputKeyDown_);this.onKeyInputWrapper_=conditionalBind$$module$build$src$core$browser_events(a,"input",this,this.onHtmlInputChange_)}unbindInputEvents_(){this.onKeyDownWrapper_&&
+(unbind$$module$build$src$core$browser_events(this.onKeyDownWrapper_),this.onKeyDownWrapper_=null);this.onKeyInputWrapper_&&(unbind$$module$build$src$core$browser_events(this.onKeyInputWrapper_),this.onKeyInputWrapper_=null)}onHtmlInputKeyDown_(a){"Enter"===a.key?(hide$$module$build$src$core$widgetdiv(),hideWithoutAnimation$$module$build$src$core$dropdowndiv()):"Escape"===a.key?(this.setValue(this.htmlInput_.getAttribute("data-untyped-default-value")),hide$$module$build$src$core$widgetdiv(),hideWithoutAnimation$$module$build$src$core$dropdowndiv()):
+"Tab"===a.key&&(hide$$module$build$src$core$widgetdiv(),hideWithoutAnimation$$module$build$src$core$dropdowndiv(),this.sourceBlock_.tab(this,!a.shiftKey),a.preventDefault())}onHtmlInputChange_(a){a=this.value_;this.setValue(this.getValueFromEditorText_(this.htmlInput_.value),!1);this.sourceBlock_&&isEnabled$$module$build$src$core$events$utils()&&this.value_!==a&&fire$$module$build$src$core$events$utils(new (get$$module$build$src$core$events$utils(BLOCK_FIELD_INTERMEDIATE_CHANGE$$module$build$src$core$events$utils))(this.sourceBlock_,
+this.name||null,a,this.value_));finishQueuedRenders$$module$build$src$core$render_management().then(()=>{this.resizeEditor_()})}setEditorValue_(a,b=!0){this.isDirty_=!0;this.isBeingEdited_&&(this.htmlInput_.value=this.getEditorText_(a));this.setValue(a,b)}resizeEditor_(){var a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;const b=getDiv$$module$build$src$core$widgetdiv(),c=this.getScaledBBox();b.style.width=c.right-c.left+"px";b.style.height=c.bottom-c.top+
+"px";a=new Coordinate$$module$build$src$core$utils$coordinate(a.RTL?c.right-b.offsetWidth:c.left,c.top);b.style.left=a.x+"px";b.style.top=a.y+"px"}repositionForWindowResize(){const a=this.getSourceBlock();if(!(a instanceof BlockSvg$$module$build$src$core$block_svg))return!1;bumpObjectIntoBounds$$module$build$src$core$bump_objects(this.workspace_,this.workspace_.getMetricsManager().getViewMetrics(!0),a);this.resizeEditor_();return!0}isTabNavigable(){return!0}getText_(){return this.isBeingEdited_&&
+this.htmlInput_?this.htmlInput_.value:null}getEditorText_(a){return`${a}`}getValueFromEditorText_(a){return a}};FieldInput$$module$build$src$core$field_input.BORDERRADIUS=4;var module$build$src$core$field_input={};module$build$src$core$field_input.FieldInput=FieldInput$$module$build$src$core$field_input;var FieldTextInput$$module$build$src$core$field_textinput=class extends FieldInput$$module$build$src$core$field_input{constructor(a,b,c){super(a,b,c)}doClassValidation_(a){return void 0===a?null:`${a}`}static fromJson(a){return new this(replaceMessageReferences$$module$build$src$core$utils$parsing(a.text),void 0,a)}};register$$module$build$src$core$field_registry("field_input",FieldTextInput$$module$build$src$core$field_textinput);
+FieldTextInput$$module$build$src$core$field_textinput.prototype.DEFAULT_VALUE="";var module$build$src$core$field_textinput={};module$build$src$core$field_textinput.FieldTextInput=FieldTextInput$$module$build$src$core$field_textinput;var BottomRow$$module$build$src$core$renderers$zelos$measurables$bottom_row=class extends BottomRow$$module$build$src$core$renderers$measurables$bottom_row{constructor(a){super(a)}endsWithElemSpacer(){return!1}hasLeftSquareCorner(a){return!!a.outputConnection}hasRightSquareCorner(a){return!!a.outputConnection&&!a.statementInputCount&&!a.nextConnection}},module$build$src$core$renderers$zelos$measurables$bottom_row={};module$build$src$core$renderers$zelos$measurables$bottom_row.BottomRow=BottomRow$$module$build$src$core$renderers$zelos$measurables$bottom_row;var StatementInput$$module$build$src$core$renderers$zelos$measurables$inputs=class extends StatementInput$$module$build$src$core$renderers$measurables$statement_input{constructor(a,b){super(a,b);this.connectedBottomNextConnection=!1;if(this.connectedBlock){for(a=this.connectedBlock;b=a.getNextBlock();)a=b;a.nextConnection||(this.height=this.connectedBlockHeight,this.connectedBottomNextConnection=!0)}}},module$build$src$core$renderers$zelos$measurables$inputs={};
+module$build$src$core$renderers$zelos$measurables$inputs.StatementInput=StatementInput$$module$build$src$core$renderers$zelos$measurables$inputs;var RightConnectionShape$$module$build$src$core$renderers$zelos$measurables$row_elements=class extends Measurable$$module$build$src$core$renderers$measurables$base{constructor(a){super(a);this.width=this.height=0;this.type|=Types$$module$build$src$core$renderers$measurables$types.getType("RIGHT_CONNECTION")}},module$build$src$core$renderers$zelos$measurables$row_elements={};module$build$src$core$renderers$zelos$measurables$row_elements.RightConnectionShape=RightConnectionShape$$module$build$src$core$renderers$zelos$measurables$row_elements;var TopRow$$module$build$src$core$renderers$zelos$measurables$top_row=class extends TopRow$$module$build$src$core$renderers$measurables$top_row{constructor(a){super(a)}endsWithElemSpacer(){return!1}hasLeftSquareCorner(a){const b=(a.hat?"cap"===a.hat:this.constants_.ADD_START_HATS)&&!a.outputConnection&&!a.previousConnection;return!!a.outputConnection||b}hasRightSquareCorner(a){return!!a.outputConnection&&!a.statementInputCount&&!a.nextConnection}},module$build$src$core$renderers$zelos$measurables$top_row=
+{};module$build$src$core$renderers$zelos$measurables$top_row.TopRow=TopRow$$module$build$src$core$renderers$zelos$measurables$top_row;var RenderInfo$$module$build$src$core$renderers$zelos$info=class extends RenderInfo$$module$build$src$core$renderers$common$info{constructor(a,b){super(a,b);this.isInline=!0;this.renderer_=a;this.constants_=this.renderer_.getConstants();this.topRow=new TopRow$$module$build$src$core$renderers$zelos$measurables$top_row(this.constants_);this.bottomRow=new BottomRow$$module$build$src$core$renderers$zelos$measurables$bottom_row(this.constants_);this.isMultiRow=!b.getInputsInline()||b.isCollapsed();this.hasStatementInput=
+0=this.rows.length-1?!!this.bottomRow.hasNextConnection:!!d.precedesStatement;if(Types$$module$build$src$core$renderers$measurables$types.isInputRow(f)&&f.hasStatement){f.measure();let g,h;b=f.width-(null!=(h=null==(g=f.getLastInput())?void 0:g.width)?h:0)+a}else if(c&&(2===e||d)&&Types$$module$build$src$core$renderers$measurables$types.isInputRow(f)&&!f.hasStatement){d=f.xPos;c=null;for(let g=
+0;gc?c:this.height/2,b-c*(1-Math.sin(Math.acos((c-this.constants_.SMALL_PADDING)/c)));default:return 0}if(Types$$module$build$src$core$renderers$measurables$types.isInlineInput(a)&&a instanceof InputConnection$$module$build$src$core$renderers$measurables$input_connection){const e=a.connectedBlock;a=e?e.pathObject.outputShapeType:a.shape.type;return null==a||e&&e.outputConnection&&(e.statementInputCount||e.nextConnection)||c===d.SHAPES.HEXAGONAL&&c!==a?0:b-this.constants_.SHAPE_IN_SHAPE_PADDING[c][a]}return Types$$module$build$src$core$renderers$measurables$types.isField(a)&&
+a instanceof Field$$module$build$src$core$renderers$measurables$field?c===d.SHAPES.ROUND&&a.field instanceof FieldTextInput$$module$build$src$core$field_textinput?b-2.75*d.GRID_UNIT:b-this.constants_.SHAPE_IN_SHAPE_PADDING[c][0]:Types$$module$build$src$core$renderers$measurables$types.isIcon(a)?this.constants_.SMALL_PADDING:0}finalizeVerticalAlignment_(){if(!this.outputConnection)for(let d=2;d=this.rows.length-
+1?!!this.bottomRow.hasNextConnection:!!g.precedesStatement;if(a?this.topRow.hasPreviousConnection:e.followsStatement){var c=f.elements[1];c=3===f.elements.length&&c instanceof Field$$module$build$src$core$renderers$measurables$field&&(c.field instanceof FieldLabel$$module$build$src$core$field_label||c.field instanceof FieldImage$$module$build$src$core$field_image);if(!a&&c)e.height-=this.constants_.SMALL_PADDING,g.height-=this.constants_.SMALL_PADDING,f.height-=this.constants_.MEDIUM_PADDING;else if(!a&&
+!b)e.height+=this.constants_.SMALL_PADDING;else if(b){a=!1;for(b=0;bc;c+=15)createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.LINE,{x1:FieldAngle$$module$build$src$core$field_angle.HALF+
+FieldAngle$$module$build$src$core$field_angle.RADIUS,y1:FieldAngle$$module$build$src$core$field_angle.HALF,x2:FieldAngle$$module$build$src$core$field_angle.HALF+FieldAngle$$module$build$src$core$field_angle.RADIUS-(0===c%45?10:5),y2:FieldAngle$$module$build$src$core$field_angle.HALF,"class":"blocklyAngleMarks",transform:"rotate("+c+","+FieldAngle$$module$build$src$core$field_angle.HALF+","+FieldAngle$$module$build$src$core$field_angle.HALF+")"},a);this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(a,
+"click",this,this.hide));this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(b,"pointerdown",this,this.onMouseMove_,!0));this.boundEvents.push(conditionalBind$$module$build$src$core$browser_events(b,"pointermove",this,this.onMouseMove_,!0));return a}dropdownDispose(){for(const a of this.boundEvents)unbind$$module$build$src$core$browser_events(a);this.boundEvents.length=0;this.line=this.gauge=null}hide(){hideIfOwner$$module$build$src$core$dropdowndiv(this);hide$$module$build$src$core$widgetdiv()}onMouseMove_(a){var b=
+this.gauge.ownerSVGElement.getBoundingClientRect();const c=a.clientX-b.left-FieldAngle$$module$build$src$core$field_angle.HALF;a=a.clientY-b.top-FieldAngle$$module$build$src$core$field_angle.HALF;b=Math.atan(-a/c);isNaN(b)||(b=toDegrees$$module$build$src$core$utils$math(b),0>c?b+=180:0a&&(a+=360);a>this.wrap&&(a-=360);return a}static fromJson(a){return new this(a.angle,void 0,a)}};
+FieldAngle$$module$build$src$core$field_angle.HALF=50;FieldAngle$$module$build$src$core$field_angle.RADIUS=FieldAngle$$module$build$src$core$field_angle.HALF-1;FieldAngle$$module$build$src$core$field_angle.CLOCKWISE=!1;FieldAngle$$module$build$src$core$field_angle.OFFSET=0;FieldAngle$$module$build$src$core$field_angle.WRAP=360;FieldAngle$$module$build$src$core$field_angle.ROUND=15;register$$module$build$src$core$field_registry("field_angle",FieldAngle$$module$build$src$core$field_angle);
+FieldAngle$$module$build$src$core$field_angle.prototype.DEFAULT_VALUE=0;register$$module$build$src$core$css("\n.blocklyAngleCircle {\n  stroke: #444;\n  stroke-width: 1;\n  fill: #ddd;\n  fill-opacity: 0.8;\n}\n\n.blocklyAngleMarks {\n  stroke: #444;\n  stroke-width: 1;\n}\n\n.blocklyAngleGauge {\n  fill: #f88;\n  fill-opacity: 0.8;\n  pointer-events: none;\n}\n\n.blocklyAngleLine {\n  stroke: #f00;\n  stroke-width: 2;\n  stroke-linecap: round;\n  pointer-events: none;\n}\n");var Mode$$module$build$src$core$field_angle;
+(function(a){a.COMPASS="compass";a.PROTRACTOR="protractor"})(Mode$$module$build$src$core$field_angle||(Mode$$module$build$src$core$field_angle={}));var module$build$src$core$field_angle={};module$build$src$core$field_angle.FieldAngle=FieldAngle$$module$build$src$core$field_angle;module$build$src$core$field_angle.Mode=Mode$$module$build$src$core$field_angle;var FieldCheckbox$$module$build$src$core$field_checkbox=class extends Field$$module$build$src$core$field{constructor(a,b,c){super(Field$$module$build$src$core$field.SKIP_SETUP);this.SERIALIZABLE=!0;this.CURSOR="default";this.value_=this.value_;this.checkChar=FieldCheckbox$$module$build$src$core$field_checkbox.CHECK_CHAR;a!==Field$$module$build$src$core$field.SKIP_SETUP&&(c&&this.configure_(c),this.setValue(a),b&&this.setValidator(b))}configure_(a){super.configure_(a);a.checkCharacter&&(this.checkChar=
+a.checkCharacter)}saveState(){const a=this.saveLegacyState(FieldCheckbox$$module$build$src$core$field_checkbox);return null!==a?a:this.getValueBoolean()}initView(){super.initView();const a=this.getTextElement();addClass$$module$build$src$core$utils$dom(a,"blocklyCheckbox");a.style.display=this.value_?"block":"none"}render_(){this.textContent_&&(this.textContent_.nodeValue=this.getDisplayText_());this.updateSize_(this.getConstants().FIELD_CHECKBOX_X_OFFSET)}getDisplayText_(){return this.checkChar}setCheckCharacter(a){this.checkChar=
+a||FieldCheckbox$$module$build$src$core$field_checkbox.CHECK_CHAR;this.forceRerender()}showEditor_(){this.setValue(!this.value_)}doClassValidation_(a){return!0===a||"TRUE"===a?"TRUE":!1===a||"FALSE"===a?"FALSE":null}doValueUpdate_(a){this.value_=this.convertValueToBool_(a);this.textElement_&&(this.textElement_.style.display=this.value_?"block":"none")}getValue(){return this.value_?"TRUE":"FALSE"}getValueBoolean(){return this.value_}getText(){return String(this.convertValueToBool_(this.value_))}convertValueToBool_(a){return"string"===
+typeof a?"TRUE"===a:!!a}static fromJson(a){return new this(a.checked,void 0,a)}};FieldCheckbox$$module$build$src$core$field_checkbox.CHECK_CHAR="\u2713";register$$module$build$src$core$field_registry("field_checkbox",FieldCheckbox$$module$build$src$core$field_checkbox);FieldCheckbox$$module$build$src$core$field_checkbox.prototype.DEFAULT_VALUE=!1;var module$build$src$core$field_checkbox={};module$build$src$core$field_checkbox.FieldCheckbox=FieldCheckbox$$module$build$src$core$field_checkbox;var FieldColour$$module$build$src$core$field_colour=class extends Field$$module$build$src$core$field{constructor(a,b,c){super(Field$$module$build$src$core$field.SKIP_SETUP);this.highlightedIndex=this.picker=null;this.boundEvents=[];this.SERIALIZABLE=!0;this.CURSOR="default";this.isDirty_=!1;this.titles=this.colours=null;this.columns=0;a!==Field$$module$build$src$core$field.SKIP_SETUP&&(c&&this.configure_(c),this.setValue(a),b&&this.setValidator(b))}configure_(a){super.configure_(a);a.colourOptions&&
+(this.colours=a.colourOptions);a.colourTitles&&(this.titles=a.colourTitles);a.columns&&(this.columns=a.columns)}initView(){this.size_=new Size$$module$build$src$core$utils$size(this.getConstants().FIELD_COLOUR_DEFAULT_WIDTH,this.getConstants().FIELD_COLOUR_DEFAULT_HEIGHT);this.createBorderRect_();this.getBorderRect().style.fillOpacity="1";this.getBorderRect().setAttribute("stroke","#fff");this.isFullBlockField()&&(this.clickTarget_=this.sourceBlock_.getSvgRoot())}isFullBlockField(){const a=this.getSourceBlock();
+if(!a)throw new UnattachedFieldError$$module$build$src$core$field;const b=this.getConstants();return a.isSimpleReporter()&&!(null==b||!b.FIELD_COLOUR_FULL_BLOCK)}applyColour(){const a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;if(this.fieldGroup_){var b=this.borderRect_;if(!b)throw Error("The border rect has not been initialized");this.isFullBlockField()?(b.style.display="none",a.pathObject.svgPath.setAttribute("fill",this.getValue()),a.pathObject.svgPath.setAttribute("stroke",
+"#fff")):(b.style.display="block",b.style.fill=this.getValue())}}getSize(){let a;if(null==(a=this.getConstants())?0:a.FIELD_COLOUR_FULL_BLOCK)this.render_(),this.isDirty_=!1;return super.getSize()}render_(){super.render_();const a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;a.applyColour()}updateSize_(a){var b=this.getConstants();this.isFullBlockField()?(a=2*(null!=a?a:0),b=b.FIELD_TEXT_HEIGHT):(a=b.FIELD_COLOUR_DEFAULT_WIDTH,b=b.FIELD_COLOUR_DEFAULT_HEIGHT);
+this.size_.height=b;this.size_.width=a;this.positionBorderRect_()}doClassValidation_(a){return"string"!==typeof a?null:parse$$module$build$src$core$utils$colour(a)}getText(){let a=this.value_;/^#(.)\1(.)\2(.)\3$/.test(a)&&(a="#"+a[1]+a[3]+a[5]);return a}setColours(a,b){this.colours=a;b&&(this.titles=b);return this}setColumns(a){this.columns=a;return this}showEditor_(){this.dropdownCreate();getContentDiv$$module$build$src$core$dropdowndiv().appendChild(this.picker);showPositionedByField$$module$build$src$core$dropdowndiv(this,
+this.dropdownDispose.bind(this));this.picker.focus({preventScroll:!0})}onClick(a){a=(a=a.target)&&a.getAttribute("data-colour");null!==a&&(this.setValue(a),hideIfOwner$$module$build$src$core$dropdowndiv(this))}onKeyDown(a){let b=!0;var c;switch(a.key){case "ArrowUp":this.moveHighlightBy(0,-1);break;case "ArrowDown":this.moveHighlightBy(0,1);break;case "ArrowLeft":this.moveHighlightBy(-1,0);break;case "ArrowRight":this.moveHighlightBy(1,0);break;case "Enter":if(c=this.getHighlighted())c=c.getAttribute("data-colour"),
+null!==c&&this.setValue(c);hideWithoutAnimation$$module$build$src$core$dropdowndiv();break;default:b=!1}b&&a.stopPropagation()}moveHighlightBy(a,b){if(this.highlightedIndex){var c=this.colours||FieldColour$$module$build$src$core$field_colour.COLOURS,d=this.columns||FieldColour$$module$build$src$core$field_colour.COLUMNS,e=this.highlightedIndex%d,f=Math.floor(this.highlightedIndex/d);e+=a;f+=b;0>a?0>e&&0e&&(e=0):0d-1&&fd-1&&e--:0>b?0>f&&(f=
+0):0Math.floor(c.length/d)-1&&(f=Math.floor(c.length/d)-1);this.setHighlightedCell(this.picker.childNodes[f].childNodes[e],f*d+e)}}onMouseMove(a){const b=(a=a.target)&&Number(a.getAttribute("data-index"));null!==b&&b!==this.highlightedIndex&&this.setHighlightedCell(a,b)}onMouseEnter(){let a;null==(a=this.picker)||a.focus({preventScroll:!0})}onMouseLeave(){var a;null==(a=this.picker)||a.blur();(a=this.getHighlighted())&&removeClass$$module$build$src$core$utils$dom(a,"blocklyColourHighlighted")}getHighlighted(){if(!this.highlightedIndex)return null;
+const a=this.columns||FieldColour$$module$build$src$core$field_colour.COLUMNS,b=this.picker.childNodes[Math.floor(this.highlightedIndex/a)];return b?b.childNodes[this.highlightedIndex%a]:null}setHighlightedCell(a,b){const c=this.getHighlighted();c&&removeClass$$module$build$src$core$utils$dom(c,"blocklyColourHighlighted");addClass$$module$build$src$core$utils$dom(a,"blocklyColourHighlighted");this.highlightedIndex=b;(a=a.getAttribute("id"))&&this.picker&&setState$$module$build$src$core$utils$aria(this.picker,
+State$$module$build$src$core$utils$aria.ACTIVEDESCENDANT,a)}dropdownCreate(){const a=this.columns||FieldColour$$module$build$src$core$field_colour.COLUMNS,b=this.colours||FieldColour$$module$build$src$core$field_colour.COLOURS,c=this.titles||FieldColour$$module$build$src$core$field_colour.TITLES,d=this.getValue(),e=document.createElement("table");e.className="blocklyColourTable";e.tabIndex=0;e.dir="ltr";setRole$$module$build$src$core$utils$aria(e,Role$$module$build$src$core$utils$aria.GRID);setState$$module$build$src$core$utils$aria(e,
+State$$module$build$src$core$utils$aria.EXPANDED,!0);setState$$module$build$src$core$utils$aria(e,State$$module$build$src$core$utils$aria.ROWCOUNT,Math.floor(b.length/a));setState$$module$build$src$core$utils$aria(e,State$$module$build$src$core$utils$aria.COLCOUNT,a);let f;for(let g=0;gtr>td {\n  border: 0.5px solid #888;\n  box-sizing: border-box;\n  cursor: pointer;\n  display: inline-block;\n  height: 20px;\n  padding: 0;\n  width: 20px;\n}\n\n.blocklyColourTable>tr>td.blocklyColourHighlighted {\n  border-color: #eee;\n  box-shadow: 2px 2px 7px 2px rgba(0, 0, 0, 0.3);\n  position: relative;\n}\n\n.blocklyColourSelected, .blocklyColourSelected:hover {\n  border-color: #eee !important;\n  outline: 1px solid #333;\n  position: relative;\n}\n");
+var module$build$src$core$field_colour={};module$build$src$core$field_colour.FieldColour=FieldColour$$module$build$src$core$field_colour;var FieldLabelSerializable$$module$build$src$core$field_label_serializable=class extends FieldLabel$$module$build$src$core$field_label{constructor(a,b,c){super(String(null!=a?a:""),b,c);this.EDITABLE=!1;this.SERIALIZABLE=!0}static fromJson(a){return new this(replaceMessageReferences$$module$build$src$core$utils$parsing(a.text),void 0,a)}};register$$module$build$src$core$field_registry("field_label_serializable",FieldLabelSerializable$$module$build$src$core$field_label_serializable);
+var module$build$src$core$field_label_serializable={};module$build$src$core$field_label_serializable.FieldLabelSerializable=FieldLabelSerializable$$module$build$src$core$field_label_serializable;var FieldMultilineInput$$module$build$src$core$field_multilineinput=class extends FieldTextInput$$module$build$src$core$field_textinput{constructor(a,b,c){super(Field$$module$build$src$core$field.SKIP_SETUP);this.textGroup=null;this.maxLines_=Infinity;this.isOverflowedY_=!1;a!==Field$$module$build$src$core$field.SKIP_SETUP&&(c&&this.configure_(c),this.setValue(a),b&&this.setValidator(b))}configure_(a){super.configure_(a);a.maxLines&&this.setMaxLines(a.maxLines)}toXml(a){a.textContent=this.getValue().replace(/\n/g,
+"
");return a}fromXml(a){this.setValue(a.textContent.replace(/
/g,"\n"))}saveState(){const a=this.saveLegacyState(FieldMultilineInput$$module$build$src$core$field_multilineinput);return null!==a?a:this.getValue()}loadState(a){this.loadLegacyState(Field$$module$build$src$core$field,a)||this.setValue(a)}initView(){this.createBorderRect_();this.textGroup=createSvgElement$$module$build$src$core$utils$dom(Svg$$module$build$src$core$utils$svg.G,{"class":"blocklyEditableText"},this.fieldGroup_)}getDisplayText_(){const a=
+this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;let b=this.getText();if(!b)return Field$$module$build$src$core$field.NBSP;const c=b.split("\n");b="";const d=this.isOverflowedY_?this.maxLines_:c.length;for(let e=0;ethis.maxDisplayLength?f=f.substring(0,this.maxDisplayLength-4)+"...":this.isOverflowedY_&&e===d-1&&(f=f.substring(0,f.length-3)+"...");f=f.replace(/\s/g,Field$$module$build$src$core$field.NBSP);b+=f;e!==d-1&&(b+="\n")}a.RTL&&
+(b+="\u200f");return b}doValueUpdate_(a){super.doValueUpdate_(a);null!==this.value_&&(this.isOverflowedY_=this.value_.split("\n").length>this.maxLines_)}render_(){var a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;for(var b,c=this.textGroup;b=c.firstChild;)c.removeChild(b);b=this.getDisplayText_().split("\n");let d=0;for(let e=0;ee&&(e=h);f+=this.getConstants().FIELD_TEXT_HEIGHT+(0this.maxDisplayLength&&(a[h]=a[h].substring(0,this.maxDisplayLength));g.textContent=a[h];const k=getFastTextWidth$$module$build$src$core$utils$dom(g,b,c,d);k>e&&(e=k)}e+=this.htmlInput_.offsetWidth-this.htmlInput_.clientWidth}this.borderRect_&&(f+=2*this.getConstants().FIELD_BORDER_RECT_Y_PADDING,e+=2*this.getConstants().FIELD_BORDER_RECT_X_PADDING,this.borderRect_.setAttribute("width",`${e}`),this.borderRect_.setAttribute("height",`${f}`));this.size_.width=e;this.size_.height=
+f;this.positionBorderRect_()}showEditor_(a,b){super.showEditor_(a,b);this.forceRerender()}widgetCreate_(){const a=getDiv$$module$build$src$core$widgetdiv(),b=this.workspace_.getScale(),c=document.createElement("textarea");c.className="blocklyHtmlInput blocklyHtmlTextAreaInput";c.setAttribute("spellcheck",String(this.spellcheck_));var d=this.getConstants().FIELD_TEXT_FONTSIZE*b+"pt";a.style.fontSize=d;c.style.fontSize=d;c.style.borderRadius=FieldTextInput$$module$build$src$core$field_textinput.BORDERRADIUS*
+b+"px";d=this.getConstants().FIELD_BORDER_RECT_X_PADDING*b;const e=this.getConstants().FIELD_BORDER_RECT_Y_PADDING*b/2;c.style.padding=e+"px "+d+"px "+e+"px "+d+"px";d=this.getConstants().FIELD_TEXT_HEIGHT+this.getConstants().FIELD_BORDER_RECT_Y_PADDING;c.style.lineHeight=d*b+"px";a.appendChild(c);c.value=c.defaultValue=this.getEditorText_(this.value_);c.setAttribute("data-untyped-default-value",String(this.value_));c.setAttribute("data-old-value","");GECKO$$module$build$src$core$utils$useragent?
+setTimeout(this.resizeEditor_.bind(this),0):this.resizeEditor_();this.bindInputEvents_(c);return c}setMaxLines(a){"number"===typeof a&&0this.max_&&(a.max=`${this.max_}`,setState$$module$build$src$core$utils$aria(a,State$$module$build$src$core$utils$aria.VALUEMAX,
+this.max_));return a}static fromJson(a){return new this(a.value,void 0,void 0,void 0,void 0,a)}};register$$module$build$src$core$field_registry("field_number",FieldNumber$$module$build$src$core$field_number);FieldNumber$$module$build$src$core$field_number.prototype.DEFAULT_VALUE=0;var module$build$src$core$field_number={};module$build$src$core$field_number.FieldNumber=FieldNumber$$module$build$src$core$field_number;var FieldVariable$$module$build$src$core$field_variable=class extends FieldDropdown$$module$build$src$core$field_dropdown{constructor(a,b,c,d,e){super(Field$$module$build$src$core$field.SKIP_SETUP);this.defaultType="";this.variableTypes=[];this.variable=null;this.SERIALIZABLE=!0;this.menuGenerator_=FieldVariable$$module$build$src$core$field_variable.dropdownCreate;this.defaultVariableName="string"===typeof a?a:"";this.size_=new Size$$module$build$src$core$utils$size(0,0);a!==Field$$module$build$src$core$field.SKIP_SETUP&&
+(e?this.configure_(e):this.setTypes(c,d),b&&this.setValidator(b))}configure_(a){super.configure_(a);this.setTypes(a.variableTypes,a.defaultType)}initModel(){var a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;this.variable||(a=$.getOrCreateVariablePackage$$module$build$src$core$variables(a.workspace,null,this.defaultVariableName,this.defaultType),this.doValueUpdate_(a.getId()))}shouldAddBorderRect_(){const a=this.getSourceBlock();if(!a)throw new UnattachedFieldError$$module$build$src$core$field;
+return super.shouldAddBorderRect_()&&(!this.getConstants().FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW||"variables_get"!==a.type)}fromXml(a){var b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;const c=a.getAttribute("id"),d=a.textContent,e=a.getAttribute("variabletype")||a.getAttribute("variableType")||"";b=$.getOrCreateVariablePackage$$module$build$src$core$variables(b.workspace,c,d,e);if(null!==e&&e!==b.type)throw Error("Serialized variable type with id '"+b.getId()+
+"' had type "+b.type+", and does not match variable field that references it: "+domToText$$module$build$src$core$xml(a)+".");this.setValue(b.getId())}toXml(a){this.initModel();a.id=this.variable.getId();a.textContent=this.variable.name;this.variable.type&&a.setAttribute("variabletype",this.variable.type);return a}saveState(a){var b=this.saveLegacyState(FieldVariable$$module$build$src$core$field_variable);if(null!==b)return b;this.initModel();b={id:this.variable.getId()};a&&(b.name=this.variable.name,
+b.type=this.variable.type);return b}loadState(a){const b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;this.loadLegacyState(FieldVariable$$module$build$src$core$field_variable,a)||(a=$.getOrCreateVariablePackage$$module$build$src$core$variables(b.workspace,a.id||null,a.name,a.type||""),this.setValue(a.getId()))}setSourceBlock(a){if(a.isShadow())throw Error("Variable fields are not allowed to exist on shadow blocks.");super.setSourceBlock(a)}getValue(){return this.variable?
+this.variable.getId():null}getText(){return this.variable?this.variable.name:""}getVariable(){return this.variable}getValidator(){return this.variable?this.validator_:null}doClassValidation_(a){if(null===a)return null;var b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;b=$.getVariable$$module$build$src$core$variables(b.workspace,a);if(!b)return console.warn("Variable id doesn't point to a real variable! ID was "+a),null;b=b.type;return this.typeIsAllowed(b)?
+a:(console.warn("Variable type doesn't match this field!  Type was "+b),null)}doValueUpdate_(a){const b=this.getSourceBlock();if(!b)throw new UnattachedFieldError$$module$build$src$core$field;this.variable=$.getVariable$$module$build$src$core$variables(b.workspace,a);super.doValueUpdate_(a)}typeIsAllowed(a){const b=this.getVariableTypes();if(!b)return!0;for(let c=0;c{const c=this.targetWorkspace.getGesture(b);
+c&&(c.setStartBlock(a),c.handleFlyoutStart(b,this))}}onMouseDown(a){const b=this.targetWorkspace.getGesture(a);b&&b.handleFlyoutStart(a,this)}isBlockCreatable(a){return a.isEnabled()}createBlock(a){let b=null;$.disable$$module$build$src$core$events$utils();var c=this.targetWorkspace.getAllVariables();this.targetWorkspace.setResizesEnabled(!1);try{b=this.placeNewBlock(a)}finally{$.enable$$module$build$src$core$events$utils()}this.targetWorkspace.hideChaff();a=getAddedVariables$$module$build$src$core$variables(this.targetWorkspace,
+c);if(isEnabled$$module$build$src$core$events$utils()){$.setGroup$$module$build$src$core$events$utils(!0);for(c=0;c90-b||a>-90-b&&a<-90+b?!0:!1}getClientRect(){if(!this.svgGroup_||this.autoClose||
+!this.isVisible())return null;const a=this.svgGroup_.getBoundingClientRect(),b=a.top;return this.toolboxPosition_===Position$$module$build$src$core$utils$toolbox.TOP?new Rect$$module$build$src$core$utils$rect(-1E9,b+a.height,-1E9,1E9):new Rect$$module$build$src$core$utils$rect(b,1E9,-1E9,1E9)}reflowInternal_(){this.workspace_.scale=this.getFlyoutScale();let a=0;const b=this.workspace_.getTopBlocks(!1);for(let d=0,e;e=b[d];d++)a=Math.max(a,e.getHeightWidth().height);const c=this.buttons_;for(let d=
+0,e;e=c[d];d++)a=Math.max(a,e.height);a+=1.5*this.MARGIN;a*=this.workspace_.scale;a+=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness;if(this.height_!==a){for(let d=0,e;e=b[d];d++)this.rectMap_.has(e)&&this.moveRectToBlock_(this.rectMap_.get(e),e);this.targetWorkspace.toolboxPosition!==this.toolboxPosition_||this.toolboxPosition_!==Position$$module$build$src$core$utils$toolbox.TOP||this.targetWorkspace.getToolbox()||this.targetWorkspace.translate(this.targetWorkspace.scrollX,this.targetWorkspace.scrollY+
+a);this.height_=a;this.position();this.targetWorkspace.recordDragTargets()}}};register$$module$build$src$core$registry(Type$$module$build$src$core$registry.FLYOUTS_HORIZONTAL_TOOLBOX,DEFAULT$$module$build$src$core$registry,HorizontalFlyout$$module$build$src$core$flyout_horizontal);var module$build$src$core$flyout_horizontal={};module$build$src$core$flyout_horizontal.HorizontalFlyout=HorizontalFlyout$$module$build$src$core$flyout_horizontal;var VerticalFlyout$$module$build$src$core$flyout_vertical=class extends Flyout$$module$build$src$core$flyout_base{constructor(a){super(a)}setMetrics_(a){if(this.isVisible()){var b=this.workspace_.getMetricsManager(),c=b.getScrollMetrics(),d=b.getViewMetrics();b=b.getAbsoluteMetrics();"number"===typeof a.y&&(this.workspace_.scrollY=-(c.top+(c.height-d.height)*a.y));this.workspace_.translate(this.workspace_.scrollX+b.left,this.workspace_.scrollY+b.top)}}getX(){if(!this.isVisible())return 0;var a=this.targetWorkspace.getMetricsManager();
+const b=a.getAbsoluteMetrics(),c=a.getViewMetrics();a=a.getToolboxMetrics();return this.targetWorkspace.toolboxPosition===this.toolboxPosition_?this.targetWorkspace.getToolbox()?this.toolboxPosition_===Position$$module$build$src$core$utils$toolbox.LEFT?a.width:c.width-this.width_:this.toolboxPosition_===Position$$module$build$src$core$utils$toolbox.LEFT?0:c.width:this.toolboxPosition_===Position$$module$build$src$core$utils$toolbox.LEFT?0:c.width+b.left-this.width_}getY(){return 0}position(){if(this.isVisible()&&
+this.targetWorkspace.isVisible()){var a=this.targetWorkspace.getMetricsManager().getViewMetrics();this.height_=a.height;this.setBackgroundPath(this.width_-this.CORNER_RADIUS,a.height-2*this.CORNER_RADIUS);a=this.getX();var b=this.getY();this.positionAt_(this.width_,this.height_,a,b)}}setBackgroundPath(a,b){const c=this.toolboxPosition_===Position$$module$build$src$core$utils$toolbox.RIGHT;var d=a+this.CORNER_RADIUS;d=["M "+(c?d:0)+",0"];d.push("h",c?-a:a);d.push("a",this.CORNER_RADIUS,this.CORNER_RADIUS,
+0,0,c?0:1,c?-this.CORNER_RADIUS:this.CORNER_RADIUS,this.CORNER_RADIUS);d.push("v",Math.max(0,b));d.push("a",this.CORNER_RADIUS,this.CORNER_RADIUS,0,0,c?0:1,c?this.CORNER_RADIUS:-this.CORNER_RADIUS,this.CORNER_RADIUS);d.push("h",c?a:-a);d.push("z");this.svgBackground_.setAttribute("d",d.join(" "))}scrollToStart(){let a;null==(a=this.workspace_.scrollbar)||a.setY(0)}wheel_(a){var b=getScrollDeltaPixels$$module$build$src$core$browser_events(a);if(b.y){const c=this.workspace_.getMetricsManager(),d=c.getScrollMetrics();
+b=c.getViewMetrics().top-d.top+b.y;let e;null==(e=this.workspace_.scrollbar)||e.setY(b);hide$$module$build$src$core$widgetdiv();hideWithoutAnimation$$module$build$src$core$dropdowndiv()}a.preventDefault();a.stopPropagation()}layout_(a,b){this.workspace_.scale=this.targetWorkspace.scale;var c=this.MARGIN;const d=this.RTL?c:c+this.tabWidth_;for(let h=0,k;k=a[h];h++)if("block"===k.type){var e=k.block,f=e.getDescendants(!1);for(let m=0,n;n=f[m];m++)n.isInFlyout=!0;f=e.getSvgRoot();const l=e.getHeightWidth();
+var g=e.outputConnection?d-this.tabWidth_:d;e.moveBy(g,c);g=this.createRect_(e,this.RTL?g-l.width:g,c,l,h);this.addBlockListeners_(f,e,g);c+=l.height+b[h]}else"button"===k.type&&(e=k.button,this.initFlyoutButton_(e,d,c),c+=e.height+b[h])}isDragTowardWorkspace(a){a=Math.atan2(a.y,a.x)/Math.PI*180;const b=this.dragAngleRange_;return a-b||a<-180+b||a>180-b?!0:!1}getClientRect(){if(!this.svgGroup_||this.autoClose||!this.isVisible())return null;const a=this.svgGroup_.getBoundingClientRect(),b=a.left;
+return this.toolboxPosition_===Position$$module$build$src$core$utils$toolbox.LEFT?new Rect$$module$build$src$core$utils$rect(-1E9,1E9,-1E9,b+a.width):new Rect$$module$build$src$core$utils$rect(-1E9,1E9,b,1E9)}reflowInternal_(){this.workspace_.scale=this.getFlyoutScale();let a=0;var b=this.workspace_.getTopBlocks(!1);for(let d=0,e;e=b[d];d++){var c=e.getHeightWidth().width;e.outputConnection&&(c-=this.tabWidth_);a=Math.max(a,c)}for(let d=0,e;e=this.buttons_[d];d++)a=Math.max(a,e.width);a+=1.5*this.MARGIN+
+this.tabWidth_;a*=this.workspace_.scale;a+=Scrollbar$$module$build$src$core$scrollbar.scrollbarThickness;if(this.width_!==a){for(let d=0,e;e=b[d];d++){if(this.RTL){c=e.getRelativeToSurfaceXY().x;let f=a/this.workspace_.scale-this.MARGIN;e.outputConnection||(f-=this.tabWidth_);e.moveBy(f-c,0)}this.rectMap_.has(e)&&this.moveRectToBlock_(this.rectMap_.get(e),e)}if(this.RTL)for(let d=0,e;e=this.buttons_[d];d++)b=e.getPosition().y,e.moveTo(a/this.workspace_.scale-e.width-this.MARGIN-this.tabWidth_,b);
+this.targetWorkspace.toolboxPosition!==this.toolboxPosition_||this.toolboxPosition_!==Position$$module$build$src$core$utils$toolbox.LEFT||this.targetWorkspace.getToolbox()||this.targetWorkspace.translate(this.targetWorkspace.scrollX+a,this.targetWorkspace.scrollY);this.width_=a;this.position();this.targetWorkspace.recordDragTargets()}}};VerticalFlyout$$module$build$src$core$flyout_vertical.registryName="verticalFlyout";
+register$$module$build$src$core$registry(Type$$module$build$src$core$registry.FLYOUTS_VERTICAL_TOOLBOX,DEFAULT$$module$build$src$core$registry,VerticalFlyout$$module$build$src$core$flyout_vertical);var module$build$src$core$flyout_vertical={};module$build$src$core$flyout_vertical.VerticalFlyout=VerticalFlyout$$module$build$src$core$flyout_vertical;var module$build$src$core$generator;
+$.CodeGenerator$$module$build$src$core$generator=class{constructor(a){this.forBlock=Object.create(null);this.FUNCTION_NAME_PLACEHOLDER_="{leCUI8hutHZI4480Dc}";this.STATEMENT_SUFFIX=this.STATEMENT_PREFIX=this.INFINITE_LOOP_TRAP=null;this.INDENT="  ";this.COMMENT_WRAP=60;this.ORDER_OVERRIDES=[];this.isInitialized=null;this.RESERVED_WORDS_="";this.definitions_=Object.create(null);this.functionNames_=Object.create(null);this.nameDB_=void 0;this.name_=a;this.FUNCTION_NAME_PLACEHOLDER_REGEXP_=new RegExp(this.FUNCTION_NAME_PLACEHOLDER_,
+"g")}workspaceToCode(a){a||(console.warn("No workspace specified in workspaceToCode call.  Guessing."),a=getMainWorkspace$$module$build$src$core$common());var b=[];this.init(a);a=a.getTopBlocks(!0);for(let c=0,d;d=a[c];c++){let e=this.blockToCode(d);Array.isArray(e)&&(e=e[0]);e&&(d.outputConnection&&(e=this.scrubNakedValue(e),this.STATEMENT_PREFIX&&!d.suppressPrefixSuffix&&(e=this.injectId(this.STATEMENT_PREFIX,d)+e),this.STATEMENT_SUFFIX&&!d.suppressPrefixSuffix&&(e+=this.injectId(this.STATEMENT_SUFFIX,
+d))),b.push(e))}b=b.join("\n");b=this.finish(b);b=b.replace(/^\s+\n/,"");b=b.replace(/\n\s+$/,"\n");return b=b.replace(/[ \t]+\n/g,"\n")}prefixLines(a,b){return b+a.replace(/(?!\n$)\n/g,"\n"+b)}allNestedComments(a){const b=[];a=a.getDescendants(!0);for(let c=0;c.blocklyPathLight,`,`${a} .blocklyInsertionMarker>.blocklyPathDark {`,`fill-opacity: ${this.INSERTION_MARKER_OPACITY};`,"stroke: none;",
+"}"])}},module$build$src$core$renderers$geras$constants={};module$build$src$core$renderers$geras$constants.ConstantProvider=ConstantProvider$$module$build$src$core$renderers$geras$constants;var Highlighter$$module$build$src$core$renderers$geras$highlighter=class{constructor(a){this.inlineSteps_=this.steps_="";this.info_=a;this.RTL_=this.info_.RTL;a=a.getRenderer();this.constants_=a.getConstants();this.highlightConstants_=a.getHighlightConstants();this.highlightOffset=this.highlightConstants_.OFFSET;this.outsideCornerPaths_=this.highlightConstants_.OUTSIDE_CORNER;this.insideCornerPaths_=this.highlightConstants_.INSIDE_CORNER;this.puzzleTabPaths_=this.highlightConstants_.PUZZLE_TAB;this.notchPaths_=
+this.highlightConstants_.NOTCH;this.startPaths_=this.highlightConstants_.START_HAT;this.jaggedTeethPaths_=this.highlightConstants_.JAGGED_TEETH}getPath(){return this.steps_+"\n"+this.inlineSteps_}drawTopCorner(a){this.steps_+=moveBy$$module$build$src$core$utils$svg_paths(a.xPos,this.info_.startY);for(let b=0,c;c=a.elements[b];b++)Types$$module$build$src$core$renderers$measurables$types.isLeftSquareCorner(c)?this.steps_+=this.highlightConstants_.START_POINT:Types$$module$build$src$core$renderers$measurables$types.isLeftRoundedCorner(c)?
+this.steps_+=this.outsideCornerPaths_.topLeft(this.RTL_):Types$$module$build$src$core$renderers$measurables$types.isPreviousConnection(c)?this.steps_+=this.notchPaths_.pathLeft:Types$$module$build$src$core$renderers$measurables$types.isHat(c)?this.steps_+=this.startPaths_.path(this.RTL_):Types$$module$build$src$core$renderers$measurables$types.isSpacer(c)&&0!==c.width&&(this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("H",c.xPos+c.width-this.highlightOffset));this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("H",
+a.xPos+a.width-this.highlightOffset)}drawJaggedEdge_(a){this.info_.RTL&&(this.steps_+=this.jaggedTeethPaths_.pathLeft+lineOnAxis$$module$build$src$core$utils$svg_paths("v",a.height-this.jaggedTeethPaths_.height-this.highlightOffset))}drawValueInput(a){const b=a.getLastInput();if(this.RTL_){const c=a.height-b.connectionHeight;this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(b.xPos+b.width-this.highlightOffset,a.yPos)+this.puzzleTabPaths_.pathDown(this.RTL_)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",
+c)}else this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(b.xPos+b.width,a.yPos)+this.puzzleTabPaths_.pathDown(this.RTL_)}drawStatementInput(a){const b=a.getLastInput();if(b)if(this.RTL_){const c=a.height-2*this.insideCornerPaths_.height;this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(b.xPos,a.yPos)+this.insideCornerPaths_.pathTop(this.RTL_)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",c)+this.insideCornerPaths_.pathBottom(this.RTL_)+lineTo$$module$build$src$core$utils$svg_paths(a.width-
+b.xPos-this.insideCornerPaths_.width,0)}else this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(b.xPos,a.yPos+a.height)+this.insideCornerPaths_.pathBottom(this.RTL_)+lineTo$$module$build$src$core$utils$svg_paths(a.width-b.xPos-this.insideCornerPaths_.width,0)}drawRightSideRow(a){const b=a.xPos+a.width-this.highlightOffset;a instanceof SpacerRow$$module$build$src$core$renderers$measurables$spacer_row&&a.followsStatement&&(this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("H",b));
+this.RTL_&&(this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("H",b),a.height>this.highlightOffset&&(this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("V",a.yPos+a.height-this.highlightOffset)))}drawBottomRow(a){if(this.RTL_)this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("V",a.baseline-this.highlightOffset);else{const b=this.info_.bottomRow.elements[0];Types$$module$build$src$core$renderers$measurables$types.isLeftSquareCorner(b)?this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(a.xPos+
+this.highlightOffset,a.baseline-this.highlightOffset):Types$$module$build$src$core$renderers$measurables$types.isLeftRoundedCorner(b)&&(this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(a.xPos,a.baseline),this.steps_+=this.outsideCornerPaths_.bottomLeft())}}drawLeft(){var a=this.info_.outputConnection;a&&(a=a.connectionOffsetY+a.height,this.RTL_?this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(this.info_.startX,a):(this.steps_+=moveTo$$module$build$src$core$utils$svg_paths(this.info_.startX+
+this.highlightOffset,this.info_.bottomRow.baseline-this.highlightOffset),this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("V",a)),this.steps_+=this.puzzleTabPaths_.pathUp(this.RTL_));this.RTL_||(a=this.info_.topRow,Types$$module$build$src$core$renderers$measurables$types.isLeftRoundedCorner(a.elements[0])?this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("V",this.outsideCornerPaths_.height):this.steps_+=lineOnAxis$$module$build$src$core$utils$svg_paths("V",a.capline+this.highlightOffset))}drawInlineInput(a){const b=
+this.highlightOffset,c=a.xPos+a.connectionWidth;var d=a.centerline-a.height/2;const e=a.width-a.connectionWidth,f=d+b;this.RTL_?(d=a.connectionOffsetY-b,a=a.height-(a.connectionOffsetY+a.connectionHeight)+b,this.inlineSteps_+=moveTo$$module$build$src$core$utils$svg_paths(c-b,f)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",d)+this.puzzleTabPaths_.pathDown(this.RTL_)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",a)+lineOnAxis$$module$build$src$core$utils$svg_paths("h",e)):this.inlineSteps_+=
+moveTo$$module$build$src$core$utils$svg_paths(a.xPos+a.width+b,f)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",a.height)+lineOnAxis$$module$build$src$core$utils$svg_paths("h",-e)+moveTo$$module$build$src$core$utils$svg_paths(c,d+a.connectionOffsetY)+this.puzzleTabPaths_.pathDown(this.RTL_)}},module$build$src$core$renderers$geras$highlighter={};module$build$src$core$renderers$geras$highlighter.Highlighter=Highlighter$$module$build$src$core$renderers$geras$highlighter;var Drawer$$module$build$src$core$renderers$geras$drawer=class extends Drawer$$module$build$src$core$renderers$common$drawer{constructor(a,b){super(a,b);this.highlighter_=new Highlighter$$module$build$src$core$renderers$geras$highlighter(b)}draw(){this.drawOutline_();this.drawInternals_();const a=this.block_.pathObject;a.setPath(this.outlinePath_+"\n"+this.inlinePath_);a.setHighlightPath(this.highlighter_.getPath());this.info_.RTL&&a.flipRTL();this.recordSizeOnBlock_()}drawTop_(){this.highlighter_.drawTopCorner(this.info_.topRow);
+this.highlighter_.drawRightSideRow(this.info_.topRow);super.drawTop_()}drawJaggedEdge_(a){this.highlighter_.drawJaggedEdge_(a);super.drawJaggedEdge_(a)}drawValueInput_(a){this.highlighter_.drawValueInput(a);super.drawValueInput_(a)}drawStatementInput_(a){this.highlighter_.drawStatementInput(a);super.drawStatementInput_(a)}drawRightSideRow_(a){this.highlighter_.drawRightSideRow(a);this.outlinePath_+=lineOnAxis$$module$build$src$core$utils$svg_paths("H",a.xPos+a.width)+lineOnAxis$$module$build$src$core$utils$svg_paths("V",
+a.yPos+a.height)}drawBottom_(){this.highlighter_.drawBottomRow(this.info_.bottomRow);super.drawBottom_()}drawLeft_(){this.highlighter_.drawLeft();super.drawLeft_()}drawInlineInput_(a){this.highlighter_.drawInlineInput(a);super.drawInlineInput_(a)}positionInlineInputConnection_(a){const b=a.centerline-a.height/2;if(a.connectionModel){let c=a.xPos+a.connectionWidth+this.constants_.DARK_PATH_OFFSET;this.info_.RTL&&(c*=-1);a.connectionModel.setOffsetInBlock(c,b+a.connectionOffsetY+this.constants_.DARK_PATH_OFFSET)}}positionStatementInputConnection_(a){const b=
+a.getLastInput();if(null==b?0:b.connectionModel){let c=a.xPos+a.statementEdge+b.notchOffset;c=this.info_.RTL?-1*c:c+this.constants_.DARK_PATH_OFFSET;b.connectionModel.setOffsetInBlock(c,a.yPos+this.constants_.DARK_PATH_OFFSET)}}positionExternalValueConnection_(a){const b=a.getLastInput();if(b&&b.connectionModel){let c=a.xPos+a.width+this.constants_.DARK_PATH_OFFSET;this.info_.RTL&&(c*=-1);b.connectionModel.setOffsetInBlock(c,a.yPos)}}positionNextConnection_(){const a=this.info_.bottomRow;if(a.connection){const b=
+a.connection,c=b.xPos;b.connectionModel.setOffsetInBlock((this.info_.RTL?-c:c)+this.constants_.DARK_PATH_OFFSET/2,a.baseline+this.constants_.DARK_PATH_OFFSET)}}},module$build$src$core$renderers$geras$drawer={};module$build$src$core$renderers$geras$drawer.Drawer=Drawer$$module$build$src$core$renderers$geras$drawer;var HighlightConstantProvider$$module$build$src$core$renderers$geras$highlight_constants=class{constructor(a){this.OFFSET=.5;this.constantProvider=a;this.START_POINT=moveBy$$module$build$src$core$utils$svg_paths(this.OFFSET,this.OFFSET)}init(){this.INSIDE_CORNER=this.makeInsideCorner();this.OUTSIDE_CORNER=this.makeOutsideCorner();this.PUZZLE_TAB=this.makePuzzleTab();this.NOTCH=this.makeNotch();this.JAGGED_TEETH=this.makeJaggedTeeth();this.START_HAT=this.makeStartHat()}makeInsideCorner(){const a=this.constantProvider.CORNER_RADIUS,
+b=this.OFFSET,c=(1-Math.SQRT1_2)*(a+b)-b,d=moveBy$$module$build$src$core$utils$svg_paths(c,c)+arc$$module$build$src$core$utils$svg_paths("a","0 0,0",a,point$$module$build$src$core$utils$svg_paths(-c-b,a-c)),e=arc$$module$build$src$core$utils$svg_paths("a","0 0,0",a+b,point$$module$build$src$core$utils$svg_paths(a+b,a+b)),f=moveBy$$module$build$src$core$utils$svg_paths(c,-c)+arc$$module$build$src$core$utils$svg_paths("a","0 0,0",a+b,point$$module$build$src$core$utils$svg_paths(a-c,c+b));return{width:a+
+b,height:a,pathTop(g){return g?d:""},pathBottom(g){return g?e:f}}}makeOutsideCorner(){const a=this.constantProvider.CORNER_RADIUS,b=this.OFFSET,c=(1-Math.SQRT1_2)*(a-b)+b,d=moveBy$$module$build$src$core$utils$svg_paths(c,c)+arc$$module$build$src$core$utils$svg_paths("a","0 0,1",a-b,point$$module$build$src$core$utils$svg_paths(a-c,-c+b)),e=moveBy$$module$build$src$core$utils$svg_paths(b,a)+arc$$module$build$src$core$utils$svg_paths("a","0 0,1",a-b,point$$module$build$src$core$utils$svg_paths(a,-a+
+b)),f=-c,g=moveBy$$module$build$src$core$utils$svg_paths(c,f)+arc$$module$build$src$core$utils$svg_paths("a","0 0,1",a-b,point$$module$build$src$core$utils$svg_paths(-c+b,-f-a));return{height:a,topLeft(h){return h?d:e},bottomLeft(){return g}}}makePuzzleTab(){const a=this.constantProvider.TAB_WIDTH,b=this.constantProvider.TAB_HEIGHT,c=moveBy$$module$build$src$core$utils$svg_paths(-2,-b+3.4)+lineTo$$module$build$src$core$utils$svg_paths(-.45*a,-2.1),d=lineOnAxis$$module$build$src$core$utils$svg_paths("v",
+2.5)+moveBy$$module$build$src$core$utils$svg_paths(.97*-a,2.5)+curve$$module$build$src$core$utils$svg_paths("q",[point$$module$build$src$core$utils$svg_paths(.05*-a,10),point$$module$build$src$core$utils$svg_paths(.3*a,9.5)])+moveBy$$module$build$src$core$utils$svg_paths(.67*a,-1.9)+lineOnAxis$$module$build$src$core$utils$svg_paths("v",2.5),e=lineOnAxis$$module$build$src$core$utils$svg_paths("v",-1.5)+moveBy$$module$build$src$core$utils$svg_paths(-.92*a,-.5)+curve$$module$build$src$core$utils$svg_paths("q",
+[point$$module$build$src$core$utils$svg_paths(-.19*a,-5.5),point$$module$build$src$core$utils$svg_paths(0,-11)])+moveBy$$module$build$src$core$utils$svg_paths(.92*a,1),f=moveBy$$module$build$src$core$utils$svg_paths(-5,b-.7)+lineTo$$module$build$src$core$utils$svg_paths(.46*a,-2.1);return{width:a,height:b,pathUp(g){return g?c:e},pathDown(g){return g?d:f}}}makeNotch(){return{pathLeft:lineOnAxis$$module$build$src$core$utils$svg_paths("h",this.OFFSET)+this.constantProvider.NOTCH.pathLeft}}makeJaggedTeeth(){return{pathLeft:lineTo$$module$build$src$core$utils$svg_paths(5.1,
+2.6)+moveBy$$module$build$src$core$utils$svg_paths(-10.2,6.8)+lineTo$$module$build$src$core$utils$svg_paths(5.1,2.6),height:12,width:10.2}}makeStartHat(){const a=this.constantProvider.START_HAT.height,b=moveBy$$module$build$src$core$utils$svg_paths(25,-8.7)+curve$$module$build$src$core$utils$svg_paths("c",[point$$module$build$src$core$utils$svg_paths(29.7,-6.2),point$$module$build$src$core$utils$svg_paths(57.2,-.5),point$$module$build$src$core$utils$svg_paths(75,8.7)]),c=curve$$module$build$src$core$utils$svg_paths("c",
+[point$$module$build$src$core$utils$svg_paths(17.8,-9.2),point$$module$build$src$core$utils$svg_paths(45.3,-14.9),point$$module$build$src$core$utils$svg_paths(75,-8.7)])+moveTo$$module$build$src$core$utils$svg_paths(100.5,a+.5);return{path(d){return d?b:c}}}},module$build$src$core$renderers$geras$highlight_constants={};module$build$src$core$renderers$geras$highlight_constants.HighlightConstantProvider=HighlightConstantProvider$$module$build$src$core$renderers$geras$highlight_constants;var InlineInput$$module$build$src$core$renderers$geras$measurables$inline_input=class extends InlineInput$$module$build$src$core$renderers$measurables$inline_input{constructor(a,b){super(a,b);this.constants_=a;this.connectedBlock&&(this.width+=this.constants_.DARK_PATH_OFFSET,this.height+=this.constants_.DARK_PATH_OFFSET)}},module$build$src$core$renderers$geras$measurables$inline_input={};module$build$src$core$renderers$geras$measurables$inline_input.InlineInput=InlineInput$$module$build$src$core$renderers$geras$measurables$inline_input;var StatementInput$$module$build$src$core$renderers$geras$measurables$statement_input=class extends StatementInput$$module$build$src$core$renderers$measurables$statement_input{constructor(a,b){super(a,b);this.constants_=a;this.connectedBlock&&(this.height+=this.constants_.DARK_PATH_OFFSET)}},module$build$src$core$renderers$geras$measurables$statement_input={};module$build$src$core$renderers$geras$measurables$statement_input.StatementInput=StatementInput$$module$build$src$core$renderers$geras$measurables$statement_input;var RenderInfo$$module$build$src$core$renderers$geras$info=class extends RenderInfo$$module$build$src$core$renderers$common$info{constructor(a,b){super(a,b);this.renderer_=a}getRenderer(){return this.renderer_}populateBottomRow_(){super.populateBottomRow_();this.block_.inputList.length&&this.block_.inputList[this.block_.inputList.length-1]instanceof StatementInput$$module$build$src$core$inputs$statement_input||(this.bottomRow.minHeight=this.constants_.MEDIUM_PADDING-this.constants_.DARK_PATH_OFFSET)}addInput_(a,
+b){if(this.isInline&&a instanceof $.ValueInput$$module$build$src$core$inputs$value_input)b.elements.push(new InlineInput$$module$build$src$core$renderers$geras$measurables$inline_input(this.constants_,a)),b.hasInlineInput=!0;else if(a instanceof StatementInput$$module$build$src$core$inputs$statement_input)b.elements.push(new StatementInput$$module$build$src$core$renderers$geras$measurables$statement_input(this.constants_,a)),b.hasStatement=!0;else if(a instanceof $.ValueInput$$module$build$src$core$inputs$value_input)b.elements.push(new ExternalValueInput$$module$build$src$core$renderers$measurables$external_value_input(this.constants_,
+a)),b.hasExternalInput=!0;else if(a instanceof DummyInput$$module$build$src$core$inputs$dummy_input||a instanceof EndRowInput$$module$build$src$core$inputs$end_row_input)b.minHeight=Math.max(b.minHeight,this.constants_.DUMMY_INPUT_MIN_HEIGHT),b.hasDummyInput=!0;this.isInline||null!==b.align||(b.align=a.align)}addElemSpacing_(){let a=!1;for(let c=0,d;d=this.rows[c];c++)d.hasExternalInput&&(a=!0);for(let c=0,d;d=this.rows[c];c++){var b=d.elements;d.elements=[];d.startsWithElemSpacer()&&d.elements.push(new InRowSpacer$$module$build$src$core$renderers$measurables$in_row_spacer(this.constants_,
+this.getInRowSpacing_(null,b[0])));if(b.length){for(let e=0;e>>/sprites.png);\n  height: 16px;\n  vertical-align: middle;\n  visibility: hidden;\n  width: 16px;\n}\n\n.blocklyTreeIconClosed {\n  background-position: -32px -1px;\n}\n\n.blocklyToolboxDiv[dir="RTL"] .blocklyTreeIconClosed {\n  background-position: 0 -1px;\n}\n\n.blocklyTreeSelected>.blocklyTreeIconClosed {\n  background-position: -32px -17px;\n}\n\n.blocklyToolboxDiv[dir="RTL"] .blocklyTreeSelected>.blocklyTreeIconClosed {\n  background-position: 0 -17px;\n}\n\n.blocklyTreeIconOpen {\n  background-position: -16px -1px;\n}\n\n.blocklyTreeSelected>.blocklyTreeIconOpen {\n  background-position: -16px -17px;\n}\n\n.blocklyTreeLabel {\n  cursor: default;\n  font: 16px sans-serif;\n  padding: 0 3px;\n  vertical-align: middle;\n}\n\n.blocklyToolboxDelete .blocklyTreeLabel {\n  cursor: url("<<>>/handdelete.cur"), auto;\n}\n\n.blocklyTreeSelected .blocklyTreeLabel {\n  color: #fff;\n}\n');
+register$$module$build$src$core$registry(Type$$module$build$src$core$registry.TOOLBOX_ITEM,ToolboxCategory$$module$build$src$core$toolbox$category.registrationName,ToolboxCategory$$module$build$src$core$toolbox$category);var module$build$src$core$toolbox$category={};module$build$src$core$toolbox$category.ToolboxCategory=ToolboxCategory$$module$build$src$core$toolbox$category;var ToolboxSeparator$$module$build$src$core$toolbox$separator=class extends ToolboxItem$$module$build$src$core$toolbox$toolbox_item{constructor(a,b){super(a,b);this.cssConfig_={container:"blocklyTreeSeparator"};this.htmlDiv_=null;Object.assign(this.cssConfig_,a.cssconfig||a.cssConfig)}init(){this.createDom_()}createDom_(){const a=document.createElement("div"),b=this.cssConfig_.container;b&&addClass$$module$build$src$core$utils$dom(a,b);return this.htmlDiv_=a}getDiv(){return this.htmlDiv_}dispose(){removeNode$$module$build$src$core$utils$dom(this.htmlDiv_)}};
+ToolboxSeparator$$module$build$src$core$toolbox$separator.registrationName="sep";register$$module$build$src$core$css('\n.blocklyTreeSeparator {\n  border-bottom: solid #e5e5e5 1px;\n  height: 0;\n  margin: 5px 0;\n}\n\n.blocklyToolboxDiv[layout="h"] .blocklyTreeSeparator {\n  border-right: solid #e5e5e5 1px;\n  border-bottom: none;\n  height: auto;\n  margin: 0 5px 0 5px;\n  padding: 5px 0;\n  width: 0;\n}\n');
+register$$module$build$src$core$registry(Type$$module$build$src$core$registry.TOOLBOX_ITEM,ToolboxSeparator$$module$build$src$core$toolbox$separator.registrationName,ToolboxSeparator$$module$build$src$core$toolbox$separator);var module$build$src$core$toolbox$separator={};module$build$src$core$toolbox$separator.ToolboxSeparator=ToolboxSeparator$$module$build$src$core$toolbox$separator;var CollapsibleToolboxCategory$$module$build$src$core$toolbox$collapsible_category=class extends ToolboxCategory$$module$build$src$core$toolbox$category{constructor(a,b,c){super(a,b,c);this.subcategoriesDiv_=null;this.expanded_=!1;this.toolboxItems_=[]}makeDefaultCssConfig_(){const a=super.makeDefaultCssConfig_();a.contents="blocklyToolboxContents";return a}parseContents_(a){if("custom"in a)this.flyoutItems_=a.custom;else{const b=a.contents;if(b){this.flyoutItems_=[];a=!0;for(let c=0;c>>/handdelete.cur"), auto;\n}\n\n.blocklyToolboxGrab {\n  cursor: url("<<>>/handclosed.cur"), auto;\n  cursor: grabbing;\n  cursor: -webkit-grabbing;\n}\n\n/* Category tree in Toolbox. */\n.blocklyToolboxDiv {\n  background-color: #ddd;\n  overflow-x: visible;\n  overflow-y: auto;\n  padding: 4px 0 4px 0;\n  position: absolute;\n  z-index: 70;  /* so blocks go under toolbox when dragging */\n  -webkit-tap-highlight-color: transparent;  /* issue #1345 */\n}\n\n.blocklyToolboxContents {\n  display: flex;\n  flex-wrap: wrap;\n  flex-direction: column;\n}\n\n.blocklyToolboxContents:focus {\n  outline: none;\n}\n');
+register$$module$build$src$core$registry(Type$$module$build$src$core$registry.TOOLBOX,DEFAULT$$module$build$src$core$registry,Toolbox$$module$build$src$core$toolbox$toolbox);var module$build$src$core$toolbox$toolbox={};module$build$src$core$toolbox$toolbox.Toolbox=Toolbox$$module$build$src$core$toolbox$toolbox;var VERSION$$module$build$src$core$blockly="10.2.2",ALIGN_LEFT$$module$build$src$core$blockly=$.Align$$module$build$src$core$inputs$align.LEFT,ALIGN_CENTRE$$module$build$src$core$blockly=$.Align$$module$build$src$core$inputs$align.CENTRE,ALIGN_RIGHT$$module$build$src$core$blockly=$.Align$$module$build$src$core$inputs$align.RIGHT,INPUT_VALUE$$module$build$src$core$blockly=ConnectionType$$module$build$src$core$connection_type.INPUT_VALUE,OUTPUT_VALUE$$module$build$src$core$blockly=ConnectionType$$module$build$src$core$connection_type.OUTPUT_VALUE,
+NEXT_STATEMENT$$module$build$src$core$blockly=ConnectionType$$module$build$src$core$connection_type.NEXT_STATEMENT,PREVIOUS_STATEMENT$$module$build$src$core$blockly=ConnectionType$$module$build$src$core$connection_type.PREVIOUS_STATEMENT,DUMMY_INPUT$$module$build$src$core$blockly=$.inputTypes$$module$build$src$core$inputs$input_types.DUMMY,TOOLBOX_AT_TOP$$module$build$src$core$blockly=Position$$module$build$src$core$utils$toolbox.TOP,TOOLBOX_AT_BOTTOM$$module$build$src$core$blockly=Position$$module$build$src$core$utils$toolbox.BOTTOM,
+TOOLBOX_AT_LEFT$$module$build$src$core$blockly=Position$$module$build$src$core$utils$toolbox.LEFT,TOOLBOX_AT_RIGHT$$module$build$src$core$blockly=Position$$module$build$src$core$utils$toolbox.RIGHT,svgResize$$module$build$src$core$blockly=svgResize$$module$build$src$core$common,getMainWorkspace$$module$build$src$core$blockly=getMainWorkspace$$module$build$src$core$common,getSelected$$module$build$src$core$blockly=getSelected$$module$build$src$core$common,defineBlocksWithJsonArray$$module$build$src$core$blockly=
+defineBlocksWithJsonArray$$module$build$src$core$common,setParentContainer$$module$build$src$core$blockly=setParentContainer$$module$build$src$core$common,COLLAPSE_CHARS$$module$build$src$core$blockly=COLLAPSE_CHARS$$module$build$src$core$internal_constants,DRAG_STACK$$module$build$src$core$blockly=DRAG_STACK$$module$build$src$core$internal_constants,OPPOSITE_TYPE$$module$build$src$core$blockly=OPPOSITE_TYPE$$module$build$src$core$internal_constants,RENAME_VARIABLE_ID$$module$build$src$core$blockly=
+RENAME_VARIABLE_ID$$module$build$src$core$internal_constants,DELETE_VARIABLE_ID$$module$build$src$core$blockly=DELETE_VARIABLE_ID$$module$build$src$core$internal_constants,COLLAPSED_INPUT_NAME$$module$build$src$core$blockly=COLLAPSED_INPUT_NAME$$module$build$src$core$constants,COLLAPSED_FIELD_NAME$$module$build$src$core$blockly=COLLAPSED_FIELD_NAME$$module$build$src$core$constants,VARIABLE_CATEGORY_NAME$$module$build$src$core$blockly=CATEGORY_NAME$$module$build$src$core$variables,VARIABLE_DYNAMIC_CATEGORY_NAME$$module$build$src$core$blockly=
+CATEGORY_NAME$$module$build$src$core$variables_dynamic,PROCEDURE_CATEGORY_NAME$$module$build$src$core$blockly=CATEGORY_NAME$$module$build$src$core$procedures;Workspace$$module$build$src$core$workspace.prototype.newBlock=function(a,b){return new Block$$module$build$src$core$block(this,a,b)};WorkspaceSvg$$module$build$src$core$workspace_svg.prototype.newBlock=function(a,b){return new BlockSvg$$module$build$src$core$block_svg(this,a,b)};WorkspaceSvg$$module$build$src$core$workspace_svg.newTrashcan=function(a){return new Trashcan$$module$build$src$core$trashcan(a)};
+WorkspaceCommentSvg$$module$build$src$core$workspace_comment_svg.prototype.showContextMenu=function(a){if(!this.workspace.options.readOnly){var b=[];this.isDeletable()&&this.isMovable()&&(b.push(commentDuplicateOption$$module$build$src$core$contextmenu(this)),b.push(commentDeleteOption$$module$build$src$core$contextmenu(this)));show$$module$build$src$core$contextmenu(a,b,this.RTL)}};MiniWorkspaceBubble$$module$build$src$core$bubbles$mini_workspace_bubble.prototype.newWorkspaceSvg=function(a){return new WorkspaceSvg$$module$build$src$core$workspace_svg(a)};
+$.Names$$module$build$src$core$names.prototype.populateProcedures=function(a){a=allProcedures$$module$build$src$core$procedures(a);a=a[0].concat(a[1]);for(let b=0;b AnyDuringMigration) | AnyDuringMigration;\n  };\n} = Object.create(null);\nexport const TEST_ONLY = {typeMap};\n\n/**\n * A map of maps. With the keys being the type and caseless name of the class we\n * are registring, and the value being the most recent cased name for that\n * registration.\n */\nconst nameMap: {[key: string]: {[key: string]: string}} = Object.create(null);\n\n/**\n * The string used to register the default class for a type of plugin.\n */\nexport const DEFAULT = 'default';\n\n/**\n * A name with the type of the element stored in the generic.\n */\nexport class Type<_T> {\n  /** @param name The name of the registry type. */\n  constructor(private readonly name: string) {}\n\n  /**\n   * Returns the name of the type.\n   *\n   * @returns The name.\n   */\n  toString(): string {\n    return this.name;\n  }\n\n  static CONNECTION_CHECKER = new Type('connectionChecker');\n\n  static CURSOR = new Type('cursor');\n\n  static EVENT = new Type('event');\n\n  static FIELD = new Type('field');\n\n  static INPUT = new Type('input');\n\n  static RENDERER = new Type('renderer');\n\n  static TOOLBOX = new Type('toolbox');\n\n  static THEME = new Type('theme');\n\n  static TOOLBOX_ITEM = new Type('toolboxItem');\n\n  static FLYOUTS_VERTICAL_TOOLBOX = new Type('flyoutsVerticalToolbox');\n\n  static FLYOUTS_HORIZONTAL_TOOLBOX = new Type(\n    'flyoutsHorizontalToolbox',\n  );\n\n  static METRICS_MANAGER = new Type('metricsManager');\n\n  static BLOCK_DRAGGER = new Type('blockDragger');\n\n  /** @internal */\n  static SERIALIZER = new Type('serializer');\n\n  /** @internal */\n  static ICON = new Type('icon');\n\n  /** @internal */\n  static PASTER = new Type>>('paster');\n}\n\n/**\n * Registers a class based on a type and name.\n *\n * @param type The type of the plugin.\n *     (e.g. Field, Renderer)\n * @param name The plugin's name. (Ex. field_angle, geras)\n * @param registryItem The class or object to register.\n * @param opt_allowOverrides True to prevent an error when overriding an already\n *     registered item.\n * @throws {Error} if the type or name is empty, a name with the given type has\n *     already been registered, or if the given class or object is not valid for\n *     its type.\n */\nexport function register(\n  type: string | Type,\n  name: string,\n  registryItem:\n    | (new (...p1: AnyDuringMigration[]) => T)\n    | null\n    | AnyDuringMigration,\n  opt_allowOverrides?: boolean,\n): void {\n  if (\n    (!(type instanceof Type) && typeof type !== 'string') ||\n    `${type}`.trim() === ''\n  ) {\n    throw Error(\n      'Invalid type \"' +\n        type +\n        '\". The type must be a' +\n        ' non-empty string or a Blockly.registry.Type.',\n    );\n  }\n  type = `${type}`.toLowerCase();\n\n  if (typeof name !== 'string' || name.trim() === '') {\n    throw Error(\n      'Invalid name \"' + name + '\". The name must be a' + ' non-empty string.',\n    );\n  }\n  const caselessName = name.toLowerCase();\n  if (!registryItem) {\n    throw Error('Can not register a null value');\n  }\n  let typeRegistry = typeMap[type];\n  let nameRegistry = nameMap[type];\n  // If the type registry has not been created, create it.\n  if (!typeRegistry) {\n    typeRegistry = typeMap[type] = Object.create(null);\n    nameRegistry = nameMap[type] = Object.create(null);\n  }\n\n  // Validate that the given class has all the required properties.\n  validate(type, registryItem);\n\n  // Don't throw an error if opt_allowOverrides is true.\n  if (!opt_allowOverrides && typeRegistry[caselessName]) {\n    throw Error(\n      'Name \"' +\n        caselessName +\n        '\" with type \"' +\n        type +\n        '\" already registered.',\n    );\n  }\n  typeRegistry[caselessName] = registryItem;\n  nameRegistry[caselessName] = name;\n}\n\n/**\n * Checks the given registry item for properties that are required based on the\n * type.\n *\n * @param type The type of the plugin. (e.g. Field, Renderer)\n * @param registryItem A class or object that we are checking for the required\n *     properties.\n */\nfunction validate(type: string, registryItem: Function | AnyDuringMigration) {\n  switch (type) {\n    case String(Type.FIELD):\n      if (typeof registryItem.fromJson !== 'function') {\n        throw Error('Type \"' + type + '\" must have a fromJson function');\n      }\n      break;\n  }\n}\n\n/**\n * Unregisters the registry item with the given type and name.\n *\n * @param type The type of the plugin.\n *     (e.g. Field, Renderer)\n * @param name The plugin's name. (Ex. field_angle, geras)\n */\nexport function unregister(type: string | Type, name: string) {\n  type = `${type}`.toLowerCase();\n  name = name.toLowerCase();\n  const typeRegistry = typeMap[type];\n  if (!typeRegistry || !typeRegistry[name]) {\n    console.warn(\n      'Unable to unregister [' +\n        name +\n        '][' +\n        type +\n        '] from the ' +\n        'registry.',\n    );\n    return;\n  }\n  delete typeMap[type][name];\n  delete nameMap[type][name];\n}\n\n/**\n * Gets the registry item for the given name and type. This can be either a\n * class or an object.\n *\n * @param type The type of the plugin.\n *     (e.g. Field, Renderer)\n * @param name The plugin's name. (Ex. field_angle, geras)\n * @param opt_throwIfMissing Whether or not to throw an error if we are unable\n *     to find the plugin.\n * @returns The class or object with the given name and type or null if none\n *     exists.\n */\nfunction getItem(\n  type: string | Type,\n  name: string,\n  opt_throwIfMissing?: boolean,\n): (new (...p1: AnyDuringMigration[]) => T) | null | AnyDuringMigration {\n  type = `${type}`.toLowerCase();\n  name = name.toLowerCase();\n  const typeRegistry = typeMap[type];\n  if (!typeRegistry || !typeRegistry[name]) {\n    const msg = 'Unable to find [' + name + '][' + type + '] in the registry.';\n    if (opt_throwIfMissing) {\n      throw new Error(\n        msg + ' You must require or register a ' + type + ' plugin.',\n      );\n    } else {\n      console.warn(msg);\n    }\n    return null;\n  }\n  return typeRegistry[name];\n}\n\n/**\n * Returns whether or not the registry contains an item with the given type and\n * name.\n *\n * @param type The type of the plugin.\n *     (e.g. Field, Renderer)\n * @param name The plugin's name. (Ex. field_angle, geras)\n * @returns True if the registry has an item with the given type and name, false\n *     otherwise.\n */\nexport function hasItem(type: string | Type, name: string): boolean {\n  type = `${type}`.toLowerCase();\n  name = name.toLowerCase();\n  const typeRegistry = typeMap[type];\n  if (!typeRegistry) {\n    return false;\n  }\n  return !!typeRegistry[name];\n}\n\n/**\n * Gets the class for the given name and type.\n *\n * @param type The type of the plugin.\n *     (e.g. Field, Renderer)\n * @param name The plugin's name. (Ex. field_angle, geras)\n * @param opt_throwIfMissing Whether or not to throw an error if we are unable\n *     to find the plugin.\n * @returns The class with the given name and type or null if none exists.\n */\nexport function getClass(\n  type: string | Type,\n  name: string,\n  opt_throwIfMissing?: boolean,\n): (new (...p1: AnyDuringMigration[]) => T) | null {\n  return getItem(type, name, opt_throwIfMissing) as\n    | (new (...p1: AnyDuringMigration[]) => T)\n    | null;\n}\n\n/**\n * Gets the object for the given name and type.\n *\n * @param type The type of the plugin.\n *     (e.g. Category)\n * @param name The plugin's name. (Ex. logic_category)\n * @param opt_throwIfMissing Whether or not to throw an error if we are unable\n *     to find the object.\n * @returns The object with the given name and type or null if none exists.\n */\nexport function getObject(\n  type: string | Type,\n  name: string,\n  opt_throwIfMissing?: boolean,\n): T | null {\n  return getItem(type, name, opt_throwIfMissing) as T;\n}\n\n/**\n * Returns a map of items registered with the given type.\n *\n * @param type The type of the plugin. (e.g. Category)\n * @param opt_cased Whether or not to return a map with cased keys (rather than\n *     caseless keys). False by default.\n * @param opt_throwIfMissing Whether or not to throw an error if we are unable\n *     to find the object. False by default.\n * @returns A map of objects with the given type, or null if none exists.\n */\nexport function getAllItems(\n  type: string | Type,\n  opt_cased?: boolean,\n  opt_throwIfMissing?: boolean,\n): {[key: string]: T | null | (new (...p1: AnyDuringMigration[]) => T)} | null {\n  type = `${type}`.toLowerCase();\n  const typeRegistry = typeMap[type];\n  if (!typeRegistry) {\n    const msg = `Unable to find [${type}] in the registry.`;\n    if (opt_throwIfMissing) {\n      throw new Error(`${msg} You must require or register a ${type} plugin.`);\n    } else {\n      console.warn(msg);\n    }\n    return null;\n  }\n  if (!opt_cased) {\n    return typeRegistry;\n  }\n  const nameRegistry = nameMap[type];\n  const casedRegistry = Object.create(null);\n  for (const key of Object.keys(typeRegistry)) {\n    casedRegistry[nameRegistry[key]] = typeRegistry[key];\n  }\n  return casedRegistry;\n}\n\n/**\n * Gets the class from Blockly options for the given type.\n * This is used for plugins that override a built in feature. (e.g. Toolbox)\n *\n * @param type The type of the plugin.\n * @param options The option object to check for the given plugin.\n * @param opt_throwIfMissing Whether or not to throw an error if we are unable\n *     to find the plugin.\n * @returns The class for the plugin.\n */\nexport function getClassFromOptions(\n  type: Type,\n  options: Options,\n  opt_throwIfMissing?: boolean,\n): (new (...p1: AnyDuringMigration[]) => T) | null {\n  const plugin = options.plugins[String(type)] || DEFAULT;\n\n  // If the user passed in a plugin class instead of a registered plugin name.\n  if (typeof plugin === 'function') {\n    return plugin;\n  }\n  return getClass(type, plugin, opt_throwIfMissing);\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.common\n\n/* eslint-disable-next-line no-unused-vars */\nimport type {Block} from './block.js';\nimport {ISelectable} from './blockly.js';\nimport {BlockDefinition, Blocks} from './blocks.js';\nimport type {Connection} from './connection.js';\nimport type {Workspace} from './workspace.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\n\n/** Database of all workspaces. */\nconst WorkspaceDB_ = Object.create(null);\n\n/**\n * Find the workspace with the specified ID.\n *\n * @param id ID of workspace to find.\n * @returns The sought after workspace or null if not found.\n */\nexport function getWorkspaceById(id: string): Workspace | null {\n  return WorkspaceDB_[id] || null;\n}\n\n/**\n * Find all workspaces.\n *\n * @returns Array of workspaces.\n */\nexport function getAllWorkspaces(): Workspace[] {\n  const workspaces = [];\n  for (const workspaceId in WorkspaceDB_) {\n    workspaces.push(WorkspaceDB_[workspaceId]);\n  }\n  return workspaces;\n}\n\n/**\n * Register a workspace in the workspace db.\n *\n * @param workspace\n */\nexport function registerWorkspace(workspace: Workspace) {\n  WorkspaceDB_[workspace.id] = workspace;\n}\n\n/**\n * Unregister a workspace from the workspace db.\n *\n * @param workspace\n */\nexport function unregisterWorkpace(workspace: Workspace) {\n  delete WorkspaceDB_[workspace.id];\n}\n\n/**\n * The main workspace most recently used.\n * Set by Blockly.WorkspaceSvg.prototype.markFocused\n */\nlet mainWorkspace: Workspace;\n\n/**\n * Returns the last used top level workspace (based on focus).  Try not to use\n * this function, particularly if there are multiple Blockly instances on a\n * page.\n *\n * @returns The main workspace.\n */\nexport function getMainWorkspace(): Workspace {\n  return mainWorkspace;\n}\n\n/**\n * Sets last used main workspace.\n *\n * @param workspace The most recently used top level workspace.\n */\nexport function setMainWorkspace(workspace: Workspace) {\n  mainWorkspace = workspace;\n}\n\n/**\n * Currently selected copyable object.\n */\nlet selected: ISelectable | null = null;\n\n/**\n * Returns the currently selected copyable object.\n */\nexport function getSelected(): ISelectable | null {\n  return selected;\n}\n\n/**\n * Sets the currently selected block. This function does not visually mark the\n * block as selected or fire the required events. If you wish to\n * programmatically select a block, use `BlockSvg#select`.\n *\n * @param newSelection The newly selected block.\n * @internal\n */\nexport function setSelected(newSelection: ISelectable | null) {\n  selected = newSelection;\n}\n\n/**\n * Container element in which to render the WidgetDiv, DropDownDiv and Tooltip.\n */\nlet parentContainer: Element | null;\n\n/**\n * Get the container element in which to render the WidgetDiv, DropDownDiv and\n * Tooltip.\n *\n * @returns The parent container.\n */\nexport function getParentContainer(): Element | null {\n  return parentContainer;\n}\n\n/**\n * Set the parent container.  This is the container element that the WidgetDiv,\n * DropDownDiv, and Tooltip are rendered into the first time `Blockly.inject`\n * is called.\n * This method is a NOP if called after the first `Blockly.inject`.\n *\n * @param newParent The container element.\n */\nexport function setParentContainer(newParent: Element) {\n  parentContainer = newParent;\n}\n\n/**\n * Size the SVG image to completely fill its container. Call this when the view\n * actually changes sizes (e.g. on a window resize/device orientation change).\n * See workspace.resizeContents to resize the workspace when the contents\n * change (e.g. when a block is added or removed).\n * Record the height/width of the SVG image.\n *\n * @param workspace Any workspace in the SVG.\n */\nexport function svgResize(workspace: WorkspaceSvg) {\n  let mainWorkspace = workspace;\n  while (mainWorkspace.options.parentWorkspace) {\n    mainWorkspace = mainWorkspace.options.parentWorkspace;\n  }\n  const svg = mainWorkspace.getParentSvg();\n  const cachedSize = mainWorkspace.getCachedParentSvgSize();\n  const div = svg.parentElement;\n  if (!(div instanceof HTMLElement)) {\n    // Workspace deleted, or something.\n    return;\n  }\n\n  const width = div.offsetWidth;\n  const height = div.offsetHeight;\n  if (cachedSize.width !== width) {\n    svg.setAttribute('width', width + 'px');\n    mainWorkspace.setCachedParentSvgSize(width, null);\n  }\n  if (cachedSize.height !== height) {\n    svg.setAttribute('height', height + 'px');\n    mainWorkspace.setCachedParentSvgSize(null, height);\n  }\n  mainWorkspace.resize();\n}\n\n/**\n * All of the connections on blocks that are currently being dragged.\n */\nexport const draggingConnections: Connection[] = [];\n\n/**\n * Get a map of all the block's descendants mapping their type to the number of\n *    children with that type.\n *\n * @param block The block to map.\n * @param opt_stripFollowing Optionally ignore all following\n *    statements (blocks that are not inside a value or statement input\n *    of the block).\n * @returns Map of types to type counts for descendants of the bock.\n */\nexport function getBlockTypeCounts(\n  block: Block,\n  opt_stripFollowing?: boolean,\n): {[key: string]: number} {\n  const typeCountsMap = Object.create(null);\n  const descendants = block.getDescendants(true);\n  if (opt_stripFollowing) {\n    const nextBlock = block.getNextBlock();\n    if (nextBlock) {\n      const index = descendants.indexOf(nextBlock);\n      descendants.splice(index, descendants.length - index);\n    }\n  }\n  for (let i = 0, checkBlock; (checkBlock = descendants[i]); i++) {\n    if (typeCountsMap[checkBlock.type]) {\n      typeCountsMap[checkBlock.type]++;\n    } else {\n      typeCountsMap[checkBlock.type] = 1;\n    }\n  }\n  return typeCountsMap;\n}\n\n/**\n * Helper function for defining a block from JSON.  The resulting function has\n * the correct value of jsonDef at the point in code where jsonInit is called.\n *\n * @param jsonDef The JSON definition of a block.\n * @returns A function that calls jsonInit with the correct value\n *     of jsonDef.\n */\nfunction jsonInitFactory(jsonDef: AnyDuringMigration): () => void {\n  return function (this: Block) {\n    this.jsonInit(jsonDef);\n  };\n}\n\n/**\n * Define blocks from an array of JSON block definitions, as might be generated\n * by the Blockly Developer Tools.\n *\n * @param jsonArray An array of JSON block definitions.\n */\nexport function defineBlocksWithJsonArray(jsonArray: AnyDuringMigration[]) {\n  TEST_ONLY.defineBlocksWithJsonArrayInternal(jsonArray);\n}\n\n/**\n * Private version of defineBlocksWithJsonArray for stubbing in tests.\n */\nfunction defineBlocksWithJsonArrayInternal(jsonArray: AnyDuringMigration[]) {\n  defineBlocks(createBlockDefinitionsFromJsonArray(jsonArray));\n}\n\n/**\n * Define blocks from an array of JSON block definitions, as might be generated\n * by the Blockly Developer Tools.\n *\n * @param jsonArray An array of JSON block definitions.\n * @returns A map of the block\n *     definitions created.\n */\nexport function createBlockDefinitionsFromJsonArray(\n  jsonArray: AnyDuringMigration[],\n): {[key: string]: BlockDefinition} {\n  const blocks: {[key: string]: BlockDefinition} = {};\n  for (let i = 0; i < jsonArray.length; i++) {\n    const elem = jsonArray[i];\n    if (!elem) {\n      console.warn(`Block definition #${i} in JSON array is ${elem}. Skipping`);\n      continue;\n    }\n    const type = elem['type'];\n    if (!type) {\n      console.warn(\n        `Block definition #${i} in JSON array is missing a type attribute. ` +\n          'Skipping.',\n      );\n      continue;\n    }\n    blocks[type] = {init: jsonInitFactory(elem)};\n  }\n  return blocks;\n}\n\n/**\n * Add the specified block definitions to the block definitions\n * dictionary (Blockly.Blocks).\n *\n * @param blocks A map of block\n *     type names to block definitions.\n */\nexport function defineBlocks(blocks: {[key: string]: BlockDefinition}) {\n  // Iterate over own enumerable properties.\n  for (const type of Object.keys(blocks)) {\n    const definition = blocks[type];\n    if (type in Blocks) {\n      console.warn(`Block definiton \"${type}\" overwrites previous definition.`);\n    }\n    Blocks[type] = definition;\n  }\n}\n\nexport const TEST_ONLY = {defineBlocksWithJsonArrayInternal};\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.idGenerator\n\n/**\n * Legal characters for the universally unique IDs.  Should be all on\n * a US keyboard.  No characters that conflict with XML or JSON.\n * Requests to remove additional 'problematic' characters from this\n * soup will be denied.  That's your failure to properly escape in\n * your own environment.  Issues #251, #625, #682, #1304.\n */\nconst soup =\n  '!#$%()*+,-./:;=?@[]^_`{|}~' +\n  'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\n/**\n * Namespace object for internal implementations we want to be able to\n * stub in tests. Do not use externally.\n *\n * @internal\n */\nconst internal = {\n  /**\n   * Generate a random unique ID.  This should be globally unique.\n   * 87 characters ^ 20 length is greater than 128 bits (better than a UUID).\n   *\n   * @returns A globally unique ID string.\n   */\n  genUid: () => {\n    const length = 20;\n    const soupLength = soup.length;\n    const id = [];\n    for (let i = 0; i < length; i++) {\n      id[i] = soup.charAt(Math.random() * soupLength);\n    }\n    return id.join('');\n  },\n};\nexport const TEST_ONLY = internal;\n\n/** Next unique ID to use. */\nlet nextId = 0;\n\n/**\n * Generate the next unique element IDs.\n * IDs are compatible with the HTML4 'id' attribute restrictions:\n * Use only ASCII letters, digits, '_', '-' and '.'\n *\n * For UUIDs use genUid (below) instead; this ID generator should\n * primarily be used for IDs that end up in the DOM.\n *\n * @returns The next unique identifier.\n */\nexport function getNextUniqueId(): string {\n  return 'blockly-' + (nextId++).toString(36);\n}\n\n/**\n * Generate a random unique ID.\n *\n * @see internal.genUid\n * @returns A globally unique ID string.\n */\nexport function genUid(): string {\n  return internal.genUid();\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Events.utils\n\nimport type {Block} from '../block.js';\nimport * as common from '../common.js';\nimport * as registry from '../registry.js';\nimport * as idGenerator from '../utils/idgenerator.js';\nimport type {Workspace} from '../workspace.js';\nimport type {WorkspaceSvg} from '../workspace_svg.js';\n\nimport type {Abstract} from './events_abstract.js';\nimport type {BlockChange} from './events_block_change.js';\nimport type {BlockCreate} from './events_block_create.js';\nimport type {BlockMove} from './events_block_move.js';\nimport type {CommentCreate} from './events_comment_create.js';\nimport type {CommentMove} from './events_comment_move.js';\nimport type {ViewportChange} from './events_viewport.js';\n\n/** Group ID for new events.  Grouped events are indivisible. */\nlet group = '';\n\n/** Sets whether the next event should be added to the undo stack. */\nlet recordUndo = true;\n\n/**\n * Sets whether events should be added to the undo stack.\n *\n * @param newValue True if events should be added to the undo stack.\n */\nexport function setRecordUndo(newValue: boolean) {\n  recordUndo = newValue;\n}\n\n/**\n * Returns whether or not events will be added to the undo stack.\n *\n * @returns True if events will be added to the undo stack.\n */\nexport function getRecordUndo(): boolean {\n  return recordUndo;\n}\n\n/** Allow change events to be created and fired. */\nlet disabled = 0;\n\n/**\n * Name of event that creates a block. Will be deprecated for BLOCK_CREATE.\n */\nexport const CREATE = 'create';\n\n/**\n * Name of event that creates a block.\n */\nexport const BLOCK_CREATE = CREATE;\n\n/**\n * Name of event that deletes a block. Will be deprecated for BLOCK_DELETE.\n */\nexport const DELETE = 'delete';\n\n/**\n * Name of event that deletes a block.\n */\nexport const BLOCK_DELETE = DELETE;\n\n/**\n * Name of event that changes a block. Will be deprecated for BLOCK_CHANGE.\n */\nexport const CHANGE = 'change';\n\n/**\n * Name of event that changes a block.\n */\nexport const BLOCK_CHANGE = CHANGE;\n\n/**\n * Name of event representing an in-progress change to a field of a block, which\n * is expected to be followed by a block change event.\n */\nexport const BLOCK_FIELD_INTERMEDIATE_CHANGE =\n  'block_field_intermediate_change';\n\n/**\n * Name of event that moves a block. Will be deprecated for BLOCK_MOVE.\n */\nexport const MOVE = 'move';\n\n/**\n * Name of event that moves a block.\n */\nexport const BLOCK_MOVE = MOVE;\n\n/**\n * Name of event that creates a variable.\n */\nexport const VAR_CREATE = 'var_create';\n\n/**\n * Name of event that deletes a variable.\n */\nexport const VAR_DELETE = 'var_delete';\n\n/**\n * Name of event that renames a variable.\n */\nexport const VAR_RENAME = 'var_rename';\n\n/**\n * Name of generic event that records a UI change.\n */\nexport const UI = 'ui';\n\n/**\n * Name of event that record a block drags a block.\n */\nexport const BLOCK_DRAG = 'drag';\n\n/**\n * Name of event that records a change in selected element.\n */\nexport const SELECTED = 'selected';\n\n/**\n * Name of event that records a click.\n */\nexport const CLICK = 'click';\n\n/**\n * Name of event that records a marker move.\n */\nexport const MARKER_MOVE = 'marker_move';\n\n/**\n * Name of event that records a bubble open.\n */\nexport const BUBBLE_OPEN = 'bubble_open';\n\n/**\n * Name of event that records a trashcan open.\n */\nexport const TRASHCAN_OPEN = 'trashcan_open';\n\n/**\n * Name of event that records a toolbox item select.\n */\nexport const TOOLBOX_ITEM_SELECT = 'toolbox_item_select';\n\n/**\n * Name of event that records a theme change.\n */\nexport const THEME_CHANGE = 'theme_change';\n\n/**\n * Name of event that records a viewport change.\n */\nexport const VIEWPORT_CHANGE = 'viewport_change';\n\n/**\n * Name of event that creates a comment.\n */\nexport const COMMENT_CREATE = 'comment_create';\n\n/**\n * Name of event that deletes a comment.\n */\nexport const COMMENT_DELETE = 'comment_delete';\n\n/**\n * Name of event that changes a comment.\n */\nexport const COMMENT_CHANGE = 'comment_change';\n\n/**\n * Name of event that moves a comment.\n */\nexport const COMMENT_MOVE = 'comment_move';\n\n/**\n * Name of event that records a workspace load.\n */\nexport const FINISHED_LOADING = 'finished_loading';\n\n/**\n * Type of events that cause objects to be bumped back into the visible\n * portion of the workspace.\n *\n * Not to be confused with bumping so that disconnected connections do not\n * appear connected.\n */\nexport type BumpEvent = BlockCreate | BlockMove | CommentCreate | CommentMove;\n\n/**\n * List of events that cause objects to be bumped back into the visible\n * portion of the workspace.\n *\n * Not to be confused with bumping so that disconnected connections do not\n * appear connected.\n */\nexport const BUMP_EVENTS: string[] = [\n  BLOCK_CREATE,\n  BLOCK_MOVE,\n  COMMENT_CREATE,\n  COMMENT_MOVE,\n];\n\n/** List of events queued for firing. */\nconst FIRE_QUEUE: Abstract[] = [];\n\n/**\n * Create a custom event and fire it.\n *\n * @param event Custom data for event.\n */\nexport function fire(event: Abstract) {\n  TEST_ONLY.fireInternal(event);\n}\n\n/**\n * Private version of fireInternal for stubbing in tests.\n */\nfunction fireInternal(event: Abstract) {\n  if (!isEnabled()) {\n    return;\n  }\n  if (!FIRE_QUEUE.length) {\n    // First event added; schedule a firing of the event queue.\n    try {\n      // If we are in a browser context, we want to make sure that the event\n      // fires after blocks have been rerendered this frame.\n      requestAnimationFrame(() => {\n        setTimeout(fireNow, 0);\n      });\n    } catch (e) {\n      // Otherwise we just want to delay so events can be coallesced.\n      // requestAnimationFrame will error triggering this.\n      setTimeout(fireNow, 0);\n    }\n  }\n  FIRE_QUEUE.push(event);\n}\n\n/** Fire all queued events. */\nfunction fireNow() {\n  const queue = filter(FIRE_QUEUE, true);\n  FIRE_QUEUE.length = 0;\n  for (let i = 0, event; (event = queue[i]); i++) {\n    if (!event.workspaceId) {\n      continue;\n    }\n    const eventWorkspace = common.getWorkspaceById(event.workspaceId);\n    if (eventWorkspace) {\n      eventWorkspace.fireChangeListener(event);\n    }\n  }\n\n  // Post-filter the undo stack to squash and remove any events that result in\n  // a null event\n\n  // 1. Determine which workspaces will need to have their undo stacks validated\n  const workspaceIds = new Set(queue.map((e) => e.workspaceId));\n  for (const workspaceId of workspaceIds) {\n    // Only process valid workspaces\n    if (!workspaceId) {\n      continue;\n    }\n    const eventWorkspace = common.getWorkspaceById(workspaceId);\n    if (!eventWorkspace) {\n      continue;\n    }\n\n    // Find the last contiguous group of events on the stack\n    const undoStack = eventWorkspace.getUndoStack();\n    let i;\n    let group: string | undefined = undefined;\n    for (i = undoStack.length; i > 0; i--) {\n      const event = undoStack[i - 1];\n      if (event.group === '') {\n        break;\n      } else if (group === undefined) {\n        group = event.group;\n      } else if (event.group !== group) {\n        break;\n      }\n    }\n    if (!group || i == undoStack.length - 1) {\n      // Need a group of two or more events on the stack. Nothing to do here.\n      continue;\n    }\n\n    // Extract the event group, filter, and add back to the undo stack\n    let events = undoStack.splice(i, undoStack.length - i);\n    events = filter(events, true);\n    undoStack.push(...events);\n  }\n}\n\n/**\n * Filter the queued events and merge duplicates.\n *\n * @param queueIn Array of events.\n * @param forward True if forward (redo), false if backward (undo).\n * @returns Array of filtered events.\n */\nexport function filter(queueIn: Abstract[], forward: boolean): Abstract[] {\n  let queue = queueIn.slice();\n  // Shallow copy of queue.\n  if (!forward) {\n    // Undo is merged in reverse order.\n    queue.reverse();\n  }\n  const mergedQueue = [];\n  const hash = Object.create(null);\n  // Merge duplicates.\n  for (let i = 0, event; (event = queue[i]); i++) {\n    if (!event.isNull()) {\n      // Treat all UI events as the same type in hash table.\n      const eventType = event.isUiEvent ? UI : event.type;\n      // TODO(#5927): Check whether `blockId` exists before accessing it.\n      const blockId = (event as AnyDuringMigration).blockId;\n      const key = [eventType, blockId, event.workspaceId].join(' ');\n\n      const lastEntry = hash[key];\n      const lastEvent = lastEntry ? lastEntry.event : null;\n      if (!lastEntry) {\n        // Each item in the hash table has the event and the index of that event\n        // in the input array.  This lets us make sure we only merge adjacent\n        // move events.\n        hash[key] = {event, index: i};\n        mergedQueue.push(event);\n      } else if (event.type === MOVE && lastEntry.index === i - 1) {\n        const moveEvent = event as BlockMove;\n        // Merge move events.\n        lastEvent.newParentId = moveEvent.newParentId;\n        lastEvent.newInputName = moveEvent.newInputName;\n        lastEvent.newCoordinate = moveEvent.newCoordinate;\n        if (moveEvent.reason) {\n          if (lastEvent.reason) {\n            // Concatenate reasons without duplicates.\n            const reasonSet = new Set(\n              moveEvent.reason.concat(lastEvent.reason),\n            );\n            lastEvent.reason = Array.from(reasonSet);\n          } else {\n            lastEvent.reason = moveEvent.reason;\n          }\n        }\n        lastEntry.index = i;\n      } else if (\n        event.type === CHANGE &&\n        (event as BlockChange).element === lastEvent.element &&\n        (event as BlockChange).name === lastEvent.name\n      ) {\n        const changeEvent = event as BlockChange;\n        // Merge change events.\n        lastEvent.newValue = changeEvent.newValue;\n      } else if (event.type === VIEWPORT_CHANGE) {\n        const viewportEvent = event as ViewportChange;\n        // Merge viewport change events.\n        lastEvent.viewTop = viewportEvent.viewTop;\n        lastEvent.viewLeft = viewportEvent.viewLeft;\n        lastEvent.scale = viewportEvent.scale;\n        lastEvent.oldScale = viewportEvent.oldScale;\n      } else if (event.type === CLICK && lastEvent.type === BUBBLE_OPEN) {\n        // Drop click events caused by opening/closing bubbles.\n      } else {\n        // Collision: newer events should merge into this event to maintain\n        // order.\n        hash[key] = {event, index: i};\n        mergedQueue.push(event);\n      }\n    }\n  }\n  // Filter out any events that have become null due to merging.\n  queue = mergedQueue.filter(function (e) {\n    return !e.isNull();\n  });\n  if (!forward) {\n    // Restore undo order.\n    queue.reverse();\n  }\n  // Move mutation events to the top of the queue.\n  // Intentionally skip first event.\n  for (let i = 1, event; (event = queue[i]); i++) {\n    // AnyDuringMigration because:  Property 'element' does not exist on type\n    // 'Abstract'.\n    if (\n      event.type === CHANGE &&\n      (event as AnyDuringMigration).element === 'mutation'\n    ) {\n      queue.unshift(queue.splice(i, 1)[0]);\n    }\n  }\n  return queue;\n}\n\n/**\n * Modify pending undo events so that when they are fired they don't land\n * in the undo stack.  Called by Workspace.clearUndo.\n */\nexport function clearPendingUndo() {\n  for (let i = 0, event; (event = FIRE_QUEUE[i]); i++) {\n    event.recordUndo = false;\n  }\n}\n\n/**\n * Stop sending events.  Every call to this function MUST also call enable.\n */\nexport function disable() {\n  disabled++;\n}\n\n/**\n * Start sending events.  Unless events were already disabled when the\n * corresponding call to disable was made.\n */\nexport function enable() {\n  disabled--;\n}\n\n/**\n * Returns whether events may be fired or not.\n *\n * @returns True if enabled.\n */\nexport function isEnabled(): boolean {\n  return disabled === 0;\n}\n\n/**\n * Current group.\n *\n * @returns ID string.\n */\nexport function getGroup(): string {\n  return group;\n}\n\n/**\n * Start or stop a group.\n *\n * @param state True to start new group, false to end group.\n *   String to set group explicitly.\n */\nexport function setGroup(state: boolean | string) {\n  TEST_ONLY.setGroupInternal(state);\n}\n\n/**\n * Private version of setGroup for stubbing in tests.\n */\nfunction setGroupInternal(state: boolean | string) {\n  if (typeof state === 'boolean') {\n    group = state ? idGenerator.genUid() : '';\n  } else {\n    group = state;\n  }\n}\n\n/**\n * Compute a list of the IDs of the specified block and all its descendants.\n *\n * @param block The root block.\n * @returns List of block IDs.\n * @internal\n */\nexport function getDescendantIds(block: Block): string[] {\n  const ids = [];\n  const descendants = block.getDescendants(false);\n  for (let i = 0, descendant; (descendant = descendants[i]); i++) {\n    ids[i] = descendant.id;\n  }\n  return ids;\n}\n\n/**\n * Decode the JSON into an event.\n *\n * @param json JSON representation.\n * @param workspace Target workspace for event.\n * @returns The event represented by the JSON.\n * @throws {Error} if an event type is not found in the registry.\n */\nexport function fromJson(\n  json: AnyDuringMigration,\n  workspace: Workspace,\n): Abstract {\n  const eventClass = get(json['type']);\n  if (!eventClass) throw Error('Unknown event type.');\n\n  return (eventClass as any).fromJson(json, workspace);\n}\n\n/**\n * Gets the class for a specific event type from the registry.\n *\n * @param eventType The type of the event to get.\n * @returns The event class with the given type.\n */\nexport function get(\n  eventType: string,\n): new (...p1: AnyDuringMigration[]) => Abstract {\n  const event = registry.getClass(registry.Type.EVENT, eventType);\n  if (!event) {\n    throw new Error(`Event type ${eventType} not found in registry.`);\n  }\n  return event;\n}\n\n/**\n * Enable/disable a block depending on whether it is properly connected.\n * Use this on applications where all blocks should be connected to a top block.\n * Recommend setting the 'disable' option to 'false' in the config so that\n * users don't try to re-enable disabled orphan blocks.\n *\n * @param event Custom data for event.\n */\nexport function disableOrphans(event: Abstract) {\n  if (event.type === MOVE || event.type === CREATE) {\n    const blockEvent = event as BlockMove | BlockCreate;\n    if (!blockEvent.workspaceId) {\n      return;\n    }\n    const eventWorkspace = common.getWorkspaceById(\n      blockEvent.workspaceId,\n    ) as WorkspaceSvg;\n    if (!blockEvent.blockId) {\n      throw new Error('Encountered a blockEvent without a proper blockId');\n    }\n    let block = eventWorkspace.getBlockById(blockEvent.blockId);\n    if (block) {\n      // Changing blocks as part of this event shouldn't be undoable.\n      const initialUndoFlag = recordUndo;\n      try {\n        recordUndo = false;\n        const parent = block.getParent();\n        if (parent && parent.isEnabled()) {\n          const children = block.getDescendants(false);\n          for (let i = 0, child; (child = children[i]); i++) {\n            child.setEnabled(true);\n          }\n        } else if (\n          (block.outputConnection || block.previousConnection) &&\n          !eventWorkspace.isDragging()\n        ) {\n          do {\n            block.setEnabled(false);\n            block = block.getNextBlock();\n          } while (block);\n        }\n      } finally {\n        recordUndo = initialUndoFlag;\n      }\n    }\n  }\n}\n\nexport const TEST_ONLY = {\n  FIRE_QUEUE,\n  fireNow,\n  fireInternal,\n  setGroupInternal,\n};\n","/**\n * @license\n * Copyright 2016 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Touch\n\nimport type {Gesture} from './gesture.js';\n\n/** Length in ms for a touch to become a long press. */\nconst LONGPRESS = 750;\n\n/**\n * Whether touch is enabled in the browser.\n * Copied from Closure's goog.events.BrowserFeature.TOUCH_ENABLED\n */\nexport const TOUCH_ENABLED =\n  'ontouchstart' in globalThis ||\n  !!(\n    globalThis['document'] &&\n    document.documentElement &&\n    'ontouchstart' in document.documentElement\n  ) || // IE10 uses non-standard touch events,\n  // so it has a different check.\n  !!(\n    globalThis['navigator'] &&\n    (globalThis['navigator']['maxTouchPoints'] ||\n      (globalThis['navigator'] as any)['msMaxTouchPoints'])\n  );\n\n/** Which touch events are we currently paying attention to? */\nlet touchIdentifier_: string | null = null;\n\n/**\n * The TOUCH_MAP lookup dictionary specifies additional touch events to fire,\n * in conjunction with mouse events.\n */\nexport const TOUCH_MAP: {[key: string]: string[]} = {\n  'mousedown': ['pointerdown'],\n  'mouseenter': ['pointerenter'],\n  'mouseleave': ['pointerleave'],\n  'mousemove': ['pointermove'],\n  'mouseout': ['pointerout'],\n  'mouseover': ['pointerover'],\n  'mouseup': ['pointerup', 'pointercancel'],\n  'touchend': ['pointerup'],\n  'touchcancel': ['pointercancel'],\n};\n\n/** PID of queued long-press task. */\nlet longPid_: AnyDuringMigration = 0;\n\n/**\n * Context menus on touch devices are activated using a long-press.\n * Unfortunately the contextmenu touch event is currently (2015) only supported\n * by Chrome.  This function is fired on any touchstart event, queues a task,\n * which after about a second opens the context menu.  The tasks is killed\n * if the touch event terminates early.\n *\n * @param e Touch start event.\n * @param gesture The gesture that triggered this longStart.\n * @internal\n */\nexport function longStart(e: PointerEvent, gesture: Gesture) {\n  longStop();\n  longPid_ = setTimeout(function () {\n    // Let the gesture route the right-click correctly.\n    if (gesture) {\n      gesture.handleRightClick(e);\n    }\n  }, LONGPRESS);\n}\n\n/**\n * Nope, that's not a long-press.  Either touchend or touchcancel was fired,\n * or a drag hath begun.  Kill the queued long-press task.\n *\n * @internal\n */\nexport function longStop() {\n  if (longPid_) {\n    clearTimeout(longPid_);\n    longPid_ = 0;\n  }\n}\n\n/**\n * Clear the touch identifier that tracks which touch stream to pay attention\n * to.  This ends the current drag/gesture and allows other pointers to be\n * captured.\n */\nexport function clearTouchIdentifier() {\n  touchIdentifier_ = null;\n}\n\n/**\n * Decide whether Blockly should handle or ignore this event.\n * Mouse and touch events require special checks because we only want to deal\n * with one touch stream at a time.  All other events should always be handled.\n *\n * @param e The event to check.\n * @returns True if this event should be passed through to the registered\n *     handler; false if it should be blocked.\n */\nexport function shouldHandleEvent(e: Event): boolean {\n  // Do not replace the startsWith with a check for `instanceof PointerEvent`.\n  // `click` and `contextmenu` are PointerEvents in some browsers,\n  // despite not starting with `pointer`, but we want to always handle them\n  // without worrying about touch identifiers.\n  return (\n    !e.type.startsWith('pointer') ||\n    (e instanceof PointerEvent && checkTouchIdentifier(e))\n  );\n}\n\n/**\n * Get the pointer identifier from the given event.\n *\n * @param e Pointer event.\n * @returns The pointerId of the event.\n */\nexport function getTouchIdentifierFromEvent(e: PointerEvent): string {\n  return `${e.pointerId}`;\n}\n\n/**\n * Check whether the pointer identifier on the event matches the current saved\n * identifier. If the current identifier was unset, save the identifier from\n * the event. This starts a drag/gesture, during which pointer events with\n * other identifiers will be silently ignored.\n *\n * @param e Pointer event.\n * @returns Whether the identifier on the event matches the current saved\n *     identifier.\n */\nexport function checkTouchIdentifier(e: PointerEvent): boolean {\n  const identifier = getTouchIdentifierFromEvent(e);\n\n  if (touchIdentifier_) {\n    // We're already tracking some touch/mouse event.  Is this from the same\n    // source?\n    return touchIdentifier_ === identifier;\n  }\n  if (e.type === 'pointerdown') {\n    // No identifier set yet, and this is the start of a drag.  Set it and\n    // return.\n    touchIdentifier_ = identifier;\n    return true;\n  }\n  // There was no identifier yet, but this wasn't a start event so we're going\n  // to ignore it.  This probably means that another drag finished while this\n  // pointer was down.\n  return false;\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.browserEvents\n\nimport * as Touch from './touch.js';\nimport * as userAgent from './utils/useragent.js';\n\n/**\n * Blockly opaque event data used to unbind events when using\n * `bind` and `conditionalBind`.\n */\nexport type Data = [EventTarget, string, (e: Event) => void][];\n\n/**\n * The multiplier for scroll wheel deltas using the line delta mode.\n * See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode\n * for more information on deltaMode.\n */\nconst LINE_MODE_MULTIPLIER = 40;\n\n/**\n * The multiplier for scroll wheel deltas using the page delta mode.\n * See https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode\n * for more information on deltaMode.\n */\nconst PAGE_MODE_MULTIPLIER = 125;\n\n/**\n * Bind an event handler that can be ignored if it is not part of the active\n * touch stream.\n * Use this for events that either start or continue a multi-part gesture (e.g.\n * mousedown or mousemove, which may be part of a drag or click).\n *\n * @param node Node upon which to listen.\n * @param name Event name to listen to (e.g. 'mousedown').\n * @param thisObject The value of 'this' in the function.\n * @param func Function to call when event is triggered.\n * @param opt_noCaptureIdentifier True if triggering on this event should not\n *     block execution of other event handlers on this touch or other\n *     simultaneous touches.  False by default.\n * @returns Opaque data that can be passed to unbindEvent_.\n */\nexport function conditionalBind(\n  node: EventTarget,\n  name: string,\n  thisObject: Object | null,\n  func: Function,\n  opt_noCaptureIdentifier?: boolean,\n): Data {\n  /**\n   *\n   * @param e\n   */\n  function wrapFunc(e: Event) {\n    const captureIdentifier = !opt_noCaptureIdentifier;\n\n    if (!(captureIdentifier && !Touch.shouldHandleEvent(e))) {\n      if (thisObject) {\n        func.call(thisObject, e);\n      } else {\n        func(e);\n      }\n    }\n  }\n\n  const bindData: Data = [];\n  if (name in Touch.TOUCH_MAP) {\n    for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) {\n      const type = Touch.TOUCH_MAP[name][i];\n      node.addEventListener(type, wrapFunc, false);\n      bindData.push([node, type, wrapFunc]);\n    }\n  } else {\n    node.addEventListener(name, wrapFunc, false);\n    bindData.push([node, name, wrapFunc]);\n  }\n  return bindData;\n}\n\n/**\n * Bind an event handler that should be called regardless of whether it is part\n * of the active touch stream.\n * Use this for events that are not part of a multi-part gesture (e.g.\n * mouseover for tooltips).\n *\n * @param node Node upon which to listen.\n * @param name Event name to listen to (e.g. 'mousedown').\n * @param thisObject The value of 'this' in the function.\n * @param func Function to call when event is triggered.\n * @returns Opaque data that can be passed to unbindEvent_.\n */\nexport function bind(\n  node: EventTarget,\n  name: string,\n  thisObject: Object | null,\n  func: Function,\n): Data {\n  /**\n   *\n   * @param e\n   */\n  function wrapFunc(e: Event) {\n    if (thisObject) {\n      func.call(thisObject, e);\n    } else {\n      func(e);\n    }\n  }\n\n  const bindData: Data = [];\n  if (name in Touch.TOUCH_MAP) {\n    for (let i = 0; i < Touch.TOUCH_MAP[name].length; i++) {\n      const type = Touch.TOUCH_MAP[name][i];\n      node.addEventListener(type, wrapFunc, false);\n      bindData.push([node, type, wrapFunc]);\n    }\n  } else {\n    node.addEventListener(name, wrapFunc, false);\n    bindData.push([node, name, wrapFunc]);\n  }\n  return bindData;\n}\n\n/**\n * Unbind one or more events event from a function call.\n *\n * @param bindData Opaque data from bindEvent_.\n *     This list is emptied during the course of calling this function.\n * @returns The function call.\n */\nexport function unbind(bindData: Data): (e: Event) => void {\n  // Accessing an element of the last property of the array is unsafe if the\n  // bindData is an empty array. But that should never happen because developers\n  // should only pass Data from bind or conditionalBind.\n  const callback = bindData[bindData.length - 1][2];\n  while (bindData.length) {\n    const [node, name, func] = bindData.pop()!;\n    node.removeEventListener(name, func, false);\n  }\n  return callback;\n}\n\n/**\n * Returns true if this event is targeting a text input widget?\n *\n * @param e An event.\n * @returns True if text input.\n */\nexport function isTargetInput(e: Event): boolean {\n  if (e.target instanceof HTMLElement) {\n    if (\n      e.target.isContentEditable ||\n      e.target.getAttribute('data-is-text-input') === 'true'\n    ) {\n      return true;\n    }\n\n    if (e.target instanceof HTMLInputElement) {\n      const target = e.target;\n      return (\n        target.type === 'text' ||\n        target.type === 'number' ||\n        target.type === 'email' ||\n        target.type === 'password' ||\n        target.type === 'search' ||\n        target.type === 'tel' ||\n        target.type === 'url'\n      );\n    }\n\n    if (e.target instanceof HTMLTextAreaElement) {\n      return true;\n    }\n  }\n\n  return false;\n}\n\n/**\n * Returns true this event is a right-click.\n *\n * @param e Mouse event.\n * @returns True if right-click.\n */\nexport function isRightButton(e: MouseEvent): boolean {\n  if (e.ctrlKey && userAgent.MAC) {\n    // Control-clicking on Mac OS X is treated as a right-click.\n    // WebKit on Mac OS X fails to change button to 2 (but Gecko does).\n    return true;\n  }\n  return e.button === 2;\n}\n\n/**\n * Returns the converted coordinates of the given mouse event.\n * The origin (0,0) is the top-left corner of the Blockly SVG.\n *\n * @param e Mouse event.\n * @param svg SVG element.\n * @param matrix Inverted screen CTM to use.\n * @returns Object with .x and .y properties.\n */\nexport function mouseToSvg(\n  e: MouseEvent,\n  svg: SVGSVGElement,\n  matrix: SVGMatrix | null,\n): SVGPoint {\n  const svgPoint = svg.createSVGPoint();\n  svgPoint.x = e.clientX;\n  svgPoint.y = e.clientY;\n\n  if (!matrix) {\n    matrix = svg.getScreenCTM()!.inverse();\n  }\n  return svgPoint.matrixTransform(matrix);\n}\n\n/**\n * Returns the scroll delta of a mouse event in pixel units.\n *\n * @param e Mouse event.\n * @returns Scroll delta object with .x and .y properties.\n */\nexport function getScrollDeltaPixels(e: WheelEvent): {x: number; y: number} {\n  switch (e.deltaMode) {\n    case 0x00: // Pixel mode.\n    default:\n      return {x: e.deltaX, y: e.deltaY};\n    case 0x01: // Line mode.\n      return {\n        x: e.deltaX * LINE_MODE_MULTIPLIER,\n        y: e.deltaY * LINE_MODE_MULTIPLIER,\n      };\n    case 0x02: // Page mode.\n      return {\n        x: e.deltaX * PAGE_MODE_MULTIPLIER,\n        y: e.deltaY * PAGE_MODE_MULTIPLIER,\n      };\n  }\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.array\n\n/**\n * Removes the first occurrence of a particular value from an array.\n *\n * @param arr Array from which to remove value.\n * @param value Value to remove.\n * @returns True if an element was removed.\n * @internal\n */\nexport function removeElem(arr: Array, value: T): boolean {\n  const i = arr.indexOf(value);\n  if (i === -1) {\n    return false;\n  }\n  arr.splice(i, 1);\n  return true;\n}\n","/**\n * @license\n * Copyright 2013 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Css\n\n/** Has CSS already been injected? */\nlet injected = false;\n\n/**\n * Add some CSS to the blob that will be injected later.  Allows optional\n * components such as fields and the toolbox to store separate CSS.\n *\n * @param cssContent Multiline CSS string or an array of single lines of CSS.\n */\nexport function register(cssContent: string) {\n  if (injected) {\n    throw Error('CSS already injected');\n  }\n  content += '\\n' + cssContent;\n}\n\n/**\n * Inject the CSS into the DOM.  This is preferable over using a regular CSS\n * file since:\n * a) It loads synchronously and doesn't force a redraw later.\n * b) It speeds up loading by not blocking on a separate HTTP transfer.\n * c) The CSS content may be made dynamic depending on init options.\n *\n * @param hasCss If false, don't inject CSS (providing CSS becomes the\n *     document's responsibility).\n * @param pathToMedia Path from page to the Blockly media directory.\n */\nexport function inject(hasCss: boolean, pathToMedia: string) {\n  // Only inject the CSS once.\n  if (injected) {\n    return;\n  }\n  injected = true;\n  if (!hasCss) {\n    return;\n  }\n  // Strip off any trailing slash (either Unix or Windows).\n  const mediaPath = pathToMedia.replace(/[\\\\/]$/, '');\n  const cssContent = content.replace(/<<>>/g, mediaPath);\n  // Cleanup the collected css content after injecting it to the DOM.\n  content = '';\n\n  // Inject CSS tag at start of head.\n  const cssNode = document.createElement('style');\n  cssNode.id = 'blockly-common-style';\n  const cssTextNode = document.createTextNode(cssContent);\n  cssNode.appendChild(cssTextNode);\n  document.head.insertBefore(cssNode, document.head.firstChild);\n}\n\n/**\n * The CSS content for Blockly.\n */\nlet content = `\n.blocklySvg {\n  background-color: #fff;\n  outline: none;\n  overflow: hidden;  /* IE overflows by default. */\n  position: absolute;\n  display: block;\n}\n\n.blocklyWidgetDiv {\n  display: none;\n  position: absolute;\n  z-index: 99999;  /* big value for bootstrap3 compatibility */\n}\n\n.injectionDiv {\n  height: 100%;\n  position: relative;\n  overflow: hidden;  /* So blocks in drag surface disappear at edges */\n  touch-action: none;\n}\n\n.blocklyNonSelectable {\n  user-select: none;\n  -ms-user-select: none;\n  -webkit-user-select: none;\n}\n\n.blocklyBlockCanvas.blocklyCanvasTransitioning,\n.blocklyBubbleCanvas.blocklyCanvasTransitioning {\n  transition: transform .5s;\n}\n\n.blocklyTooltipDiv {\n  background-color: #ffffc7;\n  border: 1px solid #ddc;\n  box-shadow: 4px 4px 20px 1px rgba(0,0,0,.15);\n  color: #000;\n  display: none;\n  font: 9pt sans-serif;\n  opacity: .9;\n  padding: 2px;\n  position: absolute;\n  z-index: 100000;  /* big value for bootstrap3 compatibility */\n}\n\n.blocklyDropDownDiv {\n  position: absolute;\n  left: 0;\n  top: 0;\n  z-index: 1000;\n  display: none;\n  border: 1px solid;\n  border-color: #dadce0;\n  background-color: #fff;\n  border-radius: 2px;\n  padding: 4px;\n  box-shadow: 0 0 3px 1px rgba(0,0,0,.3);\n}\n\n.blocklyDropDownDiv.blocklyFocused {\n  box-shadow: 0 0 6px 1px rgba(0,0,0,.3);\n}\n\n.blocklyDropDownContent {\n  max-height: 300px;  /* @todo: spec for maximum height. */\n  overflow: auto;\n  overflow-x: hidden;\n  position: relative;\n}\n\n.blocklyDropDownArrow {\n  position: absolute;\n  left: 0;\n  top: 0;\n  width: 16px;\n  height: 16px;\n  z-index: -1;\n  background-color: inherit;\n  border-color: inherit;\n}\n\n.blocklyDropDownButton {\n  display: inline-block;\n  float: left;\n  padding: 0;\n  margin: 4px;\n  border-radius: 4px;\n  outline: none;\n  border: 1px solid;\n  transition: box-shadow .1s;\n  cursor: pointer;\n}\n\n.blocklyArrowTop {\n  border-top: 1px solid;\n  border-left: 1px solid;\n  border-top-left-radius: 4px;\n  border-color: inherit;\n}\n\n.blocklyArrowBottom {\n  border-bottom: 1px solid;\n  border-right: 1px solid;\n  border-bottom-right-radius: 4px;\n  border-color: inherit;\n}\n\n.blocklyResizeSE {\n  cursor: se-resize;\n  fill: #aaa;\n}\n\n.blocklyResizeSW {\n  cursor: sw-resize;\n  fill: #aaa;\n}\n\n.blocklyResizeLine {\n  stroke: #515A5A;\n  stroke-width: 1;\n}\n\n.blocklyHighlightedConnectionPath {\n  fill: none;\n  stroke: #fc3;\n  stroke-width: 4px;\n}\n\n.blocklyPathLight {\n  fill: none;\n  stroke-linecap: round;\n  stroke-width: 1;\n}\n\n.blocklySelected>.blocklyPathLight {\n  display: none;\n}\n\n.blocklyDraggable {\n  cursor: grab;\n  cursor: -webkit-grab;\n}\n\n.blocklyDragging {\n  cursor: grabbing;\n  cursor: -webkit-grabbing;\n}\n\n  /* Changes cursor on mouse down. Not effective in Firefox because of\n     https://bugzilla.mozilla.org/show_bug.cgi?id=771241 */\n.blocklyDraggable:active {\n  cursor: grabbing;\n  cursor: -webkit-grabbing;\n}\n\n.blocklyDragging.blocklyDraggingDelete {\n  cursor: url(\"<<>>/handdelete.cur\"), auto;\n}\n\n.blocklyDragging>.blocklyPath,\n.blocklyDragging>.blocklyPathLight {\n  fill-opacity: .8;\n  stroke-opacity: .8;\n}\n\n.blocklyDragging>.blocklyPathDark {\n  display: none;\n}\n\n.blocklyDisabled>.blocklyPath {\n  fill-opacity: .5;\n  stroke-opacity: .5;\n}\n\n.blocklyDisabled>.blocklyPathLight,\n.blocklyDisabled>.blocklyPathDark {\n  display: none;\n}\n\n.blocklyInsertionMarker>.blocklyPath,\n.blocklyInsertionMarker>.blocklyPathLight,\n.blocklyInsertionMarker>.blocklyPathDark {\n  fill-opacity: .2;\n  stroke: none;\n}\n\n.blocklyMultilineText {\n  font-family: monospace;\n}\n\n.blocklyNonEditableText>text {\n  pointer-events: none;\n}\n\n.blocklyFlyout {\n  position: absolute;\n  z-index: 20;\n}\n\n.blocklyText text {\n  cursor: default;\n}\n\n/*\n  Don't allow users to select text.  It gets annoying when trying to\n  drag a block and selected text moves instead.\n*/\n.blocklySvg text {\n  user-select: none;\n  -ms-user-select: none;\n  -webkit-user-select: none;\n  cursor: inherit;\n}\n\n.blocklyHidden {\n  display: none;\n}\n\n.blocklyFieldDropdown:not(.blocklyHidden) {\n  display: block;\n}\n\n.blocklyIconGroup {\n  cursor: default;\n}\n\n.blocklyIconGroup:not(:hover),\n.blocklyIconGroupReadonly {\n  opacity: .6;\n}\n\n.blocklyIconShape {\n  fill: #00f;\n  stroke: #fff;\n  stroke-width: 1px;\n}\n\n.blocklyIconSymbol {\n  fill: #fff;\n}\n\n.blocklyMinimalBody {\n  margin: 0;\n  padding: 0;\n}\n\n.blocklyHtmlInput {\n  border: none;\n  border-radius: 4px;\n  height: 100%;\n  margin: 0;\n  outline: none;\n  padding: 0;\n  width: 100%;\n  text-align: center;\n  display: block;\n  box-sizing: border-box;\n}\n\n/* Remove the increase and decrease arrows on the field number editor */\ninput.blocklyHtmlInput[type=number]::-webkit-inner-spin-button,\ninput.blocklyHtmlInput[type=number]::-webkit-outer-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\ninput[type=number] {\n  -moz-appearance: textfield;\n}\n\n.blocklyMainBackground {\n  stroke-width: 1;\n  stroke: #c6c6c6;  /* Equates to #ddd due to border being off-pixel. */\n}\n\n.blocklyMutatorBackground {\n  fill: #fff;\n  stroke: #ddd;\n  stroke-width: 1;\n}\n\n.blocklyFlyoutBackground {\n  fill: #ddd;\n  fill-opacity: .8;\n}\n\n.blocklyMainWorkspaceScrollbar {\n  z-index: 20;\n}\n\n.blocklyFlyoutScrollbar {\n  z-index: 30;\n}\n\n.blocklyScrollbarHorizontal,\n.blocklyScrollbarVertical {\n  position: absolute;\n  outline: none;\n}\n\n.blocklyScrollbarBackground {\n  opacity: 0;\n}\n\n.blocklyScrollbarHandle {\n  fill: #ccc;\n}\n\n.blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,\n.blocklyScrollbarHandle:hover {\n  fill: #bbb;\n}\n\n/* Darken flyout scrollbars due to being on a grey background. */\n/* By contrast, workspace scrollbars are on a white background. */\n.blocklyFlyout .blocklyScrollbarHandle {\n  fill: #bbb;\n}\n\n.blocklyFlyout .blocklyScrollbarBackground:hover+.blocklyScrollbarHandle,\n.blocklyFlyout .blocklyScrollbarHandle:hover {\n  fill: #aaa;\n}\n\n.blocklyInvalidInput {\n  background: #faa;\n}\n\n.blocklyVerticalMarker {\n  stroke-width: 3px;\n  fill: rgba(255,255,255,.5);\n  pointer-events: none;\n}\n\n.blocklyComputeCanvas {\n  position: absolute;\n  width: 0;\n  height: 0;\n}\n\n.blocklyNoPointerEvents {\n  pointer-events: none;\n}\n\n.blocklyContextMenu {\n  border-radius: 4px;\n  max-height: 100%;\n}\n\n.blocklyDropdownMenu {\n  border-radius: 2px;\n  padding: 0 !important;\n}\n\n.blocklyDropdownMenu .blocklyMenuItem {\n  /* 28px on the left for icon or checkbox. */\n  padding-left: 28px;\n}\n\n/* BiDi override for the resting state. */\n.blocklyDropdownMenu .blocklyMenuItemRtl {\n  /* Flip left/right padding for BiDi. */\n  padding-left: 5px;\n  padding-right: 28px;\n}\n\n.blocklyWidgetDiv .blocklyMenu {\n  background: #fff;\n  border: 1px solid transparent;\n  box-shadow: 0 0 3px 1px rgba(0,0,0,.3);\n  font: normal 13px Arial, sans-serif;\n  margin: 0;\n  outline: none;\n  padding: 4px 0;\n  position: absolute;\n  overflow-y: auto;\n  overflow-x: hidden;\n  max-height: 100%;\n  z-index: 20000;  /* Arbitrary, but some apps depend on it... */\n}\n\n.blocklyWidgetDiv .blocklyMenu.blocklyFocused {\n  box-shadow: 0 0 6px 1px rgba(0,0,0,.3);\n}\n\n.blocklyDropDownDiv .blocklyMenu {\n  background: inherit;  /* Compatibility with gapi, reset from goog-menu */\n  border: inherit;  /* Compatibility with gapi, reset from goog-menu */\n  font: normal 13px \"Helvetica Neue\", Helvetica, sans-serif;\n  outline: none;\n  position: relative;  /* Compatibility with gapi, reset from goog-menu */\n  z-index: 20000;  /* Arbitrary, but some apps depend on it... */\n}\n\n/* State: resting. */\n.blocklyMenuItem {\n  border: none;\n  color: #000;\n  cursor: pointer;\n  list-style: none;\n  margin: 0;\n  /* 7em on the right for shortcut. */\n  min-width: 7em;\n  padding: 6px 15px;\n  white-space: nowrap;\n}\n\n/* State: disabled. */\n.blocklyMenuItemDisabled {\n  color: #ccc;\n  cursor: inherit;\n}\n\n/* State: hover. */\n.blocklyMenuItemHighlight {\n  background-color: rgba(0,0,0,.1);\n}\n\n/* State: selected/checked. */\n.blocklyMenuItemCheckbox {\n  height: 16px;\n  position: absolute;\n  width: 16px;\n}\n\n.blocklyMenuItemSelected .blocklyMenuItemCheckbox {\n  background: url(<<>>/sprites.png) no-repeat -48px -16px;\n  float: left;\n  margin-left: -24px;\n  position: static;  /* Scroll with the menu. */\n}\n\n.blocklyMenuItemRtl .blocklyMenuItemCheckbox {\n  float: right;\n  margin-right: -24px;\n}\n`;\n","/**\n * @license\n * Copyright 2020 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.deprecation\n\n/**\n * Warn developers that a function or property is deprecated.\n *\n * @param name The name of the function or property.\n * @param deprecationDate The date of deprecation. Prefer 'version n.0.0'\n *     format, and fall back to 'month yyyy' or 'quarter yyyy' format.\n * @param deletionDate The date of deletion. Prefer 'version n.0.0'\n *     format, and fall back to 'month yyyy' or 'quarter yyyy' format.\n * @param opt_use The name of a function or property to use instead, if any.\n * @internal\n */\nexport function warn(\n  name: string,\n  deprecationDate: string,\n  deletionDate: string,\n  opt_use?: string,\n) {\n  let msg =\n    name +\n    ' was deprecated in ' +\n    deprecationDate +\n    ' and will be deleted in ' +\n    deletionDate +\n    '.';\n  if (opt_use) {\n    msg += '\\nUse ' + opt_use + ' instead.';\n  }\n  console.warn(msg);\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.dom\n\nimport * as deprecation from './deprecation.js';\nimport type {Svg} from './svg.js';\n\n/**\n * Required name space for SVG elements.\n */\nexport const SVG_NS = 'http://www.w3.org/2000/svg';\n\n/**\n * Required name space for HTML elements.\n */\nexport const HTML_NS = 'http://www.w3.org/1999/xhtml';\n\n/**\n * Required name space for XLINK elements.\n */\nexport const XLINK_NS = 'http://www.w3.org/1999/xlink';\n\n/**\n * Node type constants.\n * https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType\n */\nexport enum NodeType {\n  ELEMENT_NODE = 1,\n  TEXT_NODE = 3,\n  COMMENT_NODE = 8,\n}\n\n/** Temporary cache of text widths. */\nlet cacheWidths: {[key: string]: number} | null = null;\n\n/** Number of current references to cache. */\nlet cacheReference = 0;\n\n/** A HTML canvas context used for computing text width. */\nlet canvasContext: CanvasRenderingContext2D | null = null;\n\n/**\n * Helper method for creating SVG elements.\n *\n * @param name Element's tag name.\n * @param attrs Dictionary of attribute names and values.\n * @param opt_parent Optional parent on which to append the element.\n * @returns if name is a string or a more specific type if it a member of Svg.\n */\nexport function createSvgElement(\n  name: string | Svg,\n  attrs: {[key: string]: string | number},\n  opt_parent?: Element | null,\n): T {\n  const e = document.createElementNS(SVG_NS, `${name}`) as T;\n  for (const key in attrs) {\n    e.setAttribute(key, `${attrs[key]}`);\n  }\n  if (opt_parent) {\n    opt_parent.appendChild(e);\n  }\n  return e;\n}\n\n/**\n * Add a CSS class to a element.\n *\n * Handles multiple space-separated classes for legacy reasons.\n *\n * @param element DOM element to add class to.\n * @param className Name of class to add.\n * @returns True if class was added, false if already present.\n */\nexport function addClass(element: Element, className: string): boolean {\n  const classNames = className.split(' ');\n  if (classNames.every((name) => element.classList.contains(name))) {\n    return false;\n  }\n  element.classList.add(...classNames);\n  return true;\n}\n\n/**\n * Removes multiple classes from an element.\n *\n * @param element DOM element to remove classes from.\n * @param classNames A string of one or multiple class names for an element.\n */\nexport function removeClasses(element: Element, classNames: string) {\n  element.classList.remove(...classNames.split(' '));\n}\n\n/**\n * Remove a CSS class from a element.\n *\n * Handles multiple space-separated classes for legacy reasons.\n *\n * @param element DOM element to remove class from.\n * @param className Name of class to remove.\n * @returns True if class was removed, false if never present.\n */\nexport function removeClass(element: Element, className: string): boolean {\n  const classNames = className.split(' ');\n  if (classNames.every((name) => !element.classList.contains(name))) {\n    return false;\n  }\n  element.classList.remove(...classNames);\n  return true;\n}\n\n/**\n * Checks if an element has the specified CSS class.\n *\n * @param element DOM element to check.\n * @param className Name of class to check.\n * @returns True if class exists, false otherwise.\n */\nexport function hasClass(element: Element, className: string): boolean {\n  return element.classList.contains(className);\n}\n\n/**\n * Removes a node from its parent. No-op if not attached to a parent.\n *\n * @param node The node to remove.\n * @returns The node removed if removed; else, null.\n */\n// Copied from Closure goog.dom.removeNode\nexport function removeNode(node: Node | null): Node | null {\n  return node && node.parentNode ? node.parentNode.removeChild(node) : null;\n}\n\n/**\n * Insert a node after a reference node.\n * Contrast with node.insertBefore function.\n *\n * @param newNode New element to insert.\n * @param refNode Existing element to precede new node.\n */\nexport function insertAfter(newNode: Element, refNode: Element) {\n  const siblingNode = refNode.nextSibling;\n  const parentNode = refNode.parentNode;\n  if (!parentNode) {\n    throw Error('Reference node has no parent.');\n  }\n  if (siblingNode) {\n    parentNode.insertBefore(newNode, siblingNode);\n  } else {\n    parentNode.appendChild(newNode);\n  }\n}\n\n/**\n * Whether a node contains another node.\n *\n * @param parent The node that should contain the other node.\n * @param descendant The node to test presence of.\n * @returns Whether the parent node contains the descendant node.\n * @deprecated Use native 'contains' DOM method.\n */\nexport function containsNode(parent: Node, descendant: Node): boolean {\n  deprecation.warn(\n    'Blockly.utils.dom.containsNode',\n    'version 10',\n    'version 11',\n    'Use native \"contains\" DOM method',\n  );\n  return parent.contains(descendant);\n}\n\n/**\n * Sets the CSS transform property on an element. This function sets the\n * non-vendor-prefixed and vendor-prefixed versions for backwards compatibility\n * with older browsers. See https://caniuse.com/#feat=transforms2d\n *\n * @param element Element to which the CSS transform will be applied.\n * @param transform The value of the CSS `transform` property.\n */\nexport function setCssTransform(\n  element: HTMLElement | SVGElement,\n  transform: string,\n) {\n  element.style['transform'] = transform;\n  element.style['-webkit-transform' as any] = transform;\n}\n\n/**\n * Start caching text widths. Every call to this function MUST also call\n * stopTextWidthCache. Caches must not survive between execution threads.\n */\nexport function startTextWidthCache() {\n  cacheReference++;\n  if (!cacheWidths) {\n    cacheWidths = Object.create(null);\n  }\n}\n\n/**\n * Stop caching field widths. Unless caching was already on when the\n * corresponding call to startTextWidthCache was made.\n */\nexport function stopTextWidthCache() {\n  cacheReference--;\n  if (!cacheReference) {\n    cacheWidths = null;\n  }\n}\n\n/**\n * Gets the width of a text element, caching it in the process.\n *\n * @param textElement An SVG 'text' element.\n * @returns Width of element.\n */\nexport function getTextWidth(textElement: SVGTextElement): number {\n  const key = textElement.textContent + '\\n' + textElement.className.baseVal;\n  let width;\n  // Return the cached width if it exists.\n  if (cacheWidths) {\n    width = cacheWidths[key];\n    if (width) {\n      return width;\n    }\n  }\n\n  // Attempt to compute fetch the width of the SVG text element.\n  try {\n    width = textElement.getComputedTextLength();\n  } catch (e) {\n    // In other cases where we fail to get the computed text. Instead, use an\n    // approximation and do not cache the result. At some later point in time\n    // when the block is inserted into the visible DOM, this method will be\n    // called again and, at that point in time, will not throw an exception.\n    return textElement.textContent!.length * 8;\n  }\n\n  // Cache the computed width and return.\n  if (cacheWidths) {\n    cacheWidths[key] = width;\n  }\n  return width;\n}\n\n/**\n * Gets the width of a text element using a faster method than `getTextWidth`.\n * This method requires that we know the text element's font family and size in\n * advance. Similar to `getTextWidth`, we cache the width we compute.\n *\n * @param textElement An SVG 'text' element.\n * @param fontSize The font size to use.\n * @param fontWeight The font weight to use.\n * @param fontFamily The font family to use.\n * @returns Width of element.\n */\nexport function getFastTextWidth(\n  textElement: SVGTextElement,\n  fontSize: number,\n  fontWeight: string,\n  fontFamily: string,\n): number {\n  return getFastTextWidthWithSizeString(\n    textElement,\n    fontSize + 'pt',\n    fontWeight,\n    fontFamily,\n  );\n}\n\n/**\n * Gets the width of a text element using a faster method than `getTextWidth`.\n * This method requires that we know the text element's font family and size in\n * advance. Similar to `getTextWidth`, we cache the width we compute.\n * This method is similar to `getFastTextWidth` but expects the font size\n * parameter to be a string.\n *\n * @param textElement An SVG 'text' element.\n * @param fontSize The font size to use.\n * @param fontWeight The font weight to use.\n * @param fontFamily The font family to use.\n * @returns Width of element.\n */\nexport function getFastTextWidthWithSizeString(\n  textElement: SVGTextElement,\n  fontSize: string,\n  fontWeight: string,\n  fontFamily: string,\n): number {\n  const text = textElement.textContent;\n  const key = text + '\\n' + textElement.className.baseVal;\n  let width;\n\n  // Return the cached width if it exists.\n  if (cacheWidths) {\n    width = cacheWidths[key];\n    if (width) {\n      return width;\n    }\n  }\n\n  if (!canvasContext) {\n    // Inject the canvas element used for computing text widths.\n    const computeCanvas = document.createElement('canvas');\n    computeCanvas.className = 'blocklyComputeCanvas';\n    document.body.appendChild(computeCanvas);\n\n    // Initialize the HTML canvas context and set the font.\n    // The context font must match blocklyText's fontsize and font-family\n    // set in CSS.\n    canvasContext = computeCanvas.getContext('2d') as CanvasRenderingContext2D;\n  }\n  // Set the desired font size and family.\n  canvasContext.font = fontWeight + ' ' + fontSize + ' ' + fontFamily;\n\n  // Measure the text width using the helper canvas context.\n  if (text) {\n    width = canvasContext.measureText(text).width;\n  } else {\n    width = 0;\n  }\n\n  // Cache the computed width and return.\n  if (cacheWidths) {\n    cacheWidths[key] = width;\n  }\n  return width;\n}\n\n/**\n * Measure a font's metrics. The height and baseline values.\n *\n * @param text Text to measure the font dimensions of.\n * @param fontSize The font size to use.\n * @param fontWeight The font weight to use.\n * @param fontFamily The font family to use.\n * @returns Font measurements.\n */\nexport function measureFontMetrics(\n  text: string,\n  fontSize: string,\n  fontWeight: string,\n  fontFamily: string,\n): {height: number; baseline: number} {\n  const span = document.createElement('span');\n  span.style.font = fontWeight + ' ' + fontSize + ' ' + fontFamily;\n  span.textContent = text;\n\n  const block = document.createElement('div');\n  block.style.width = '1px';\n  block.style.height = '0';\n\n  const div = document.createElement('div');\n  div.style.display = 'flex';\n  div.style.position = 'fixed';\n  div.style.top = '0';\n  div.style.left = '0';\n  div.appendChild(span);\n  div.appendChild(block);\n\n  document.body.appendChild(div);\n  const result = {\n    height: 0,\n    baseline: 0,\n  };\n  try {\n    div.style.alignItems = 'baseline';\n    result.baseline = block.offsetTop - span.offsetTop;\n    div.style.alignItems = 'flex-end';\n    result.height = block.offsetTop - span.offsetTop;\n  } finally {\n    document.body.removeChild(div);\n  }\n  return result;\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.style\n\nimport {Coordinate} from './coordinate.js';\nimport {Rect} from './rect.js';\nimport {Size} from './size.js';\n\n/**\n * Gets the height and width of an element.\n * Similar to Closure's goog.style.getSize\n *\n * @param element Element to get size of.\n * @returns Object with width/height properties.\n */\nexport function getSize(element: Element): Size {\n  return TEST_ONLY.getSizeInternal(element);\n}\n\n/**\n * Private version of getSize for stubbing in tests.\n */\nfunction getSizeInternal(element: Element): Size {\n  if (getComputedStyle(element, 'display') !== 'none') {\n    return getSizeWithDisplay(element);\n  }\n\n  // Evaluate size with a temporary element.\n  // AnyDuringMigration because:  Property 'style' does not exist on type\n  // 'Element'.\n  const style = (element as AnyDuringMigration).style;\n  const originalDisplay = style.display;\n  const originalVisibility = style.visibility;\n  const originalPosition = style.position;\n\n  style.visibility = 'hidden';\n  style.position = 'absolute';\n  style.display = 'inline';\n\n  const offsetWidth = (element as HTMLElement).offsetWidth;\n  const offsetHeight = (element as HTMLElement).offsetHeight;\n\n  style.display = originalDisplay;\n  style.position = originalPosition;\n  style.visibility = originalVisibility;\n\n  return new Size(offsetWidth, offsetHeight);\n}\n\n/**\n * Gets the height and width of an element when the display is not none.\n *\n * @param element Element to get size of.\n * @returns Object with width/height properties.\n */\nfunction getSizeWithDisplay(element: Element): Size {\n  const offsetWidth = (element as HTMLElement).offsetWidth;\n  const offsetHeight = (element as HTMLElement).offsetHeight;\n  return new Size(offsetWidth, offsetHeight);\n}\n\n/**\n * Retrieves a computed style value of a node. It returns empty string\n * if the property requested is an SVG one and it has not been\n * explicitly set (firefox and webkit).\n *\n * Copied from Closure's goog.style.getComputedStyle\n *\n * @param element Element to get style of.\n * @param property Property to get (camel-case).\n * @returns Style value.\n */\nexport function getComputedStyle(element: Element, property: string): string {\n  const styles = window.getComputedStyle(element);\n  // element.style[..] is undefined for browser specific styles\n  // as 'filter'.\n  return (\n    (styles as AnyDuringMigration)[property] ||\n    styles.getPropertyValue(property)\n  );\n}\n\n/**\n * Returns a Coordinate object relative to the top-left of the HTML document.\n * Similar to Closure's goog.style.getPageOffset\n *\n * @param el Element to get the page offset for.\n * @returns The page offset.\n */\nexport function getPageOffset(el: Element): Coordinate {\n  const pos = new Coordinate(0, 0);\n  const box = el.getBoundingClientRect();\n  const documentElement = document.documentElement;\n  // Must add the scroll coordinates in to get the absolute page offset\n  // of element since getBoundingClientRect returns relative coordinates to\n  // the viewport.\n  const scrollCoord = new Coordinate(\n    window.pageXOffset || documentElement.scrollLeft,\n    window.pageYOffset || documentElement.scrollTop,\n  );\n  pos.x = box.left + scrollCoord.x;\n  pos.y = box.top + scrollCoord.y;\n\n  return pos;\n}\n\n/**\n * Calculates the viewport coordinates relative to the document.\n * Similar to Closure's goog.style.getViewportPageOffset\n *\n * @returns The page offset of the viewport.\n */\nexport function getViewportPageOffset(): Coordinate {\n  const body = document.body;\n  const documentElement = document.documentElement;\n  const scrollLeft = body.scrollLeft || documentElement.scrollLeft;\n  const scrollTop = body.scrollTop || documentElement.scrollTop;\n  return new Coordinate(scrollLeft, scrollTop);\n}\n\n/**\n * Gets the computed border widths (on all sides) in pixels\n * Copied from Closure's goog.style.getBorderBox\n *\n * @param element  The element to get the border widths for.\n * @returns The computed border widths.\n */\nexport function getBorderBox(element: Element): Rect {\n  const left = parseFloat(getComputedStyle(element, 'borderLeftWidth'));\n  const right = parseFloat(getComputedStyle(element, 'borderRightWidth'));\n  const top = parseFloat(getComputedStyle(element, 'borderTopWidth'));\n  const bottom = parseFloat(getComputedStyle(element, 'borderBottomWidth'));\n\n  return new Rect(top, bottom, left, right);\n}\n\n/**\n * Changes the scroll position of `container` with the minimum amount so\n * that the content and the borders of the given `element` become visible.\n * If the element is bigger than the container, its top left corner will be\n * aligned as close to the container's top left corner as possible.\n * Copied from Closure's goog.style.scrollIntoContainerView\n *\n * @param element The element to make visible.\n * @param container The container to scroll. If not set, then the document\n *     scroll element will be used.\n * @param opt_center Whether to center the element in the container.\n *     Defaults to false.\n */\nexport function scrollIntoContainerView(\n  element: Element,\n  container: Element,\n  opt_center?: boolean,\n) {\n  const offset = getContainerOffsetToScrollInto(element, container, opt_center);\n  container.scrollLeft = offset.x;\n  container.scrollTop = offset.y;\n}\n\n/**\n * Calculate the scroll position of `container` with the minimum amount so\n * that the content and the borders of the given `element` become visible.\n * If the element is bigger than the container, its top left corner will be\n * aligned as close to the container's top left corner as possible.\n * Copied from Closure's goog.style.getContainerOffsetToScrollInto\n *\n * @param element The element to make visible.\n * @param container The container to scroll. If not set, then the document\n *     scroll element will be used.\n * @param opt_center Whether to center the element in the container.\n *     Defaults to false.\n * @returns The new scroll position of the container.\n */\nexport function getContainerOffsetToScrollInto(\n  element: Element,\n  container: Element,\n  opt_center?: boolean,\n): Coordinate {\n  // Absolute position of the element's border's top left corner.\n  const elementPos = getPageOffset(element);\n  // Absolute position of the container's border's top left corner.\n  const containerPos = getPageOffset(container);\n  const containerBorder = getBorderBox(container);\n  // Relative pos. of the element's border box to the container's content box.\n  const relX = elementPos.x - containerPos.x - containerBorder.left;\n  const relY = elementPos.y - containerPos.y - containerBorder.top;\n  // How much the element can move in the container, i.e. the difference between\n  // the element's bottom-right-most and top-left-most position where it's\n  // fully visible.\n  const elementSize = getSizeWithDisplay(element);\n  const spaceX = container.clientWidth - elementSize.width;\n  const spaceY = container.clientHeight - elementSize.height;\n  let scrollLeft = container.scrollLeft;\n  let scrollTop = container.scrollTop;\n  if (opt_center) {\n    // All browsers round non-integer scroll positions down.\n    scrollLeft += relX - spaceX / 2;\n    scrollTop += relY - spaceY / 2;\n  } else {\n    // This formula was designed to give the correct scroll values in the\n    // following cases:\n    // - element is higher than container (spaceY < 0) => scroll down by relY\n    // - element is not higher that container (spaceY >= 0):\n    //   - it is above container (relY < 0) => scroll up by abs(relY)\n    //   - it is below container (relY > spaceY) => scroll down by relY - spaceY\n    //   - it is in the container => don't scroll\n    scrollLeft += Math.min(relX, Math.max(relX - spaceX, 0));\n    scrollTop += Math.min(relY, Math.max(relY - spaceY, 0));\n  }\n  return new Coordinate(scrollLeft, scrollTop);\n}\n\nexport const TEST_ONLY = {\n  getSizeInternal,\n};\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.svgMath\n\nimport type {WorkspaceSvg} from '../workspace_svg.js';\n\nimport {Coordinate} from './coordinate.js';\nimport {Rect} from './rect.js';\nimport * as style from './style.js';\n\n/**\n * Static regex to pull the x,y values out of an SVG translate() directive.\n * Note that Firefox and IE (9,10) return 'translate(12)' instead of\n * 'translate(12, 0)'.\n * Note that IE (9,10) returns 'translate(16 8)' instead of 'translate(16, 8)'.\n * Note that IE has been reported to return scientific notation (0.123456e-42).\n */\nconst XY_REGEX = /translate\\(\\s*([-+\\d.e]+)([ ,]\\s*([-+\\d.e]+)\\s*)?/;\n\n/**\n * Static regex to pull the x,y values out of a translate() or translate3d()\n * style property.\n * Accounts for same exceptions as XY_REGEX.\n */\nconst XY_STYLE_REGEX =\n  /transform:\\s*translate(?:3d)?\\(\\s*([-+\\d.e]+)\\s*px([ ,]\\s*([-+\\d.e]+)\\s*px)?/;\n\n/**\n * Return the coordinates of the top-left corner of this element relative to\n * its parent.  Only for SVG elements and children (e.g. rect, g, path).\n *\n * @param element SVG element to find the coordinates of.\n * @returns Object with .x and .y properties.\n */\nexport function getRelativeXY(element: Element): Coordinate {\n  const xy = new Coordinate(0, 0);\n  // First, check for x and y attributes.\n  // Checking for the existence of x/y properties is faster than getAttribute.\n  // However, x/y contains an SVGAnimatedLength object, so rely on getAttribute\n  // to get the number.\n  const x = (element as any).x && element.getAttribute('x');\n  const y = (element as any).y && element.getAttribute('y');\n  if (x) {\n    xy.x = parseInt(x);\n  }\n  if (y) {\n    xy.y = parseInt(y);\n  }\n  // Second, check for transform=\"translate(...)\" attribute.\n  const transform = element.getAttribute('transform');\n  const r = transform && transform.match(XY_REGEX);\n  if (r) {\n    xy.x += Number(r[1]);\n    if (r[3]) {\n      xy.y += Number(r[3]);\n    }\n  }\n\n  // Then check for style = transform: translate(...) or translate3d(...)\n  const style = element.getAttribute('style');\n  if (style && style.indexOf('translate') > -1) {\n    const styleComponents = style.match(XY_STYLE_REGEX);\n    if (styleComponents) {\n      xy.x += Number(styleComponents[1]);\n      if (styleComponents[3]) {\n        xy.y += Number(styleComponents[3]);\n      }\n    }\n  }\n  return xy;\n}\n\n/**\n * Return the coordinates of the top-left corner of this element relative to\n * the div Blockly was injected into.\n *\n * @param element SVG element to find the coordinates of. If this is not a child\n *     of the div Blockly was injected into, the behaviour is undefined.\n * @returns Object with .x and .y properties.\n */\nexport function getInjectionDivXY(element: Element): Coordinate {\n  let x = 0;\n  let y = 0;\n  while (element) {\n    const xy = getRelativeXY(element);\n    x = x + xy.x;\n    y = y + xy.y;\n    const classes = element.getAttribute('class') || '';\n    if ((' ' + classes + ' ').indexOf(' injectionDiv ') !== -1) {\n      break;\n    }\n    element = element.parentNode as Element;\n  }\n  return new Coordinate(x, y);\n}\n\n/**\n * Get the position of the current viewport in window coordinates.  This takes\n * scroll into account.\n *\n * @returns An object containing window width, height, and scroll position in\n *     window coordinates.\n * @internal\n */\nexport function getViewportBBox(): Rect {\n  // Pixels, in window coordinates.\n  const scrollOffset = style.getViewportPageOffset();\n  return new Rect(\n    scrollOffset.y,\n    document.documentElement.clientHeight + scrollOffset.y,\n    scrollOffset.x,\n    document.documentElement.clientWidth + scrollOffset.x,\n  );\n}\n\n/**\n * Gets the document scroll distance as a coordinate object.\n * Copied from Closure's goog.dom.getDocumentScroll.\n *\n * @returns Object with values 'x' and 'y'.\n */\nexport function getDocumentScroll(): Coordinate {\n  const el = document.documentElement;\n  const win = window;\n  return new Coordinate(\n    win.pageXOffset || el.scrollLeft,\n    win.pageYOffset || el.scrollTop,\n  );\n}\n\n/**\n * Converts screen coordinates to workspace coordinates.\n *\n * @param ws The workspace to find the coordinates on.\n * @param screenCoordinates The screen coordinates to be converted to workspace\n *     coordinates\n * @returns The workspace coordinates.\n */\nexport function screenToWsCoordinates(\n  ws: WorkspaceSvg,\n  screenCoordinates: Coordinate,\n): Coordinate {\n  const screenX = screenCoordinates.x;\n  const screenY = screenCoordinates.y;\n\n  const injectionDiv = ws.getInjectionDiv();\n  // Bounding rect coordinates are in client coordinates, meaning that they\n  // are in pixels relative to the upper left corner of the visible browser\n  // window.  These coordinates change when you scroll the browser window.\n  const boundingRect = injectionDiv.getBoundingClientRect();\n\n  // The client coordinates offset by the injection div's upper left corner.\n  const clientOffsetPixels = new Coordinate(\n    screenX - boundingRect.left,\n    screenY - boundingRect.top,\n  );\n\n  // The offset in pixels between the main workspace's origin and the upper\n  // left corner of the injection div.\n  const mainOffsetPixels = ws.getOriginOffsetInPixels();\n\n  // The position of the new comment in pixels relative to the origin of the\n  // main workspace.\n  const finalOffsetPixels = Coordinate.difference(\n    clientOffsetPixels,\n    mainOffsetPixels,\n  );\n  // The position in main workspace coordinates.\n  const finalOffsetMainWs = finalOffsetPixels.scale(1 / ws.scale);\n  return finalOffsetMainWs;\n}\n\n/**\n * Converts workspace coordinates to screen coordinates.\n *\n * @param ws The workspace to get the coordinates out of.\n * @param workspaceCoordinates  The workspace coordinates to be converted\n *     to screen coordinates.\n * @returns The screen coordinates.\n */\nexport function wsToScreenCoordinates(\n  ws: WorkspaceSvg,\n  workspaceCoordinates: Coordinate,\n): Coordinate {\n  // Fix workspace scale vs browser scale.\n  const screenCoordinates = workspaceCoordinates.scale(ws.scale);\n  const screenX = screenCoordinates.x;\n  const screenY = screenCoordinates.y;\n\n  const injectionDiv = ws.getInjectionDiv();\n  const boundingRect = injectionDiv.getBoundingClientRect();\n  const mainOffset = ws.getOriginOffsetInPixels();\n\n  // Fix workspace origin vs browser origin.\n  return new Coordinate(\n    screenX + boundingRect.left + mainOffset.x,\n    screenY + boundingRect.top + mainOffset.y,\n  );\n}\n\nexport const TEST_ONLY = {\n  XY_REGEX,\n  XY_STYLE_REGEX,\n};\n","/**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.xml\n\nlet domParser: DOMParser = {\n  parseFromString: function () {\n    throw new Error(\n      'DOMParser was not found in the global scope and was not properly ' +\n        'injected using injectDependencies',\n    );\n  },\n};\n\nlet xmlSerializer: XMLSerializer = {\n  serializeToString: function () {\n    throw new Error(\n      'XMLSerializer was not foundin the global scope and was not properly ' +\n        'injected using injectDependencies',\n    );\n  },\n};\n\n/**\n * Injected dependencies.  By default these are just (and have the\n * same types as) the corresponding DOM Window properties, but the\n * Node.js wrapper for Blockly (see scripts/package/node/core.js)\n * calls injectDependencies to supply implementations from the jsdom\n * package instead.\n */\nlet {document, DOMParser, XMLSerializer} = globalThis;\nif (DOMParser) domParser = new DOMParser();\nif (XMLSerializer) xmlSerializer = new XMLSerializer();\n\n/**\n * Inject implementations of document, DOMParser and/or XMLSerializer\n * to use instead of the default ones.\n *\n * Used by the Node.js wrapper for Blockly (see\n * scripts/package/node/core.js) to supply implementations from the\n * jsdom package instead.\n *\n * While they may be set individually, it is normally the case that\n * all three will be sourced from the same JSDOM instance.  They MUST\n * at least come from the same copy of the jsdom package.  (Typically\n * this is hard to avoid satsifying this requirement, but it can be\n * inadvertently violated by using webpack to build multiple bundles\n * containing Blockly and jsdom, and then loading more than one into\n * the same JavaScript runtime.  See\n * https://github.com/google/blockly-samples/pull/1452#issuecomment-1364442135\n * for an example of how this happened.)\n *\n * @param dependencies Options object containing dependencies to set.\n */\nexport function injectDependencies(dependencies: {\n  document?: Document;\n  DOMParser?: typeof DOMParser;\n  XMLSerializer?: typeof XMLSerializer;\n}) {\n  ({\n    // Default to existing value if option not supplied.\n    document = document,\n    DOMParser = DOMParser,\n    XMLSerializer = XMLSerializer,\n  } = dependencies);\n\n  domParser = new DOMParser();\n  xmlSerializer = new XMLSerializer();\n}\n\n/**\n * Namespace for Blockly's XML.\n */\nexport const NAME_SPACE = 'https://developers.google.com/blockly/xml';\n\n// eslint-disable-next-line no-control-regex\nconst INVALID_CONTROL_CHARS = /[\\x00-\\x09\\x0B\\x0C\\x0E-\\x1F]/g;\n\n/**\n * Create DOM element for XML.\n *\n * @param tagName Name of DOM element.\n * @returns New DOM element.\n */\nexport function createElement(tagName: string): Element {\n  return document.createElementNS(NAME_SPACE, tagName);\n}\n\n/**\n * Create text element for XML.\n *\n * @param text Text content.\n * @returns New DOM text node.\n */\nexport function createTextNode(text: string): Text {\n  return document.createTextNode(text);\n}\n\n/**\n * Converts an XML string into a DOM structure.\n *\n * Control characters should be escaped. (But we will try to best-effort parse\n * unescaped characters.)\n *\n * Note that even when escaped, U+0000 will be parsed as U+FFFD (the\n * \"replacement character\") because U+0000 is never a valid XML character\n * (even in XML 1.1).\n * https://www.w3.org/TR/xml11/#charsets\n *\n * @param text An XML string.\n * @returns A DOM object representing the singular child of the document\n *     element.\n * @throws if the text doesn't parse.\n */\nexport function textToDom(text: string): Element {\n  let doc = domParser.parseFromString(text, 'text/xml');\n  if (\n    doc &&\n    doc.documentElement &&\n    !doc.getElementsByTagName('parsererror').length\n  ) {\n    return doc.documentElement;\n  }\n\n  // Attempt to parse as HTML to deserialize control characters that were\n  // serialized before the serializer did proper escaping.\n  doc = domParser.parseFromString(text, 'text/html');\n  if (\n    doc &&\n    doc.body.firstChild &&\n    doc.body.firstChild.nodeName.toLowerCase() === 'xml'\n  ) {\n    return doc.body.firstChild as Element;\n  }\n\n  throw new Error(`DOMParser was unable to parse: ${text}`);\n}\n\n/**\n * Converts a DOM structure into plain text.\n * Currently the text format is fairly ugly: all one line with no whitespace.\n *\n * Control characters are escaped using their decimal encodings. This includes\n * U+0000 even though it is technically never a valid XML character (even in\n * XML 1.1).\n * https://www.w3.org/TR/xml11/#charsets\n *\n * When decoded U+0000 will be parsed as U+FFFD (the \"replacement character\").\n *\n * @param dom A tree of XML nodes.\n * @returns Text representation.\n */\nexport function domToText(dom: Node): string {\n  return sanitizeText(xmlSerializer.serializeToString(dom));\n}\n\nfunction sanitizeText(text: string) {\n  return text.replace(\n    INVALID_CONTROL_CHARS,\n    (match) => `&#${match.charCodeAt(0)};`,\n  );\n}\n","/**\n * @license\n * Copyright 2020 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.toolbox\n\nimport type {ConnectionState} from '../serialization/blocks.js';\nimport type {CssConfig as CategoryCssConfig} from '../toolbox/category.js';\nimport type {CssConfig as SeparatorCssConfig} from '../toolbox/separator.js';\nimport * as utilsXml from './xml.js';\n\n/**\n * The information needed to create a block in the toolbox.\n * Note that disabled has a different type for backwards compatibility.\n */\nexport interface BlockInfo {\n  kind: string;\n  blockxml?: string | Node;\n  type?: string;\n  gap?: string | number;\n  disabled?: string | boolean;\n  enabled?: boolean;\n  id?: string;\n  x?: number;\n  y?: number;\n  collapsed?: boolean;\n  inline?: boolean;\n  data?: string;\n  extraState?: AnyDuringMigration;\n  icons?: {[key: string]: AnyDuringMigration};\n  fields?: {[key: string]: AnyDuringMigration};\n  inputs?: {[key: string]: ConnectionState};\n  next?: ConnectionState;\n}\n\n/**\n * The information needed to create a separator in the toolbox.\n */\nexport interface SeparatorInfo {\n  kind: string;\n  id: string | undefined;\n  gap: number | undefined;\n  cssconfig: SeparatorCssConfig | undefined;\n}\n\n/**\n * The information needed to create a button in the toolbox.\n */\nexport interface ButtonInfo {\n  kind: string;\n  text: string;\n  callbackkey: string;\n}\n\n/**\n * The information needed to create a label in the toolbox.\n */\nexport interface LabelInfo {\n  kind: string;\n  text: string;\n  id: string | undefined;\n}\n\n/**\n * The information needed to create either a button or a label in the flyout.\n */\nexport type ButtonOrLabelInfo = ButtonInfo | LabelInfo;\n\n/**\n * The information needed to create a category in the toolbox.\n */\nexport interface StaticCategoryInfo {\n  kind: string;\n  name: string;\n  contents: ToolboxItemInfo[];\n  id: string | undefined;\n  categorystyle: string | undefined;\n  colour: string | undefined;\n  cssconfig: CategoryCssConfig | undefined;\n  hidden: string | undefined;\n  expanded?: string | boolean;\n}\n\n/**\n * The information needed to create a custom category.\n */\nexport interface DynamicCategoryInfo {\n  kind: string;\n  custom: string;\n  id: string | undefined;\n  categorystyle: string | undefined;\n  colour: string | undefined;\n  cssconfig: CategoryCssConfig | undefined;\n  hidden: string | undefined;\n  expanded?: string | boolean;\n}\n\n/**\n * The information needed to create either a dynamic or static category.\n */\nexport type CategoryInfo = StaticCategoryInfo | DynamicCategoryInfo;\n\n/**\n * Any information that can be used to create an item in the toolbox.\n */\nexport type ToolboxItemInfo = FlyoutItemInfo | StaticCategoryInfo;\n\n/**\n * All the different types that can be displayed in a flyout.\n */\nexport type FlyoutItemInfo =\n  | BlockInfo\n  | SeparatorInfo\n  | ButtonInfo\n  | LabelInfo\n  | DynamicCategoryInfo;\n\n/**\n * The JSON definition of a toolbox.\n */\nexport interface ToolboxInfo {\n  kind?: string;\n  contents: ToolboxItemInfo[];\n}\n\n/**\n * An array holding flyout items.\n */\nexport type FlyoutItemInfoArray = FlyoutItemInfo[];\n\n/**\n * All of the different types that can create a toolbox.\n */\nexport type ToolboxDefinition = Node | ToolboxInfo | string;\n\n/**\n * All of the different types that can be used to show items in a flyout.\n */\nexport type FlyoutDefinition =\n  | FlyoutItemInfoArray\n  | NodeList\n  | ToolboxInfo\n  | Node[];\n\n/**\n * The name used to identify a toolbox that has category like items.\n * This only needs to be used if a toolbox wants to be treated like a category\n * toolbox but does not actually contain any toolbox items with the kind\n * 'category'.\n */\nconst CATEGORY_TOOLBOX_KIND = 'categoryToolbox';\n\n/**\n * The name used to identify a toolbox that has no categories and is displayed\n * as a simple flyout displaying blocks, buttons, or labels.\n */\nconst FLYOUT_TOOLBOX_KIND = 'flyoutToolbox';\n\n/**\n * Position of the toolbox and/or flyout relative to the workspace.\n */\nexport enum Position {\n  TOP,\n  BOTTOM,\n  LEFT,\n  RIGHT,\n}\n\n/**\n * Converts the toolbox definition into toolbox JSON.\n *\n * @param toolboxDef The definition of the toolbox in one of its many forms.\n * @returns Object holding information for creating a toolbox.\n * @internal\n */\nexport function convertToolboxDefToJson(\n  toolboxDef: ToolboxDefinition | null,\n): ToolboxInfo | null {\n  if (!toolboxDef) {\n    return null;\n  }\n\n  if (toolboxDef instanceof Element || typeof toolboxDef === 'string') {\n    toolboxDef = parseToolboxTree(toolboxDef);\n    // AnyDuringMigration because:  Argument of type 'Node | null' is not\n    // assignable to parameter of type 'Node'.\n    toolboxDef = convertToToolboxJson(toolboxDef as AnyDuringMigration);\n  }\n\n  const toolboxJson = toolboxDef as ToolboxInfo;\n  validateToolbox(toolboxJson);\n  return toolboxJson;\n}\n\n/**\n * Validates the toolbox JSON fields have been set correctly.\n *\n * @param toolboxJson Object holding information for creating a toolbox.\n * @throws {Error} if the toolbox is not the correct format.\n */\nfunction validateToolbox(toolboxJson: ToolboxInfo) {\n  const toolboxKind = toolboxJson['kind'];\n  const toolboxContents = toolboxJson['contents'];\n\n  if (toolboxKind) {\n    if (\n      toolboxKind !== FLYOUT_TOOLBOX_KIND &&\n      toolboxKind !== CATEGORY_TOOLBOX_KIND\n    ) {\n      throw Error(\n        'Invalid toolbox kind ' +\n          toolboxKind +\n          '.' +\n          ' Please supply either ' +\n          FLYOUT_TOOLBOX_KIND +\n          ' or ' +\n          CATEGORY_TOOLBOX_KIND,\n      );\n    }\n  }\n  if (!toolboxContents) {\n    throw Error('Toolbox must have a contents attribute.');\n  }\n}\n\n/**\n * Converts the flyout definition into a list of flyout items.\n *\n * @param flyoutDef The definition of the flyout in one of its many forms.\n * @returns A list of flyout items.\n * @internal\n */\nexport function convertFlyoutDefToJsonArray(\n  flyoutDef: FlyoutDefinition | null,\n): FlyoutItemInfoArray {\n  if (!flyoutDef) {\n    return [];\n  }\n\n  if ((flyoutDef as AnyDuringMigration)['contents']) {\n    return (flyoutDef as AnyDuringMigration)['contents'];\n  }\n  // If it is already in the correct format return the flyoutDef.\n  // AnyDuringMigration because:  Property 'nodeType' does not exist on type\n  // 'Node | FlyoutItemInfo'.\n  if (\n    Array.isArray(flyoutDef) &&\n    flyoutDef.length > 0 &&\n    !(flyoutDef[0] as AnyDuringMigration).nodeType\n  ) {\n    // AnyDuringMigration because:  Type 'FlyoutItemInfoArray | Node[]' is not\n    // assignable to type 'FlyoutItemInfoArray'.\n    return flyoutDef as AnyDuringMigration;\n  }\n\n  // AnyDuringMigration because:  Type 'ToolboxItemInfo[] | FlyoutItemInfoArray'\n  // is not assignable to type 'FlyoutItemInfoArray'.\n  return xmlToJsonArray(flyoutDef as Node[] | NodeList) as AnyDuringMigration;\n}\n\n/**\n * Whether or not the toolbox definition has categories.\n *\n * @param toolboxJson Object holding information for creating a toolbox.\n * @returns True if the toolbox has categories.\n * @internal\n */\nexport function hasCategories(toolboxJson: ToolboxInfo | null): boolean {\n  return TEST_ONLY.hasCategoriesInternal(toolboxJson);\n}\n\n/**\n * Private version of hasCategories for stubbing in tests.\n */\nfunction hasCategoriesInternal(toolboxJson: ToolboxInfo | null): boolean {\n  if (!toolboxJson) {\n    return false;\n  }\n\n  const toolboxKind = toolboxJson['kind'];\n  if (toolboxKind) {\n    return toolboxKind === CATEGORY_TOOLBOX_KIND;\n  }\n\n  const categories = toolboxJson['contents'].filter(function (item) {\n    return item['kind'].toUpperCase() === 'CATEGORY';\n  });\n  return !!categories.length;\n}\n\n/**\n * Whether or not the category is collapsible.\n *\n * @param categoryInfo Object holing information for creating a category.\n * @returns True if the category has subcategories.\n * @internal\n */\nexport function isCategoryCollapsible(categoryInfo: CategoryInfo): boolean {\n  if (!categoryInfo || !(categoryInfo as AnyDuringMigration)['contents']) {\n    return false;\n  }\n\n  const categories = (categoryInfo as AnyDuringMigration)['contents'].filter(\n    function (item: AnyDuringMigration) {\n      return item['kind'].toUpperCase() === 'CATEGORY';\n    },\n  );\n  return !!categories.length;\n}\n\n/**\n * Parses the provided toolbox definition into a consistent format.\n *\n * @param toolboxDef The definition of the toolbox in one of its many forms.\n * @returns Object holding information for creating a toolbox.\n */\nfunction convertToToolboxJson(toolboxDef: Node): ToolboxInfo {\n  const contents = xmlToJsonArray(toolboxDef as Node | Node[]);\n  const toolboxJson = {'contents': contents};\n  if (toolboxDef instanceof Node) {\n    addAttributes(toolboxDef, toolboxJson);\n  }\n  return toolboxJson;\n}\n\n/**\n * Converts the xml for a toolbox to JSON.\n *\n * @param toolboxDef The definition of the toolbox in one of its many forms.\n * @returns A list of objects in the toolbox.\n */\nfunction xmlToJsonArray(\n  toolboxDef: Node | Node[] | NodeList,\n): FlyoutItemInfoArray | ToolboxItemInfo[] {\n  const arr = [];\n  // If it is a node it will have children.\n  // AnyDuringMigration because:  Property 'childNodes' does not exist on type\n  // 'Node | NodeList | Node[]'.\n  let childNodes = (toolboxDef as AnyDuringMigration).childNodes;\n  if (!childNodes) {\n    // Otherwise the toolboxDef is an array or collection.\n    childNodes = toolboxDef;\n  }\n  for (let i = 0, child; (child = childNodes[i]); i++) {\n    if (!child.tagName) {\n      continue;\n    }\n    const obj = {};\n    const tagName = child.tagName.toUpperCase();\n    (obj as AnyDuringMigration)['kind'] = tagName;\n\n    // Store the XML for a block.\n    if (tagName === 'BLOCK') {\n      (obj as AnyDuringMigration)['blockxml'] = child;\n    } else if (child.childNodes && child.childNodes.length > 0) {\n      // Get the contents of a category\n      (obj as AnyDuringMigration)['contents'] = xmlToJsonArray(child);\n    }\n\n    // Add XML attributes to object\n    addAttributes(child, obj);\n    arr.push(obj);\n  }\n  // AnyDuringMigration because:  Type '{}[]' is not assignable to type\n  // 'ToolboxItemInfo[] | FlyoutItemInfoArray'.\n  return arr as AnyDuringMigration;\n}\n\n/**\n * Adds the attributes on the node to the given object.\n *\n * @param node The node to copy the attributes from.\n * @param obj The object to copy the attributes to.\n */\nfunction addAttributes(node: Node, obj: AnyDuringMigration) {\n  // AnyDuringMigration because:  Property 'attributes' does not exist on type\n  // 'Node'.\n  for (let j = 0; j < (node as AnyDuringMigration).attributes.length; j++) {\n    // AnyDuringMigration because:  Property 'attributes' does not exist on type\n    // 'Node'.\n    const attr = (node as AnyDuringMigration).attributes[j];\n    if (attr.nodeName.indexOf('css-') > -1) {\n      obj['cssconfig'] = obj['cssconfig'] || {};\n      obj['cssconfig'][attr.nodeName.replace('css-', '')] = attr.value;\n    } else {\n      obj[attr.nodeName] = attr.value;\n    }\n  }\n}\n\n/**\n * Parse the provided toolbox tree into a consistent DOM format.\n *\n * @param toolboxDef DOM tree of blocks, or text representation of same.\n * @returns DOM tree of blocks, or null.\n */\nexport function parseToolboxTree(\n  toolboxDef: Element | null | string,\n): Element | null {\n  let parsedToolboxDef: Element | null = null;\n  if (toolboxDef) {\n    if (typeof toolboxDef === 'string') {\n      parsedToolboxDef = utilsXml.textToDom(toolboxDef);\n      if (parsedToolboxDef.nodeName.toLowerCase() !== 'xml') {\n        throw TypeError('Toolbox should be an  document.');\n      }\n    } else if (toolboxDef instanceof Element) {\n      parsedToolboxDef = toolboxDef;\n    }\n  }\n  return parsedToolboxDef;\n}\n\nexport const TEST_ONLY = {\n  hasCategoriesInternal,\n};\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.uiPosition\n\nimport type {UiMetrics} from './metrics_manager.js';\nimport {Scrollbar} from './scrollbar.js';\nimport {Rect} from './utils/rect.js';\nimport type {Size} from './utils/size.js';\nimport * as toolbox from './utils/toolbox.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\n\n/**\n * Enum for vertical positioning.\n *\n * @internal\n */\nexport enum verticalPosition {\n  TOP,\n  BOTTOM,\n}\n\n/**\n * Enum for horizontal positioning.\n *\n * @internal\n */\nexport enum horizontalPosition {\n  LEFT,\n  RIGHT,\n}\n\n/**\n * An object defining a horizontal and vertical positioning.\n *\n * @internal\n */\nexport interface Position {\n  horizontal: horizontalPosition;\n  vertical: verticalPosition;\n}\n\n/**\n * Enum for bump rules to use for dealing with collisions.\n *\n * @internal\n */\nexport enum bumpDirection {\n  UP,\n  DOWN,\n}\n\n/**\n * Returns a rectangle representing reasonable position for where to place a UI\n * element of the specified size given the restraints and locations of the\n * scrollbars. This method does not take into account any already placed UI\n * elements.\n *\n * @param position The starting horizontal and vertical position.\n * @param size the size of the UI element to get a start position for.\n * @param horizontalPadding The horizontal padding to use.\n * @param verticalPadding The vertical padding to use.\n * @param metrics The workspace UI metrics.\n * @param workspace The workspace.\n * @returns The suggested start position.\n * @internal\n */\nexport function getStartPositionRect(\n  position: Position,\n  size: Size,\n  horizontalPadding: number,\n  verticalPadding: number,\n  metrics: UiMetrics,\n  workspace: WorkspaceSvg,\n): Rect {\n  // Horizontal positioning.\n  let left = 0;\n  const hasVerticalScrollbar =\n    workspace.scrollbar && workspace.scrollbar.canScrollVertically();\n  if (position.horizontal === horizontalPosition.LEFT) {\n    left = metrics.absoluteMetrics.left + horizontalPadding;\n    if (hasVerticalScrollbar && workspace.RTL) {\n      left += Scrollbar.scrollbarThickness;\n    }\n  } else {\n    // position.horizontal === horizontalPosition.RIGHT\n    left =\n      metrics.absoluteMetrics.left +\n      metrics.viewMetrics.width -\n      size.width -\n      horizontalPadding;\n    if (hasVerticalScrollbar && !workspace.RTL) {\n      left -= Scrollbar.scrollbarThickness;\n    }\n  }\n  // Vertical positioning.\n  let top = 0;\n  if (position.vertical === verticalPosition.TOP) {\n    top = metrics.absoluteMetrics.top + verticalPadding;\n  } else {\n    // position.vertical === verticalPosition.BOTTOM\n    top =\n      metrics.absoluteMetrics.top +\n      metrics.viewMetrics.height -\n      size.height -\n      verticalPadding;\n    if (workspace.scrollbar && workspace.scrollbar.canScrollHorizontally()) {\n      // The scrollbars are always positioned on the bottom if they exist.\n      top -= Scrollbar.scrollbarThickness;\n    }\n  }\n  return new Rect(top, top + size.height, left, left + size.width);\n}\n\n/**\n * Returns a corner position that is on the opposite side of the workspace from\n * the toolbox.\n * If in horizontal orientation, defaults to the bottom corner. If in vertical\n * orientation, defaults to the right corner.\n *\n * @param workspace The workspace.\n * @param metrics The workspace metrics.\n * @returns The suggested corner position.\n * @internal\n */\nexport function getCornerOppositeToolbox(\n  workspace: WorkspaceSvg,\n  metrics: UiMetrics,\n): Position {\n  const leftCorner =\n    metrics.toolboxMetrics.position !== toolbox.Position.LEFT &&\n    (!workspace.horizontalLayout || workspace.RTL);\n  const topCorner = metrics.toolboxMetrics.position === toolbox.Position.BOTTOM;\n  const hPosition = leftCorner\n    ? horizontalPosition.LEFT\n    : horizontalPosition.RIGHT;\n  const vPosition = topCorner ? verticalPosition.TOP : verticalPosition.BOTTOM;\n  return {horizontal: hPosition, vertical: vPosition};\n}\n\n/**\n * Returns a position Rect based on a starting position that is bumped\n * so that it doesn't intersect with any of the provided savedPositions. This\n * method does not check that the bumped position is still within bounds.\n *\n * @param startRect The starting position to use.\n * @param margin The margin to use between elements when bumping.\n * @param bumpDir The direction to bump if there is a collision with an existing\n *     UI element.\n * @param savedPositions List of rectangles that represent the positions of UI\n *     elements already placed.\n * @returns The suggested position rectangle.\n * @internal\n */\nexport function bumpPositionRect(\n  startRect: Rect,\n  margin: number,\n  bumpDir: bumpDirection,\n  savedPositions: Rect[],\n): Rect {\n  let top = startRect.top;\n  const left = startRect.left;\n  const width = startRect.right - startRect.left;\n  const height = startRect.bottom - startRect.top;\n\n  // Check for collision and bump if needed.\n  let boundingRect = startRect;\n  for (let i = 0; i < savedPositions.length; i++) {\n    const otherEl = savedPositions[i];\n    if (boundingRect.intersects(otherEl)) {\n      if (bumpDir === bumpDirection.UP) {\n        top = otherEl.top - height - margin;\n      } else {\n        // bumpDir === bumpDirection.DOWN\n        top = otherEl.bottom + margin;\n      }\n      // Recheck other savedPositions\n      boundingRect = new Rect(top, top + height, left, left + width);\n      i = -1;\n    }\n  }\n  return boundingRect;\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.dialog\n\nlet alertImplementation = function (\n  message: string,\n  opt_callback?: () => void,\n) {\n  window.alert(message);\n  if (opt_callback) {\n    opt_callback();\n  }\n};\n\nlet confirmImplementation = function (\n  message: string,\n  callback: (result: boolean) => void,\n) {\n  callback(window.confirm(message));\n};\n\nlet promptImplementation = function (\n  message: string,\n  defaultValue: string,\n  callback: (result: string | null) => void,\n) {\n  callback(window.prompt(message, defaultValue));\n};\n\n/**\n * Wrapper to window.alert() that app developers may override via setAlert to\n * provide alternatives to the modal browser window.\n *\n * @param message The message to display to the user.\n * @param opt_callback The callback when the alert is dismissed.\n */\nexport function alert(message: string, opt_callback?: () => void) {\n  alertImplementation(message, opt_callback);\n}\n\n/**\n * Sets the function to be run when Blockly.dialog.alert() is called.\n *\n * @param alertFunction The function to be run.\n * @see Blockly.dialog.alert\n */\nexport function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {\n  alertImplementation = alertFunction;\n}\n\n/**\n * Wrapper to window.confirm() that app developers may override via setConfirm\n * to provide alternatives to the modal browser window.\n *\n * @param message The message to display to the user.\n * @param callback The callback for handling user response.\n */\nexport function confirm(message: string, callback: (p1: boolean) => void) {\n  TEST_ONLY.confirmInternal(message, callback);\n}\n\n/**\n * Private version of confirm for stubbing in tests.\n */\nfunction confirmInternal(message: string, callback: (p1: boolean) => void) {\n  confirmImplementation(message, callback);\n}\n\n/**\n * Sets the function to be run when Blockly.dialog.confirm() is called.\n *\n * @param confirmFunction The function to be run.\n * @see Blockly.dialog.confirm\n */\nexport function setConfirm(\n  confirmFunction: (p1: string, p2: (p1: boolean) => void) => void,\n) {\n  confirmImplementation = confirmFunction;\n}\n\n/**\n * Wrapper to window.prompt() that app developers may override via setPrompt to\n * provide alternatives to the modal browser window. Built-in browser prompts\n * are often used for better text input experience on mobile device. We strongly\n * recommend testing mobile when overriding this.\n *\n * @param message The message to display to the user.\n * @param defaultValue The value to initialize the prompt with.\n * @param callback The callback for handling user response.\n */\nexport function prompt(\n  message: string,\n  defaultValue: string,\n  callback: (p1: string | null) => void,\n) {\n  promptImplementation(message, defaultValue, callback);\n}\n\n/**\n * Sets the function to be run when Blockly.dialog.prompt() is called.\n *\n * @param promptFunction The function to be run.\n * @see Blockly.dialog.prompt\n */\nexport function setPrompt(\n  promptFunction: (\n    p1: string,\n    p2: string,\n    p3: (p1: string | null) => void,\n  ) => void,\n) {\n  promptImplementation = promptFunction;\n}\n\nexport const TEST_ONLY = {\n  confirmInternal,\n};\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type {VariableModel} from '../variable_model.js';\nimport {IParameterModel} from './i_parameter_model.js';\n\n/** Interface for a parameter model that holds a variable model. */\nexport interface IVariableBackedParameterModel extends IParameterModel {\n  /** Returns the variable model held by this type. */\n  getVariableModel(): VariableModel;\n}\n\n/**\n * Returns whether the given object is a variable holder or not.\n */\nexport function isVariableBackedParameterModel(\n  param: IParameterModel,\n): param is IVariableBackedParameterModel {\n  return (param as any).getVariableModel !== undefined;\n}\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Legacy means of representing a procedure signature. The elements are\n * respectively: name, parameter names, and whether it has a return value.\n */\nexport type ProcedureTuple = [string, string[], boolean];\n\n/**\n * Procedure block type.\n *\n * @internal\n */\nexport interface ProcedureBlock {\n  getProcedureCall: () => string;\n  renameProcedure: (p1: string, p2: string) => void;\n  getProcedureDef: () => ProcedureTuple;\n}\n\n/** @internal */\nexport interface LegacyProcedureDefBlock {\n  getProcedureDef: () => ProcedureTuple;\n}\n\n/** @internal */\nexport function isLegacyProcedureDefBlock(\n  block: Object,\n): block is LegacyProcedureDefBlock {\n  return (block as any).getProcedureDef !== undefined;\n}\n\n/** @internal */\nexport interface LegacyProcedureCallBlock {\n  getProcedureCall: () => string;\n  renameProcedure: (p1: string, p2: string) => void;\n}\n\n/** @internal */\nexport function isLegacyProcedureCallBlock(\n  block: Object,\n): block is LegacyProcedureCallBlock {\n  return (\n    (block as any).getProcedureCall !== undefined &&\n    (block as any).renameProcedure !== undefined\n  );\n}\n","/**\n * @license\n * Copyright 2012 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Variables\n\nimport {Blocks} from './blocks.js';\nimport * as dialog from './dialog.js';\nimport {isVariableBackedParameterModel} from './interfaces/i_variable_backed_parameter_model.js';\nimport {Msg} from './msg.js';\nimport {isLegacyProcedureDefBlock} from './interfaces/i_legacy_procedure_blocks.js';\nimport * as utilsXml from './utils/xml.js';\nimport {VariableModel} from './variable_model.js';\nimport type {Workspace} from './workspace.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\n\n/**\n * String for use in the \"custom\" attribute of a category in toolbox XML.\n * This string indicates that the category should be dynamically populated with\n * variable blocks.\n * See also Blockly.Procedures.CATEGORY_NAME and\n * Blockly.VariablesDynamic.CATEGORY_NAME.\n */\nexport const CATEGORY_NAME = 'VARIABLE';\n\n/**\n * Find all user-created variables that are in use in the workspace.\n * For use by generators.\n * To get a list of all variables on a workspace, including unused variables,\n * call Workspace.getAllVariables.\n *\n * @param ws The workspace to search for variables.\n * @returns Array of variable models.\n */\nexport function allUsedVarModels(ws: Workspace): VariableModel[] {\n  const blocks = ws.getAllBlocks(false);\n  const variables = new Set();\n  // Iterate through every block and add each variable to the set.\n  for (let i = 0; i < blocks.length; i++) {\n    const blockVariables = blocks[i].getVarModels();\n    if (blockVariables) {\n      for (let j = 0; j < blockVariables.length; j++) {\n        const variable = blockVariables[j];\n        const id = variable.getId();\n        if (id) {\n          variables.add(variable);\n        }\n      }\n    }\n  }\n  // Convert the set into a list.\n  return Array.from(variables.values());\n}\n\n/**\n * Find all developer variables used by blocks in the workspace.\n * Developer variables are never shown to the user, but are declared as global\n * variables in the generated code.\n * To declare developer variables, define the getDeveloperVariables function on\n * your block and return a list of variable names.\n * For use by generators.\n *\n * @param workspace The workspace to search.\n * @returns A list of non-duplicated variable names.\n */\nexport function allDeveloperVariables(workspace: Workspace): string[] {\n  const blocks = workspace.getAllBlocks(false);\n  const variables = new Set();\n  for (let i = 0, block; (block = blocks[i]); i++) {\n    const getDeveloperVariables = block.getDeveloperVariables;\n    if (getDeveloperVariables) {\n      const devVars = getDeveloperVariables();\n      for (let j = 0; j < devVars.length; j++) {\n        variables.add(devVars[j]);\n      }\n    }\n  }\n  // Convert the set into a list.\n  return Array.from(variables.values());\n}\n\n/**\n * Construct the elements (blocks and button) required by the flyout for the\n * variable category.\n *\n * @param workspace The workspace containing variables.\n * @returns Array of XML elements.\n */\nexport function flyoutCategory(workspace: WorkspaceSvg): Element[] {\n  let xmlList = new Array();\n  const button = document.createElement('button');\n  button.setAttribute('text', '%{BKY_NEW_VARIABLE}');\n  button.setAttribute('callbackKey', 'CREATE_VARIABLE');\n\n  workspace.registerButtonCallback('CREATE_VARIABLE', function (button) {\n    createVariableButtonHandler(button.getTargetWorkspace());\n  });\n\n  xmlList.push(button);\n\n  const blockList = flyoutCategoryBlocks(workspace);\n  xmlList = xmlList.concat(blockList);\n  return xmlList;\n}\n\n/**\n * Construct the blocks required by the flyout for the variable category.\n *\n * @param workspace The workspace containing variables.\n * @returns Array of XML block elements.\n */\nexport function flyoutCategoryBlocks(workspace: Workspace): Element[] {\n  const variableModelList = workspace.getVariablesOfType('');\n\n  const xmlList = [];\n  if (variableModelList.length > 0) {\n    // New variables are added to the end of the variableModelList.\n    const mostRecentVariable = variableModelList[variableModelList.length - 1];\n    if (Blocks['variables_set']) {\n      const block = utilsXml.createElement('block');\n      block.setAttribute('type', 'variables_set');\n      block.setAttribute('gap', Blocks['math_change'] ? '8' : '24');\n      block.appendChild(generateVariableFieldDom(mostRecentVariable));\n      xmlList.push(block);\n    }\n    if (Blocks['math_change']) {\n      const block = utilsXml.createElement('block');\n      block.setAttribute('type', 'math_change');\n      block.setAttribute('gap', Blocks['variables_get'] ? '20' : '8');\n      block.appendChild(generateVariableFieldDom(mostRecentVariable));\n      const value = utilsXml.textToDom(\n        '' +\n          '' +\n          '1' +\n          '' +\n          '',\n      );\n      block.appendChild(value);\n      xmlList.push(block);\n    }\n\n    if (Blocks['variables_get']) {\n      variableModelList.sort(VariableModel.compareByName);\n      for (let i = 0, variable; (variable = variableModelList[i]); i++) {\n        const block = utilsXml.createElement('block');\n        block.setAttribute('type', 'variables_get');\n        block.setAttribute('gap', '8');\n        block.appendChild(generateVariableFieldDom(variable));\n        xmlList.push(block);\n      }\n    }\n  }\n  return xmlList;\n}\n\nexport const VAR_LETTER_OPTIONS = 'ijkmnopqrstuvwxyzabcdefgh';\n\n/**\n * Return a new variable name that is not yet being used. This will try to\n * generate single letter variable names in the range 'i' to 'z' to start with.\n * If no unique name is located it will try 'i' to 'z', 'a' to 'h',\n * then 'i2' to 'z2' etc.  Skip 'l'.\n *\n * @param workspace The workspace to be unique in.\n * @returns New variable name.\n */\nexport function generateUniqueName(workspace: Workspace): string {\n  return TEST_ONLY.generateUniqueNameInternal(workspace);\n}\n\n/**\n * Private version of generateUniqueName for stubbing in tests.\n */\nfunction generateUniqueNameInternal(workspace: Workspace): string {\n  return generateUniqueNameFromOptions(\n    VAR_LETTER_OPTIONS.charAt(0),\n    workspace.getAllVariableNames(),\n  );\n}\n\n/**\n * Returns a unique name that is not present in the usedNames array. This\n * will try to generate single letter names in the range a - z (skip l). It\n * will start with the character passed to startChar.\n *\n * @param startChar The character to start the search at.\n * @param usedNames A list of all of the used names.\n * @returns A unique name that is not present in the usedNames array.\n */\nexport function generateUniqueNameFromOptions(\n  startChar: string,\n  usedNames: string[],\n): string {\n  if (!usedNames.length) {\n    return startChar;\n  }\n\n  const letters = VAR_LETTER_OPTIONS;\n  let suffix = '';\n  let letterIndex = letters.indexOf(startChar);\n  let potName = startChar;\n\n  // eslint-disable-next-line no-constant-condition\n  while (true) {\n    let inUse = false;\n    for (let i = 0; i < usedNames.length; i++) {\n      if (usedNames[i].toLowerCase() === potName) {\n        inUse = true;\n        break;\n      }\n    }\n    if (!inUse) {\n      break;\n    }\n\n    letterIndex++;\n    if (letterIndex === letters.length) {\n      // Reached the end of the character sequence so back to 'i'.\n      letterIndex = 0;\n      suffix = `${Number(suffix) + 1}`;\n    }\n    potName = letters.charAt(letterIndex) + suffix;\n  }\n  return potName;\n}\n\n/**\n * Handles \"Create Variable\" button in the default variables toolbox category.\n * It will prompt the user for a variable name, including re-prompts if a name\n * is already in use among the workspace's variables.\n *\n * Custom button handlers can delegate to this function, allowing variables\n * types and after-creation processing. More complex customization (e.g.,\n * prompting for variable type) is beyond the scope of this function.\n *\n * @param workspace The workspace on which to create the variable.\n * @param opt_callback A callback. It will be passed an acceptable new variable\n *     name, or null if change is to be aborted (cancel button), or undefined if\n *     an existing variable was chosen.\n * @param opt_type The type of the variable like 'int', 'string', or ''. This\n *     will default to '', which is a specific type.\n */\nexport function createVariableButtonHandler(\n  workspace: Workspace,\n  opt_callback?: (p1?: string | null) => void,\n  opt_type?: string,\n) {\n  const type = opt_type || '';\n  // This function needs to be named so it can be called recursively.\n  function promptAndCheckWithAlert(defaultName: string) {\n    promptName(Msg['NEW_VARIABLE_TITLE'], defaultName, function (text) {\n      if (!text) {\n        // User canceled prompt.\n        if (opt_callback) opt_callback(null);\n        return;\n      }\n\n      const existing = nameUsedWithAnyType(text, workspace);\n      if (!existing) {\n        // No conflict\n        workspace.createVariable(text, type);\n        if (opt_callback) opt_callback(text);\n        return;\n      }\n\n      let msg;\n      if (existing.type === type) {\n        msg = Msg['VARIABLE_ALREADY_EXISTS'].replace('%1', existing.name);\n      } else {\n        msg = Msg['VARIABLE_ALREADY_EXISTS_FOR_ANOTHER_TYPE'];\n        msg = msg.replace('%1', existing.name).replace('%2', existing.type);\n      }\n      dialog.alert(msg, function () {\n        promptAndCheckWithAlert(text);\n      });\n    });\n  }\n  promptAndCheckWithAlert('');\n}\n\n/**\n * Opens a prompt that allows the user to enter a new name for a variable.\n * Triggers a rename if the new name is valid. Or re-prompts if there is a\n * collision.\n *\n * @param workspace The workspace on which to rename the variable.\n * @param variable Variable to rename.\n * @param opt_callback A callback. It will be passed an acceptable new variable\n *     name, or null if change is to be aborted (cancel button), or undefined if\n *     an existing variable was chosen.\n */\nexport function renameVariable(\n  workspace: Workspace,\n  variable: VariableModel,\n  opt_callback?: (p1?: string | null) => void,\n) {\n  // This function needs to be named so it can be called recursively.\n  function promptAndCheckWithAlert(defaultName: string) {\n    const promptText = Msg['RENAME_VARIABLE_TITLE'].replace(\n      '%1',\n      variable.name,\n    );\n    promptName(promptText, defaultName, function (newName) {\n      if (!newName) {\n        // User canceled prompt.\n        if (opt_callback) opt_callback(null);\n        return;\n      }\n\n      const existing = nameUsedWithOtherType(newName, variable.type, workspace);\n      const procedure = nameUsedWithConflictingParam(\n        variable.name,\n        newName,\n        workspace,\n      );\n      if (!existing && !procedure) {\n        // No conflict.\n        workspace.renameVariableById(variable.getId(), newName);\n        if (opt_callback) opt_callback(newName);\n        return;\n      }\n\n      let msg = '';\n      if (existing) {\n        msg = Msg['VARIABLE_ALREADY_EXISTS_FOR_ANOTHER_TYPE']\n          .replace('%1', existing.name)\n          .replace('%2', existing.type);\n      } else if (procedure) {\n        msg = Msg['VARIABLE_ALREADY_EXISTS_FOR_A_PARAMETER']\n          .replace('%1', newName)\n          .replace('%2', procedure);\n      }\n      dialog.alert(msg, function () {\n        promptAndCheckWithAlert(newName);\n      });\n    });\n  }\n  promptAndCheckWithAlert('');\n}\n\n/**\n * Prompt the user for a new variable name.\n *\n * @param promptText The string of the prompt.\n * @param defaultText The default value to show in the prompt's field.\n * @param callback A callback. It will be passed the new variable name, or null\n *     if the user picked something illegal.\n */\nexport function promptName(\n  promptText: string,\n  defaultText: string,\n  callback: (p1: string | null) => void,\n) {\n  dialog.prompt(promptText, defaultText, function (newVar) {\n    // Merge runs of whitespace.  Strip leading and trailing whitespace.\n    // Beyond this, all names are legal.\n    if (newVar) {\n      newVar = newVar.replace(/[\\s\\xa0]+/g, ' ').trim();\n      if (newVar === Msg['RENAME_VARIABLE'] || newVar === Msg['NEW_VARIABLE']) {\n        // Ok, not ALL names are legal...\n        newVar = null;\n      }\n    }\n    callback(newVar);\n  });\n}\n/**\n * Check whether there exists a variable with the given name but a different\n * type.\n *\n * @param name The name to search for.\n * @param type The type to exclude from the search.\n * @param workspace The workspace to search for the variable.\n * @returns The variable with the given name and a different type, or null if\n *     none was found.\n */\nfunction nameUsedWithOtherType(\n  name: string,\n  type: string,\n  workspace: Workspace,\n): VariableModel | null {\n  const allVariables = workspace.getVariableMap().getAllVariables();\n\n  name = name.toLowerCase();\n  for (let i = 0, variable; (variable = allVariables[i]); i++) {\n    if (variable.name.toLowerCase() === name && variable.type !== type) {\n      return variable;\n    }\n  }\n  return null;\n}\n\n/**\n * Check whether there exists a variable with the given name of any type.\n *\n * @param name The name to search for.\n * @param workspace The workspace to search for the variable.\n * @returns The variable with the given name, or null if none was found.\n */\nexport function nameUsedWithAnyType(\n  name: string,\n  workspace: Workspace,\n): VariableModel | null {\n  const allVariables = workspace.getVariableMap().getAllVariables();\n\n  name = name.toLowerCase();\n  for (let i = 0, variable; (variable = allVariables[i]); i++) {\n    if (variable.name.toLowerCase() === name) {\n      return variable;\n    }\n  }\n  return null;\n}\n\n/**\n * Returns the name of the procedure with a conflicting parameter name, or null\n * if one does not exist.\n *\n * This checks the procedure map if it contains models, and the legacy procedure\n * blocks otherwise.\n *\n * @param oldName The old name of the variable.\n * @param newName The proposed name of the variable.\n * @param workspace The workspace to search for conflicting parameters.\n * @internal\n */\nexport function nameUsedWithConflictingParam(\n  oldName: string,\n  newName: string,\n  workspace: Workspace,\n): string | null {\n  return workspace.getProcedureMap().getProcedures().length\n    ? checkForConflictingParamWithProcedureModels(oldName, newName, workspace)\n    : checkForConflictingParamWithLegacyProcedures(oldName, newName, workspace);\n}\n\n/**\n * Returns the name of the procedure model with a conflicting param name, or\n * null if one does not exist.\n */\nfunction checkForConflictingParamWithProcedureModels(\n  oldName: string,\n  newName: string,\n  workspace: Workspace,\n): string | null {\n  oldName = oldName.toLowerCase();\n  newName = newName.toLowerCase();\n\n  const procedures = workspace.getProcedureMap().getProcedures();\n  for (const procedure of procedures) {\n    const params = procedure\n      .getParameters()\n      .filter(isVariableBackedParameterModel)\n      .map((param) => param.getVariableModel().name);\n    if (!params) continue;\n    const procHasOld = params.some((param) => param.toLowerCase() === oldName);\n    const procHasNew = params.some((param) => param.toLowerCase() === newName);\n    if (procHasOld && procHasNew) return procedure.getName();\n  }\n  return null;\n}\n\n/**\n * Returns the name of the procedure block with a conflicting param name, or\n * null if one does not exist.\n */\nfunction checkForConflictingParamWithLegacyProcedures(\n  oldName: string,\n  newName: string,\n  workspace: Workspace,\n): string | null {\n  oldName = oldName.toLowerCase();\n  newName = newName.toLowerCase();\n\n  const blocks = workspace.getAllBlocks(false);\n  for (const block of blocks) {\n    if (!isLegacyProcedureDefBlock(block)) continue;\n    const def = block.getProcedureDef();\n    const params = def[1];\n    const blockHasOld = params.some((param) => param.toLowerCase() === oldName);\n    const blockHasNew = params.some((param) => param.toLowerCase() === newName);\n    if (blockHasOld && blockHasNew) return def[0];\n  }\n  return null;\n}\n\n/**\n * Generate DOM objects representing a variable field.\n *\n * @param variableModel The variable model to represent.\n * @returns The generated DOM.\n */\nexport function generateVariableFieldDom(\n  variableModel: VariableModel,\n): Element {\n  /* Generates the following XML:\n   * foo\n   */\n  const field = utilsXml.createElement('field');\n  field.setAttribute('name', 'VAR');\n  field.setAttribute('id', variableModel.getId());\n  field.setAttribute('variabletype', variableModel.type);\n  const name = utilsXml.createTextNode(variableModel.name);\n  field.appendChild(name);\n  return field;\n}\n\n/**\n * Helper function to look up or create a variable on the given workspace.\n * If no variable exists, creates and returns it.\n *\n * @param workspace The workspace to search for the variable.  It may be a\n *     flyout workspace or main workspace.\n * @param id The ID to use to look up or create the variable, or null.\n * @param opt_name The string to use to look up or create the variable.\n * @param opt_type The type to use to look up or create the variable.\n * @returns The variable corresponding to the given ID or name + type\n *     combination.\n */\nexport function getOrCreateVariablePackage(\n  workspace: Workspace,\n  id: string | null,\n  opt_name?: string,\n  opt_type?: string,\n): VariableModel {\n  let variable = getVariable(workspace, id, opt_name, opt_type);\n  if (!variable) {\n    variable = createVariable(workspace, id, opt_name, opt_type);\n  }\n  return variable;\n}\n\n/**\n * Look up  a variable on the given workspace.\n * Always looks in the main workspace before looking in the flyout workspace.\n * Always prefers lookup by ID to lookup by name + type.\n *\n * @param workspace The workspace to search for the variable.  It may be a\n *     flyout workspace or main workspace.\n * @param id The ID to use to look up the variable, or null.\n * @param opt_name The string to use to look up the variable.\n *     Only used if lookup by ID fails.\n * @param opt_type The type to use to look up the variable.\n *     Only used if lookup by ID fails.\n * @returns The variable corresponding to the given ID or name + type\n *     combination, or null if not found.\n */\nexport function getVariable(\n  workspace: Workspace,\n  id: string | null,\n  opt_name?: string,\n  opt_type?: string,\n): VariableModel | null {\n  const potentialVariableMap = workspace.getPotentialVariableMap();\n  let variable = null;\n  // Try to just get the variable, by ID if possible.\n  if (id) {\n    // Look in the real variable map before checking the potential variable map.\n    variable = workspace.getVariableById(id);\n    if (!variable && potentialVariableMap) {\n      variable = potentialVariableMap.getVariableById(id);\n    }\n    if (variable) {\n      return variable;\n    }\n  }\n  // If there was no ID, or there was an ID but it didn't match any variables,\n  // look up by name and type.\n  if (opt_name) {\n    if (opt_type === undefined) {\n      throw Error('Tried to look up a variable by name without a type');\n    }\n    // Otherwise look up by name and type.\n    variable = workspace.getVariable(opt_name, opt_type);\n    if (!variable && potentialVariableMap) {\n      variable = potentialVariableMap.getVariable(opt_name, opt_type);\n    }\n  }\n  return variable;\n}\n\n/**\n * Helper function to create a variable on the given workspace.\n *\n * @param workspace The workspace in which to create the variable.  It may be a\n *     flyout workspace or main workspace.\n * @param id The ID to use to create the variable, or null.\n * @param opt_name The string to use to create the variable.\n * @param opt_type The type to use to create the variable.\n * @returns The variable corresponding to the given ID or name + type\n *     combination.\n */\nfunction createVariable(\n  workspace: Workspace,\n  id: string | null,\n  opt_name?: string,\n  opt_type?: string,\n): VariableModel {\n  const potentialVariableMap = workspace.getPotentialVariableMap();\n  // Variables without names get uniquely named for this workspace.\n  if (!opt_name) {\n    const ws = workspace.isFlyout\n      ? (workspace as WorkspaceSvg).targetWorkspace\n      : workspace;\n    opt_name = generateUniqueName(ws!);\n  }\n\n  // Create a potential variable if in the flyout.\n  let variable = null;\n  if (potentialVariableMap) {\n    variable = potentialVariableMap.createVariable(opt_name, opt_type, id);\n  } else {\n    // In the main workspace, create a real variable.\n    variable = workspace.createVariable(opt_name, opt_type, id);\n  }\n  return variable;\n}\n\n/**\n * Helper function to get the list of variables that have been added to the\n * workspace after adding a new block, using the given list of variables that\n * were in the workspace before the new block was added.\n *\n * @param workspace The workspace to inspect.\n * @param originalVariables The array of variables that existed in the workspace\n *     before adding the new block.\n * @returns The new array of variables that were freshly added to the workspace\n *     after creating the new block, or [] if no new variables were added to the\n *     workspace.\n * @internal\n */\nexport function getAddedVariables(\n  workspace: Workspace,\n  originalVariables: VariableModel[],\n): VariableModel[] {\n  const allCurrentVariables = workspace.getAllVariables();\n  const addedVariables = [];\n  if (originalVariables.length !== allCurrentVariables.length) {\n    for (let i = 0; i < allCurrentVariables.length; i++) {\n      const variable = allCurrentVariables[i];\n      // For any variable that is present in allCurrentVariables but not\n      // present in originalVariables, add the variable to addedVariables.\n      if (originalVariables.indexOf(variable) === -1) {\n        addedVariables.push(variable);\n      }\n    }\n  }\n  return addedVariables;\n}\n\nexport const TEST_ONLY = {\n  generateUniqueNameInternal,\n};\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {ICopyable, ICopyData} from '../interfaces/i_copyable.js';\nimport type {IPaster} from '../interfaces/i_paster.js';\nimport * as registry from '../registry.js';\n\n/**\n * Registers the given paster so that it cna be used for pasting.\n *\n * @param type The type of the paster to register, e.g. 'block', 'comment', etc.\n * @param paster The paster to register.\n */\nexport function register>(\n  type: string,\n  paster: IPaster,\n) {\n  registry.register(registry.Type.PASTER, type, paster);\n}\n\n/**\n * Unregisters the paster associated with the given type.\n *\n * @param type The type of the paster to unregister.\n */\nexport function unregister(type: string) {\n  registry.unregister(registry.Type.PASTER, type);\n}\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {BlockSvg} from './block_svg.js';\nimport {Coordinate} from './utils/coordinate.js';\nimport * as userAgent from './utils/useragent.js';\n\n/** The set of all blocks in need of rendering which don't have parents. */\nconst rootBlocks = new Set();\n\n/** The set of all blocks in need of rendering. */\nlet dirtyBlocks = new WeakSet();\n\n/**\n * The promise which resolves after the current set of renders is completed. Or\n * null if there are no queued renders.\n *\n * Stored so that we can return it from afterQueuedRenders.\n */\nlet afterRendersPromise: Promise | null = null;\n\n/** The function to call to resolve the `afterRendersPromise`. */\nlet afterRendersResolver: (() => void) | null = null;\n\n/**\n * The ID of the current animation frame request. Used to cancel the request\n * if necessary.\n */\nlet animationRequestId = 0;\n\n/**\n * Registers that the given block and all of its parents need to be rerendered,\n * and registers a callback to do so after a delay, to allowf or batching.\n *\n * @param block The block to rerender.\n * @returns A promise that resolves after the currently queued renders have been\n *     completed. Used for triggering other behavior that relies on updated\n *     size/position location for the block.\n * @internal\n */\nexport function queueRender(block: BlockSvg): Promise {\n  queueBlock(block);\n\n  if (alwaysImmediatelyRender()) {\n    doRenders();\n    return Promise.resolve();\n  }\n\n  if (!afterRendersPromise) {\n    afterRendersPromise = new Promise((resolve) => {\n      afterRendersResolver = resolve;\n      animationRequestId = window.requestAnimationFrame(() => {\n        doRenders();\n        resolve();\n      });\n    });\n  }\n  return afterRendersPromise;\n}\n\n/**\n * @returns A promise that resolves after the currently queued renders have\n *     been completed.\n */\nexport function finishQueuedRenders(): Promise {\n  // If there are no queued renders, return a resolved promise so `then`\n  // callbacks trigger immediately.\n  return afterRendersPromise ? afterRendersPromise : Promise.resolve();\n}\n\n/**\n * Triggers an immediate render of all queued renders. Should only be used in\n * cases where queueing renders breaks functionality + backwards compatibility\n * (such as rendering icons).\n *\n * @internal\n */\nexport function triggerQueuedRenders() {\n  window.cancelAnimationFrame(animationRequestId);\n  doRenders();\n  if (afterRendersResolver) afterRendersResolver();\n}\n\n/**\n * @returns True if we should always trigger an immediate render.\n *     Some platforms don't properly support `requestAnimationFrame`, so to\n *     avoid glitchiness, we give up the performance improvements.\n */\nfunction alwaysImmediatelyRender() {\n  return userAgent.JavaFx;\n}\n\n/**\n * Adds the given block and its parents to the render queue. Adds the root block\n * to the list of root blocks.\n *\n * @param block The block to queue.\n */\nfunction queueBlock(block: BlockSvg) {\n  dirtyBlocks.add(block);\n  const parent = block.getParent();\n  if (parent) {\n    queueBlock(parent);\n  } else {\n    rootBlocks.add(block);\n  }\n}\n\n/**\n * Rerenders all of the blocks in the queue.\n */\nfunction doRenders() {\n  const workspaces = new Set([...rootBlocks].map((block) => block.workspace));\n  for (const block of rootBlocks) {\n    // No need to render a dead block.\n    if (block.isDisposed()) continue;\n    // A render for this block may have been queued, and then the block was\n    // connected to a parent, so it is no longer a root block.\n    // Rendering will be triggered through the real root block.\n    if (block.getParent()) continue;\n\n    renderBlock(block);\n    const blockOrigin = block.getRelativeToSurfaceXY();\n    updateConnectionLocations(block, blockOrigin);\n    updateIconLocations(block, blockOrigin);\n  }\n  for (const workspace of workspaces) {\n    workspace.resizeContents();\n  }\n\n  rootBlocks.clear();\n  dirtyBlocks = new Set();\n  afterRendersPromise = null;\n}\n\n/**\n * Recursively renders all of the dirty children of the given block, and\n * then renders the block.\n *\n * @param block The block to rerender.\n */\nfunction renderBlock(block: BlockSvg) {\n  if (!dirtyBlocks.has(block)) return;\n  for (const child of block.getChildren(false)) {\n    renderBlock(child);\n  }\n  block.renderEfficiently();\n}\n\n/**\n * Updates the connection database with the new locations of all of the\n * connections that are children of the given block.\n *\n * @param block The block to update the connection locations of.\n * @param blockOrigin The top left of the given block in workspace coordinates.\n */\nfunction updateConnectionLocations(block: BlockSvg, blockOrigin: Coordinate) {\n  for (const conn of block.getConnections_(false)) {\n    const moved = conn.moveToOffset(blockOrigin);\n    const target = conn.targetBlock();\n    if (!conn.isSuperior()) continue;\n    if (!target) continue;\n    if (moved || dirtyBlocks.has(target)) {\n      updateConnectionLocations(\n        target,\n        Coordinate.sum(blockOrigin, target.relativeCoords),\n      );\n    }\n  }\n}\n\n/**\n * Updates all icons that are children of the given block with their new\n * locations.\n *\n * @param block The block to update the icon locations of.\n */\nfunction updateIconLocations(block: BlockSvg, blockOrigin: Coordinate) {\n  if (!block.getIcons) return;\n  for (const icon of block.getIcons()) {\n    icon.onLocationChange(blockOrigin);\n  }\n  for (const child of block.getChildren(false)) {\n    updateIconLocations(\n      child,\n      Coordinate.sum(blockOrigin, child.relativeCoords),\n    );\n  }\n}\n","/**\n * @license\n * Copyright 2012 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Xml\n\nimport type {Block} from './block.js';\nimport type {BlockSvg} from './block_svg.js';\nimport type {Connection} from './connection.js';\nimport * as eventUtils from './events/utils.js';\nimport type {Field} from './field.js';\nimport {IconType} from './icons/icon_types.js';\nimport {inputTypes} from './inputs/input_types.js';\nimport * as dom from './utils/dom.js';\nimport {Size} from './utils/size.js';\nimport * as utilsXml from './utils/xml.js';\nimport type {VariableModel} from './variable_model.js';\nimport * as Variables from './variables.js';\nimport type {Workspace} from './workspace.js';\nimport {WorkspaceComment} from './workspace_comment.js';\nimport {WorkspaceCommentSvg} from './workspace_comment_svg.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\nimport * as renderManagement from './render_management.js';\n\n/**\n * Encode a block tree as XML.\n *\n * @param workspace The workspace containing blocks.\n * @param opt_noId True if the encoder should skip the block IDs.\n * @returns XML DOM element.\n */\nexport function workspaceToDom(\n  workspace: Workspace,\n  opt_noId?: boolean,\n): Element {\n  const treeXml = utilsXml.createElement('xml');\n  const variablesElement = variablesToDom(\n    Variables.allUsedVarModels(workspace),\n  );\n  if (variablesElement.hasChildNodes()) {\n    treeXml.appendChild(variablesElement);\n  }\n  const comments = workspace.getTopComments(true);\n  for (let i = 0; i < comments.length; i++) {\n    const comment = comments[i];\n    treeXml.appendChild(comment.toXmlWithXY(opt_noId));\n  }\n  const blocks = workspace.getTopBlocks(true);\n  for (let i = 0; i < blocks.length; i++) {\n    const block = blocks[i];\n    treeXml.appendChild(blockToDomWithXY(block, opt_noId));\n  }\n  return treeXml;\n}\n\n/**\n * Encode a list of variables as XML.\n *\n * @param variableList List of all variable models.\n * @returns Tree of XML elements.\n */\nexport function variablesToDom(variableList: VariableModel[]): Element {\n  const variables = utilsXml.createElement('variables');\n  for (let i = 0; i < variableList.length; i++) {\n    const variable = variableList[i];\n    const element = utilsXml.createElement('variable');\n    element.appendChild(utilsXml.createTextNode(variable.name));\n    if (variable.type) {\n      element.setAttribute('type', variable.type);\n    }\n    element.id = variable.getId();\n    variables.appendChild(element);\n  }\n  return variables;\n}\n\n/**\n * Encode a block subtree as XML with XY coordinates.\n *\n * @param block The root block to encode.\n * @param opt_noId True if the encoder should skip the block ID.\n * @returns Tree of XML elements or an empty document fragment if the block was\n *     an insertion marker.\n */\nexport function blockToDomWithXY(\n  block: Block,\n  opt_noId?: boolean,\n): Element | DocumentFragment {\n  if (block.isInsertionMarker()) {\n    // Skip over insertion markers.\n    block = block.getChildren(false)[0];\n    if (!block) {\n      // Disappears when appended.\n      return new DocumentFragment();\n    }\n  }\n\n  let width = 0; // Not used in LTR.\n  if (block.workspace.RTL) {\n    width = block.workspace.getWidth();\n  }\n\n  const element = blockToDom(block, opt_noId);\n  if (isElement(element)) {\n    const xy = block.getRelativeToSurfaceXY();\n    element.setAttribute(\n      'x',\n      String(Math.round(block.workspace.RTL ? width - xy.x : xy.x)),\n    );\n    element.setAttribute('y', String(Math.round(xy.y)));\n  }\n  return element;\n}\n\n/**\n * Encode a field as XML.\n *\n * @param field The field to encode.\n * @returns XML element, or null if the field did not need to be serialized.\n */\nfunction fieldToDom(field: Field): Element | null {\n  if (field.isSerializable()) {\n    const container = utilsXml.createElement('field');\n    container.setAttribute('name', field.name || '');\n    return field.toXml(container);\n  }\n  return null;\n}\n\n/**\n * Encode all of a block's fields as XML and attach them to the given tree of\n * XML elements.\n *\n * @param block A block with fields to be encoded.\n * @param element The XML element to which the field DOM should be attached.\n */\nfunction allFieldsToDom(block: Block, element: Element) {\n  for (let i = 0; i < block.inputList.length; i++) {\n    const input = block.inputList[i];\n    for (let j = 0; j < input.fieldRow.length; j++) {\n      const field = input.fieldRow[j];\n      const fieldDom = fieldToDom(field);\n      if (fieldDom) {\n        element.appendChild(fieldDom);\n      }\n    }\n  }\n}\n\n/**\n * Encode a block subtree as XML.\n *\n * @param block The root block to encode.\n * @param opt_noId True if the encoder should skip the block ID.\n * @returns Tree of XML elements or an empty document fragment if the block was\n *     an insertion marker.\n */\nexport function blockToDom(\n  block: Block,\n  opt_noId?: boolean,\n): Element | DocumentFragment {\n  // Skip over insertion markers.\n  if (block.isInsertionMarker()) {\n    const child = block.getChildren(false)[0];\n    if (child) {\n      return blockToDom(child);\n    } else {\n      // Disappears when appended.\n      return new DocumentFragment();\n    }\n  }\n\n  const element = utilsXml.createElement(block.isShadow() ? 'shadow' : 'block');\n  element.setAttribute('type', block.type);\n  if (!opt_noId) {\n    element.id = block.id;\n  }\n  if (block.mutationToDom) {\n    // Custom data for an advanced block.\n    const mutation = block.mutationToDom();\n    if (mutation && (mutation.hasChildNodes() || mutation.hasAttributes())) {\n      element.appendChild(mutation);\n    }\n  }\n\n  allFieldsToDom(block, element);\n\n  const commentText = block.getCommentText();\n  if (commentText) {\n    const comment = block.getIcon(IconType.COMMENT)!;\n    const size = comment.getBubbleSize();\n    const pinned = comment.bubbleIsVisible();\n\n    const commentElement = utilsXml.createElement('comment');\n    commentElement.appendChild(utilsXml.createTextNode(commentText));\n    commentElement.setAttribute('pinned', `${pinned}`);\n    commentElement.setAttribute('h', String(size.height));\n    commentElement.setAttribute('w', String(size.width));\n\n    element.appendChild(commentElement);\n  }\n\n  if (block.data) {\n    const dataElement = utilsXml.createElement('data');\n    dataElement.appendChild(utilsXml.createTextNode(block.data));\n    element.appendChild(dataElement);\n  }\n\n  for (let i = 0; i < block.inputList.length; i++) {\n    const input = block.inputList[i];\n    let container: Element;\n    let empty = true;\n    if (input.type === inputTypes.DUMMY || input.type === inputTypes.END_ROW) {\n      continue;\n    } else {\n      const childBlock = input.connection!.targetBlock();\n      if (input.type === inputTypes.VALUE) {\n        container = utilsXml.createElement('value');\n      } else if (input.type === inputTypes.STATEMENT) {\n        container = utilsXml.createElement('statement');\n      }\n      const childShadow = input.connection!.getShadowDom();\n      if (childShadow && (!childBlock || !childBlock.isShadow())) {\n        container!.appendChild(cloneShadow(childShadow, opt_noId));\n      }\n      if (childBlock) {\n        const childElem = blockToDom(childBlock, opt_noId);\n        if (childElem.nodeType === dom.NodeType.ELEMENT_NODE) {\n          container!.appendChild(childElem);\n          empty = false;\n        }\n      }\n    }\n    container!.setAttribute('name', input.name);\n    if (!empty) {\n      element.appendChild(container!);\n    }\n  }\n  if (\n    block.inputsInline !== undefined &&\n    block.inputsInline !== block.inputsInlineDefault\n  ) {\n    element.setAttribute('inline', String(block.inputsInline));\n  }\n  if (block.isCollapsed()) {\n    element.setAttribute('collapsed', 'true');\n  }\n  if (!block.isEnabled()) {\n    element.setAttribute('disabled', 'true');\n  }\n  if (!block.isDeletable() && !block.isShadow()) {\n    element.setAttribute('deletable', 'false');\n  }\n  if (!block.isMovable() && !block.isShadow()) {\n    element.setAttribute('movable', 'false');\n  }\n  if (!block.isEditable()) {\n    element.setAttribute('editable', 'false');\n  }\n\n  const nextBlock = block.getNextBlock();\n  let container: Element;\n  if (nextBlock) {\n    const nextElem = blockToDom(nextBlock, opt_noId);\n    if (nextElem.nodeType === dom.NodeType.ELEMENT_NODE) {\n      container = utilsXml.createElement('next');\n      container.appendChild(nextElem);\n      element.appendChild(container);\n    }\n  }\n  const nextShadow =\n    block.nextConnection && block.nextConnection.getShadowDom();\n  if (nextShadow && (!nextBlock || !nextBlock.isShadow())) {\n    container!.appendChild(cloneShadow(nextShadow, opt_noId));\n  }\n\n  return element;\n}\n\n/**\n * Deeply clone the shadow's DOM so that changes don't back-wash to the block.\n *\n * @param shadow A tree of XML elements.\n * @param opt_noId True if the encoder should skip the block ID.\n * @returns A tree of XML elements.\n */\nfunction cloneShadow(shadow: Element, opt_noId?: boolean): Element {\n  shadow = shadow.cloneNode(true) as Element;\n  // Walk the tree looking for whitespace.  Don't prune whitespace in a tag.\n  let node: Node | null = shadow;\n  let textNode;\n  while (node) {\n    if (opt_noId && node.nodeName === 'shadow') {\n      // Strip off IDs from shadow blocks.  There should never be a 'block' as\n      // a child of a 'shadow', so no need to check that.\n      (node as Element).removeAttribute('id');\n    }\n    if (node.firstChild) {\n      node = node.firstChild;\n    } else {\n      while (node && !node.nextSibling) {\n        textNode = node;\n        node = node.parentNode;\n        if (\n          textNode.nodeType === dom.NodeType.TEXT_NODE &&\n          (textNode as Text).data.trim() === '' &&\n          node?.firstChild !== textNode\n        ) {\n          // Prune whitespace after a tag.\n          dom.removeNode(textNode);\n        }\n      }\n      if (node) {\n        textNode = node;\n        node = node.nextSibling;\n        if (\n          textNode.nodeType === dom.NodeType.TEXT_NODE &&\n          (textNode as Text).data.trim() === ''\n        ) {\n          // Prune whitespace before a tag.\n          dom.removeNode(textNode);\n        }\n      }\n    }\n  }\n  return shadow;\n}\n\n/**\n * Converts a DOM structure into plain text.\n * Currently the text format is fairly ugly: all one line with no whitespace,\n * unless the DOM itself has whitespace built-in.\n *\n * @param dom A tree of XML nodes.\n * @returns Text representation.\n */\nexport function domToText(dom: Node): string {\n  const text = utilsXml.domToText(dom);\n  // Unpack self-closing tags.  These tags fail when embedded in HTML.\n  //  -> \n  return text.replace(/<(\\w+)([^<]*)\\/>/g, '<$1$2>');\n}\n\n/**\n * Converts a DOM structure into properly indented text.\n *\n * @param dom A tree of XML elements.\n * @returns Text representation.\n */\nexport function domToPrettyText(dom: Node): string {\n  // This function is not guaranteed to be correct for all XML.\n  // But it handles the XML that Blockly generates.\n  const blob = domToText(dom);\n  // Place every open and close tag on its own line.\n  const lines = blob.split('<');\n  // Indent every line.\n  let indent = '';\n  for (let i = 1; i < lines.length; i++) {\n    const line = lines[i];\n    if (line[0] === '/') {\n      indent = indent.substring(2);\n    }\n    lines[i] = indent + '<' + line;\n    if (line[0] !== '/' && line.slice(-2) !== '/>') {\n      indent += '  ';\n    }\n  }\n  // Pull simple tags back together.\n  // E.g. \n  let text = lines.join('\\n');\n  text = text.replace(/(<(\\w+)\\b[^>]*>[^\\n]*)\\n *<\\/\\2>/g, '$1');\n  // Trim leading blank line.\n  return text.replace(/^\\n/, '');\n}\n\n/**\n * Clear the given workspace then decode an XML DOM and\n * create blocks on the workspace.\n *\n * @param xml XML DOM.\n * @param workspace The workspace.\n * @returns An array containing new block IDs.\n */\nexport function clearWorkspaceAndLoadFromXml(\n  xml: Element,\n  workspace: WorkspaceSvg,\n): string[] {\n  workspace.setResizesEnabled(false);\n  workspace.clear();\n  const blockIds = domToWorkspace(xml, workspace);\n  workspace.setResizesEnabled(true);\n  return blockIds;\n}\n\n/**\n * Decode an XML DOM and create blocks on the workspace.\n *\n * @param xml XML DOM.\n * @param workspace The workspace.\n * @returns An array containing new block IDs.\n */\nexport function domToWorkspace(xml: Element, workspace: Workspace): string[] {\n  let width = 0; // Not used in LTR.\n  if (workspace.RTL) {\n    width = workspace.getWidth();\n  }\n  const newBlockIds = []; // A list of block IDs added by this call.\n  dom.startTextWidthCache();\n  const existingGroup = eventUtils.getGroup();\n  if (!existingGroup) {\n    eventUtils.setGroup(true);\n  }\n\n  // Disable workspace resizes as an optimization.\n  // Assume it is rendered so we can check.\n  if ((workspace as WorkspaceSvg).setResizesEnabled) {\n    (workspace as WorkspaceSvg).setResizesEnabled(false);\n  }\n  let variablesFirst = true;\n  try {\n    for (let i = 0, xmlChild; (xmlChild = xml.childNodes[i]); i++) {\n      const name = xmlChild.nodeName.toLowerCase();\n      const xmlChildElement = xmlChild as Element;\n      if (\n        name === 'block' ||\n        (name === 'shadow' && !eventUtils.getRecordUndo())\n      ) {\n        // Allow top-level shadow blocks if recordUndo is disabled since\n        // that means an undo is in progress.  Such a block is expected\n        // to be moved to a nested destination in the next operation.\n        const block = domToBlockInternal(xmlChildElement, workspace);\n        newBlockIds.push(block.id);\n        const blockX = parseInt(xmlChildElement.getAttribute('x') ?? '10', 10);\n        const blockY = parseInt(xmlChildElement.getAttribute('y') ?? '10', 10);\n        if (!isNaN(blockX) && !isNaN(blockY)) {\n          block.moveBy(workspace.RTL ? width - blockX : blockX, blockY, [\n            'create',\n          ]);\n        }\n        variablesFirst = false;\n      } else if (name === 'shadow') {\n        throw TypeError('Shadow block cannot be a top-level block.');\n      } else if (name === 'comment') {\n        if (workspace.rendered) {\n          WorkspaceCommentSvg.fromXmlRendered(\n            xmlChildElement,\n            workspace as WorkspaceSvg,\n            width,\n          );\n        } else {\n          WorkspaceComment.fromXml(xmlChildElement, workspace);\n        }\n      } else if (name === 'variables') {\n        if (variablesFirst) {\n          domToVariables(xmlChildElement, workspace);\n        } else {\n          throw Error(\n            \"'variables' tag must exist once before block and \" +\n              'shadow tag elements in the workspace XML, but it was found in ' +\n              'another location.',\n          );\n        }\n        variablesFirst = false;\n      }\n    }\n  } finally {\n    eventUtils.setGroup(existingGroup);\n    if ((workspace as WorkspaceSvg).setResizesEnabled) {\n      (workspace as WorkspaceSvg).setResizesEnabled(true);\n    }\n    if (workspace.rendered) renderManagement.triggerQueuedRenders();\n    dom.stopTextWidthCache();\n  }\n  // Re-enable workspace resizing.\n  eventUtils.fire(new (eventUtils.get(eventUtils.FINISHED_LOADING))(workspace));\n  return newBlockIds;\n}\n\n/**\n * Decode an XML DOM and create blocks on the workspace. Position the new\n * blocks immediately below prior blocks, aligned by their starting edge.\n *\n * @param xml The XML DOM.\n * @param workspace The workspace to add to.\n * @returns An array containing new block IDs.\n */\nexport function appendDomToWorkspace(\n  xml: Element,\n  workspace: WorkspaceSvg,\n): string[] {\n  // First check if we have a WorkspaceSvg, otherwise the blocks have no shape\n  // and the position does not matter.\n  // Assume it is rendered so we can check.\n  if (!(workspace as WorkspaceSvg).getBlocksBoundingBox) {\n    return domToWorkspace(xml, workspace);\n  }\n\n  const bbox = (workspace as WorkspaceSvg).getBlocksBoundingBox();\n  // Load the new blocks into the workspace and get the IDs of the new blocks.\n  const newBlockIds = domToWorkspace(xml, workspace);\n  if (bbox && bbox.top !== bbox.bottom) {\n    // Check if any previous block.\n    let offsetY = 0; // Offset to add to y of the new block.\n    let offsetX = 0;\n    const farY = bbox.bottom; // Bottom position.\n    const topX = workspace.RTL ? bbox.right : bbox.left; // X of bounding box.\n    // Check position of the new blocks.\n    let newLeftX = Infinity; // X of top left corner.\n    let newRightX = -Infinity; // X of top right corner.\n    let newY = Infinity; // Y of top corner.\n    const ySeparation = 10;\n    for (let i = 0; i < newBlockIds.length; i++) {\n      const blockXY = workspace\n        .getBlockById(newBlockIds[i])!\n        .getRelativeToSurfaceXY();\n      if (blockXY.y < newY) {\n        newY = blockXY.y;\n      }\n      if (blockXY.x < newLeftX) {\n        // if we left align also on x\n        newLeftX = blockXY.x;\n      }\n      if (blockXY.x > newRightX) {\n        // if we right align also on x\n        newRightX = blockXY.x;\n      }\n    }\n    offsetY = farY - newY + ySeparation;\n    offsetX = workspace.RTL ? topX - newRightX : topX - newLeftX;\n    for (let i = 0; i < newBlockIds.length; i++) {\n      const block = workspace.getBlockById(newBlockIds[i]);\n      block!.moveBy(offsetX, offsetY, ['create']);\n    }\n  }\n  return newBlockIds;\n}\n\n/**\n * Decode an XML block tag and create a block (and possibly sub blocks) on the\n * workspace.\n *\n * @param xmlBlock XML block element.\n * @param workspace The workspace.\n * @returns The root block created.\n */\nexport function domToBlock(xmlBlock: Element, workspace: Workspace): Block {\n  const block = domToBlockInternal(xmlBlock, workspace);\n  if (workspace.rendered) renderManagement.triggerQueuedRenders();\n  return block;\n}\n\n/**\n * Decode an XML block tag and create a block (and possibly sub blocks) on the\n * workspace.\n *\n * This is defined internally so that it doesn't trigger an immediate render,\n * which we do want to happen for external calls.\n *\n * @param xmlBlock XML block element.\n * @param workspace The workspace.\n * @returns The root block created.\n * @internal\n */\nexport function domToBlockInternal(\n  xmlBlock: Element,\n  workspace: Workspace,\n): Block {\n  // Create top-level block.\n  eventUtils.disable();\n  const variablesBeforeCreation = workspace.getAllVariables();\n  let topBlock;\n  try {\n    topBlock = domToBlockHeadless(xmlBlock, workspace);\n    // Generate list of all blocks.\n    if (workspace.rendered) {\n      const topBlockSvg = topBlock as BlockSvg;\n      const blocks = topBlock.getDescendants(false);\n      topBlockSvg.setConnectionTracking(false);\n      // Render each block.\n      for (let i = blocks.length - 1; i >= 0; i--) {\n        (blocks[i] as BlockSvg).initSvg();\n      }\n      for (let i = blocks.length - 1; i >= 0; i--) {\n        (blocks[i] as BlockSvg).queueRender();\n      }\n      // Populating the connection database may be deferred until after the\n      // blocks have rendered.\n      setTimeout(function () {\n        if (!topBlockSvg.disposed) {\n          topBlockSvg.setConnectionTracking(true);\n        }\n      }, 1);\n      topBlockSvg.updateDisabled();\n      // Allow the scrollbars to resize and move based on the new contents.\n      // TODO(@picklesrus): #387. Remove when domToBlock avoids resizing.\n      (workspace as WorkspaceSvg).resizeContents();\n    } else {\n      const blocks = topBlock.getDescendants(false);\n      for (let i = blocks.length - 1; i >= 0; i--) {\n        blocks[i].initModel();\n      }\n    }\n  } finally {\n    eventUtils.enable();\n  }\n  if (eventUtils.isEnabled()) {\n    const newVariables = Variables.getAddedVariables(\n      workspace,\n      variablesBeforeCreation,\n    );\n    // Fire a VarCreate event for each (if any) new variable created.\n    for (let i = 0; i < newVariables.length; i++) {\n      const thisVariable = newVariables[i];\n      eventUtils.fire(\n        new (eventUtils.get(eventUtils.VAR_CREATE))(thisVariable),\n      );\n    }\n    // Block events come after var events, in case they refer to newly created\n    // variables.\n    eventUtils.fire(new (eventUtils.get(eventUtils.CREATE))(topBlock));\n  }\n  return topBlock;\n}\n\n/**\n * Decode an XML list of variables and add the variables to the workspace.\n *\n * @param xmlVariables List of XML variable elements.\n * @param workspace The workspace to which the variable should be added.\n */\nexport function domToVariables(xmlVariables: Element, workspace: Workspace) {\n  for (let i = 0; i < xmlVariables.children.length; i++) {\n    const xmlChild = xmlVariables.children[i];\n    const type = xmlChild.getAttribute('type');\n    const id = xmlChild.getAttribute('id');\n    const name = xmlChild.textContent;\n\n    if (!name) return;\n    workspace.createVariable(name, type, id);\n  }\n}\n\n/** A mapping of nodeName to node for child nodes of xmlBlock. */\ninterface childNodeTagMap {\n  mutation: Element[];\n  comment: Element[];\n  data: Element[];\n  field: Element[];\n  input: Element[];\n  next: Element[];\n}\n\n/**\n * Creates a mapping of childNodes for each supported XML tag for the provided\n * xmlBlock. Logs a warning for any encountered unsupported tags.\n *\n * @param xmlBlock XML block element.\n * @returns The childNode map from nodeName to node.\n */\nfunction mapSupportedXmlTags(xmlBlock: Element): childNodeTagMap {\n  const childNodeMap = {\n    mutation: new Array(),\n    comment: new Array(),\n    data: new Array(),\n    field: new Array(),\n    input: new Array(),\n    next: new Array(),\n  };\n  for (let i = 0; i < xmlBlock.children.length; i++) {\n    const xmlChild = xmlBlock.children[i];\n    if (xmlChild.nodeType === dom.NodeType.TEXT_NODE) {\n      // Ignore any text at the  level.  It's all whitespace anyway.\n      continue;\n    }\n    switch (xmlChild.nodeName.toLowerCase()) {\n      case 'mutation':\n        childNodeMap.mutation.push(xmlChild);\n        break;\n      case 'comment':\n        childNodeMap.comment.push(xmlChild);\n        break;\n      case 'data':\n        childNodeMap.data.push(xmlChild);\n        break;\n      case 'title':\n      // Titles were renamed to field in December 2013.\n      // Fall through.\n      case 'field':\n        childNodeMap.field.push(xmlChild);\n        break;\n      case 'value':\n      case 'statement':\n        childNodeMap.input.push(xmlChild);\n        break;\n      case 'next':\n        childNodeMap.next.push(xmlChild);\n        break;\n      default:\n        // Unknown tag; ignore.  Same principle as HTML parsers.\n        console.warn('Ignoring unknown tag: ' + xmlChild.nodeName);\n    }\n  }\n  return childNodeMap;\n}\n\n/**\n * Applies mutation tag child nodes to the given block.\n *\n * @param xmlChildren Child nodes.\n * @param block The block to apply the child nodes on.\n * @returns True if mutation may have added some elements that need\n *     initialization (requiring initSvg call).\n */\nfunction applyMutationTagNodes(xmlChildren: Element[], block: Block): boolean {\n  let shouldCallInitSvg = false;\n  for (let i = 0; i < xmlChildren.length; i++) {\n    const xmlChild = xmlChildren[i];\n    // Custom data for an advanced block.\n    if (block.domToMutation) {\n      block.domToMutation(xmlChild);\n      if ((block as BlockSvg).initSvg) {\n        // Mutation may have added some elements that need initializing.\n        shouldCallInitSvg = true;\n      }\n    }\n  }\n  return shouldCallInitSvg;\n}\n\n/**\n * Applies comment tag child nodes to the given block.\n *\n * @param xmlChildren Child nodes.\n * @param block The block to apply the child nodes on.\n */\nfunction applyCommentTagNodes(xmlChildren: Element[], block: Block) {\n  for (let i = 0; i < xmlChildren.length; i++) {\n    const xmlChild = xmlChildren[i];\n    const text = xmlChild.textContent;\n    const pinned = xmlChild.getAttribute('pinned') === 'true';\n    const width = parseInt(xmlChild.getAttribute('w') ?? '50', 10);\n    const height = parseInt(xmlChild.getAttribute('h') ?? '50', 10);\n\n    block.setCommentText(text);\n    const comment = block.getIcon(IconType.COMMENT)!;\n    if (!isNaN(width) && !isNaN(height)) {\n      comment.setBubbleSize(new Size(width, height));\n    }\n    // Set the pinned state of the bubble.\n    comment.setBubbleVisible(pinned);\n    // Actually show the bubble after the block has been rendered.\n    setTimeout(() => comment.setBubbleVisible(pinned), 1);\n  }\n}\n\n/**\n * Applies data tag child nodes to the given block.\n *\n * @param xmlChildren Child nodes.\n * @param block The block to apply the child nodes on.\n */\nfunction applyDataTagNodes(xmlChildren: Element[], block: Block) {\n  for (let i = 0; i < xmlChildren.length; i++) {\n    const xmlChild = xmlChildren[i];\n    block.data = xmlChild.textContent;\n  }\n}\n\n/**\n * Applies field tag child nodes to the given block.\n *\n * @param xmlChildren Child nodes.\n * @param block The block to apply the child nodes on.\n */\nfunction applyFieldTagNodes(xmlChildren: Element[], block: Block) {\n  for (let i = 0; i < xmlChildren.length; i++) {\n    const xmlChild = xmlChildren[i];\n    const nodeName = xmlChild.getAttribute('name');\n    if (!nodeName) {\n      console.warn(`Ignoring unnamed field in block ${block.type}`);\n      continue;\n    }\n    domToField(block, nodeName, xmlChild);\n  }\n}\n\n/**\n * Finds any enclosed blocks or shadows within this XML node.\n *\n * @param xmlNode The XML node to extract child block info from.\n * @returns Any found child block.\n */\nfunction findChildBlocks(xmlNode: Element): {\n  childBlockElement: Element | null;\n  childShadowElement: Element | null;\n} {\n  let childBlockElement: Element | null = null;\n  let childShadowElement: Element | null = null;\n  for (let i = 0; i < xmlNode.childNodes.length; i++) {\n    const xmlChild = xmlNode.childNodes[i];\n    if (isElement(xmlChild)) {\n      if (xmlChild.nodeName.toLowerCase() === 'block') {\n        childBlockElement = xmlChild;\n      } else if (xmlChild.nodeName.toLowerCase() === 'shadow') {\n        childShadowElement = xmlChild;\n      }\n    }\n  }\n  return {childBlockElement, childShadowElement};\n}\n/**\n * Applies input child nodes (value or statement) to the given block.\n *\n * @param xmlChildren Child nodes.\n * @param workspace The workspace containing the given block.\n * @param block The block to apply the child nodes on.\n * @param prototypeName The prototype name of the block.\n */\nfunction applyInputTagNodes(\n  xmlChildren: Element[],\n  workspace: Workspace,\n  block: Block,\n  prototypeName: string,\n) {\n  for (let i = 0; i < xmlChildren.length; i++) {\n    const xmlChild = xmlChildren[i];\n    const nodeName = xmlChild.getAttribute('name');\n    const input = nodeName ? block.getInput(nodeName) : null;\n    if (!input) {\n      console.warn(\n        'Ignoring non-existent input ' +\n          nodeName +\n          ' in block ' +\n          prototypeName,\n      );\n      break;\n    }\n    const childBlockInfo = findChildBlocks(xmlChild);\n    if (childBlockInfo.childBlockElement) {\n      if (!input.connection) {\n        throw TypeError('Input connection does not exist.');\n      }\n      domToBlockHeadless(\n        childBlockInfo.childBlockElement,\n        workspace,\n        input.connection,\n        false,\n      );\n    }\n    // Set shadow after so we don't create a shadow we delete immediately.\n    if (childBlockInfo.childShadowElement) {\n      input.connection?.setShadowDom(childBlockInfo.childShadowElement);\n    }\n  }\n}\n\n/**\n * Applies next child nodes to the given block.\n *\n * @param xmlChildren Child nodes.\n * @param workspace The workspace containing the given block.\n * @param block The block to apply the child nodes on.\n */\nfunction applyNextTagNodes(\n  xmlChildren: Element[],\n  workspace: Workspace,\n  block: Block,\n) {\n  for (let i = 0; i < xmlChildren.length; i++) {\n    const xmlChild = xmlChildren[i];\n    const childBlockInfo = findChildBlocks(xmlChild);\n    if (childBlockInfo.childBlockElement) {\n      if (!block.nextConnection) {\n        throw TypeError('Next statement does not exist.');\n      }\n      // If there is more than one XML 'next' tag.\n      if (block.nextConnection.isConnected()) {\n        throw TypeError('Next statement is already connected.');\n      }\n      // Create child block.\n      domToBlockHeadless(\n        childBlockInfo.childBlockElement,\n        workspace,\n        block.nextConnection,\n        true,\n      );\n    }\n    // Set shadow after so we don't create a shadow we delete immediately.\n    if (childBlockInfo.childShadowElement && block.nextConnection) {\n      block.nextConnection.setShadowDom(childBlockInfo.childShadowElement);\n    }\n  }\n}\n\n/**\n * Decode an XML block tag and create a block (and possibly sub blocks) on the\n * workspace.\n *\n * @param xmlBlock XML block element.\n * @param workspace The workspace.\n * @param parentConnection The parent connection to to connect this block to\n *     after instantiating.\n * @param connectedToParentNext Whether the provided parent connection is a next\n *     connection, rather than output or statement.\n * @returns The root block created.\n */\nfunction domToBlockHeadless(\n  xmlBlock: Element,\n  workspace: Workspace,\n  parentConnection?: Connection,\n  connectedToParentNext?: boolean,\n): Block {\n  let block = null;\n  const prototypeName = xmlBlock.getAttribute('type');\n  if (!prototypeName) {\n    throw TypeError('Block type unspecified: ' + xmlBlock.outerHTML);\n  }\n  const id = xmlBlock.getAttribute('id') ?? undefined;\n  block = workspace.newBlock(prototypeName, id);\n\n  // Preprocess childNodes so tags can be processed in a consistent order.\n  const xmlChildNameMap = mapSupportedXmlTags(xmlBlock);\n\n  const shouldCallInitSvg = applyMutationTagNodes(\n    xmlChildNameMap.mutation,\n    block,\n  );\n  applyCommentTagNodes(xmlChildNameMap.comment, block);\n  applyDataTagNodes(xmlChildNameMap.data, block);\n\n  // Connect parent after processing mutation and before setting fields.\n  if (parentConnection) {\n    if (connectedToParentNext) {\n      if (block.previousConnection) {\n        parentConnection.connect(block.previousConnection);\n      } else {\n        throw TypeError('Next block does not have previous statement.');\n      }\n    } else {\n      if (block.outputConnection) {\n        parentConnection.connect(block.outputConnection);\n      } else if (block.previousConnection) {\n        parentConnection.connect(block.previousConnection);\n      } else {\n        throw TypeError(\n          'Child block does not have output or previous statement.',\n        );\n      }\n    }\n  }\n\n  applyFieldTagNodes(xmlChildNameMap.field, block);\n  applyInputTagNodes(xmlChildNameMap.input, workspace, block, prototypeName);\n  applyNextTagNodes(xmlChildNameMap.next, workspace, block);\n\n  if (shouldCallInitSvg) {\n    // This shouldn't even be called here\n    // (ref: https://github.com/google/blockly/pull/4296#issuecomment-884226021\n    // But the XML serializer/deserializer is iceboxed so I'm not going to fix\n    // it.\n    (block as BlockSvg).initSvg();\n  }\n\n  const inline = xmlBlock.getAttribute('inline');\n  if (inline) {\n    block.setInputsInline(inline === 'true');\n  }\n  const disabled = xmlBlock.getAttribute('disabled');\n  if (disabled) {\n    block.setEnabled(disabled !== 'true' && disabled !== 'disabled');\n  }\n  const deletable = xmlBlock.getAttribute('deletable');\n  if (deletable) {\n    block.setDeletable(deletable === 'true');\n  }\n  const movable = xmlBlock.getAttribute('movable');\n  if (movable) {\n    block.setMovable(movable === 'true');\n  }\n  const editable = xmlBlock.getAttribute('editable');\n  if (editable) {\n    block.setEditable(editable === 'true');\n  }\n  const collapsed = xmlBlock.getAttribute('collapsed');\n  if (collapsed) {\n    block.setCollapsed(collapsed === 'true');\n  }\n  if (xmlBlock.nodeName.toLowerCase() === 'shadow') {\n    // Ensure all children are also shadows.\n    const children = block.getChildren(false);\n    for (let i = 0; i < children.length; i++) {\n      const child = children[i];\n      if (!child.isShadow()) {\n        throw TypeError('Shadow block not allowed non-shadow child.');\n      }\n    }\n    // Ensure this block doesn't have any variable inputs.\n    if (block.getVarModels().length) {\n      throw TypeError('Shadow blocks cannot have variable references.');\n    }\n    block.setShadow(true);\n  }\n  return block;\n}\n\n/**\n * Decode an XML field tag and set the value of that field on the given block.\n *\n * @param block The block that is currently being deserialized.\n * @param fieldName The name of the field on the block.\n * @param xml The field tag to decode.\n */\nfunction domToField(block: Block, fieldName: string, xml: Element) {\n  const field = block.getField(fieldName);\n  if (!field) {\n    console.warn(\n      'Ignoring non-existent field ' + fieldName + ' in block ' + block.type,\n    );\n    return;\n  }\n  field.fromXml(xml);\n}\n\n/**\n * Remove any 'next' block (statements in a stack).\n *\n * @param xmlBlock XML block element or an empty DocumentFragment if the block\n *     was an insertion marker.\n */\nexport function deleteNext(xmlBlock: Element | DocumentFragment) {\n  for (let i = 0; i < xmlBlock.childNodes.length; i++) {\n    const child = xmlBlock.childNodes[i];\n    if (child.nodeName.toLowerCase() === 'next') {\n      xmlBlock.removeChild(child);\n      break;\n    }\n  }\n}\n\nfunction isElement(node: Node): node is Element {\n  return node.nodeType === dom.NodeType.ELEMENT_NODE;\n}\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport interface ISerializable {\n  /**\n   * @param doFullSerialization If true, this signals that any backing data\n   *     structures used by this ISerializable should also be serialized. This\n   *     is used for copy-paste.\n   * @returns a JSON serializable value that records the ISerializable's state.\n   */\n  saveState(doFullSerialization: boolean): any;\n\n  /**\n   * Takes in a JSON serializable value and sets the ISerializable's state\n   * based on that.\n   *\n   * @param state The state to apply to the ISerializable.\n   */\n  loadState(state: any): void;\n}\n\n/** Type guard that checks whether the given object is a ISerializable. */\nexport function isSerializable(obj: any): obj is ISerializable {\n  return obj.saveState !== undefined && obj.loadState !== undefined;\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.serialization.registry\n\nimport type {ISerializer} from '../interfaces/i_serializer.js';\nimport * as registry from '../registry.js';\n\n/**\n * Registers the given serializer so that it can be used for serialization and\n * deserialization.\n *\n * @param name The name of the serializer to register.\n * @param serializer The serializer to register.\n */\nexport function register(name: string, serializer: ISerializer) {\n  registry.register(registry.Type.SERIALIZER, name, serializer);\n}\n\n/**\n * Unregisters the serializer associated with the given name.\n *\n * @param name The name of the serializer to unregister.\n */\nexport function unregister(name: string) {\n  registry.unregister(registry.Type.SERIALIZER, name);\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.serialization.blocks\n\nimport type {Block} from '../block.js';\nimport type {BlockSvg} from '../block_svg.js';\nimport type {Connection} from '../connection.js';\nimport * as eventUtils from '../events/utils.js';\nimport {inputTypes} from '../inputs/input_types.js';\nimport {isSerializable} from '../interfaces/i_serializable.js';\nimport type {ISerializer} from '../interfaces/i_serializer.js';\nimport * as registry from '../registry.js';\nimport * as utilsXml from '../utils/xml.js';\nimport type {Workspace} from '../workspace.js';\nimport * as Xml from '../xml.js';\nimport * as renderManagement from '../render_management.js';\n\nimport {\n  BadConnectionCheck,\n  MissingBlockType,\n  MissingConnection,\n  RealChildOfShadow,\n  UnregisteredIcon,\n} from './exceptions.js';\nimport * as priorities from './priorities.js';\nimport * as serializationRegistry from './registry.js';\n\n// TODO(#5160): Remove this once lint is fixed.\n/* eslint-disable no-use-before-define */\n\n/**\n * Represents the state of a connection.\n */\nexport interface ConnectionState {\n  shadow?: State;\n  block?: State;\n}\n\n/**\n * Represents the state of a given block.\n */\nexport interface State {\n  type: string;\n  id?: string;\n  x?: number;\n  y?: number;\n  collapsed?: boolean;\n  deletable?: boolean;\n  movable?: boolean;\n  editable?: boolean;\n  enabled?: boolean;\n  inline?: boolean;\n  data?: string;\n  extraState?: AnyDuringMigration;\n  icons?: {[key: string]: AnyDuringMigration};\n  fields?: {[key: string]: AnyDuringMigration};\n  inputs?: {[key: string]: ConnectionState};\n  next?: ConnectionState;\n}\n\n/**\n * Returns the state of the given block as a plain JavaScript object.\n *\n * @param block The block to serialize.\n * @param param1 addCoordinates: If true, the coordinates of the block are added\n *     to the serialized state. False by default. addinputBlocks: If true,\n *     children of the block which are connected to inputs will be serialized.\n *     True by default. addNextBlocks: If true, children of the block which are\n *     connected to the block's next connection (if it exists) will be\n *     serialized. True by default. doFullSerialization: If true, fields that\n *     normally just save a reference to some external state (eg variables) will\n *     instead serialize all of the info about that state. This supports\n *     deserializing the block into a workspace where that state doesn't yet\n *     exist. True by default.\n * @returns The serialized state of the block, or null if the block could not be\n *     serialied (eg it was an insertion marker).\n */\nexport function save(\n  block: Block,\n  {\n    addCoordinates = false,\n    addInputBlocks = true,\n    addNextBlocks = true,\n    doFullSerialization = true,\n  }: {\n    addCoordinates?: boolean;\n    addInputBlocks?: boolean;\n    addNextBlocks?: boolean;\n    doFullSerialization?: boolean;\n  } = {},\n): State | null {\n  if (block.isInsertionMarker()) {\n    return null;\n  }\n\n  const state = {\n    'type': block.type,\n    'id': block.id,\n  };\n\n  if (addCoordinates) {\n    // AnyDuringMigration because:  Argument of type '{ type: string; id:\n    // string; }' is not assignable to parameter of type 'State'.\n    saveCoords(block, state as AnyDuringMigration);\n  }\n  // AnyDuringMigration because:  Argument of type '{ type: string; id: string;\n  // }' is not assignable to parameter of type 'State'.\n  saveAttributes(block, state as AnyDuringMigration);\n  // AnyDuringMigration because:  Argument of type '{ type: string; id: string;\n  // }' is not assignable to parameter of type 'State'.\n  saveExtraState(block, state as AnyDuringMigration, doFullSerialization);\n  // AnyDuringMigration because:  Argument of type '{ type: string; id: string;\n  // }' is not assignable to parameter of type 'State'.\n  saveIcons(block, state as AnyDuringMigration, doFullSerialization);\n  // AnyDuringMigration because:  Argument of type '{ type: string; id: string;\n  // }' is not assignable to parameter of type 'State'.\n  saveFields(block, state as AnyDuringMigration, doFullSerialization);\n  if (addInputBlocks) {\n    // AnyDuringMigration because:  Argument of type '{ type: string; id:\n    // string; }' is not assignable to parameter of type 'State'.\n    saveInputBlocks(block, state as AnyDuringMigration, doFullSerialization);\n  }\n  if (addNextBlocks) {\n    // AnyDuringMigration because:  Argument of type '{ type: string; id:\n    // string; }' is not assignable to parameter of type 'State'.\n    saveNextBlocks(block, state as AnyDuringMigration, doFullSerialization);\n  }\n\n  // AnyDuringMigration because:  Type '{ type: string; id: string; }' is not\n  // assignable to type 'State'.\n  return state as AnyDuringMigration;\n}\n\n/**\n * Adds attributes to the given state object based on the state of the block.\n * Eg collapsed, disabled, inline, etc.\n *\n * @param block The block to base the attributes on.\n * @param state The state object to append to.\n */\nfunction saveAttributes(block: Block, state: State) {\n  if (block.isCollapsed()) {\n    state['collapsed'] = true;\n  }\n  if (!block.isEnabled()) {\n    state['enabled'] = false;\n  }\n  if (!block.isOwnDeletable()) {\n    state['deletable'] = false;\n  }\n  if (!block.isOwnMovable()) {\n    state['movable'] = false;\n  }\n  if (!block.isOwnEditable()) {\n    state['editable'] = false;\n  }\n  if (\n    block.inputsInline !== undefined &&\n    block.inputsInline !== block.inputsInlineDefault\n  ) {\n    state['inline'] = block.inputsInline;\n  }\n  // Data is a nullable string, so we don't need to worry about falsy values.\n  if (block.data) {\n    state['data'] = block.data;\n  }\n}\n\n/**\n * Adds the coordinates of the given block to the given state object.\n *\n * @param block The block to base the coordinates on.\n * @param state The state object to append to.\n */\nfunction saveCoords(block: Block, state: State) {\n  const workspace = block.workspace;\n  const xy = block.getRelativeToSurfaceXY();\n  state['x'] = Math.round(workspace.RTL ? workspace.getWidth() - xy.x : xy.x);\n  state['y'] = Math.round(xy.y);\n}\n\n/**\n * Adds any extra state the block may provide to the given state object.\n *\n * @param block The block to serialize the extra state of.\n * @param state The state object to append to.\n * @param doFullSerialization Whether or not to serialize the full state of the\n *     extra state (rather than possibly saving a reference to some state).\n */\nfunction saveExtraState(\n  block: Block,\n  state: State,\n  doFullSerialization: boolean,\n) {\n  if (block.saveExtraState) {\n    const extraState = block.saveExtraState(doFullSerialization);\n    if (extraState !== null) {\n      state['extraState'] = extraState;\n    }\n  } else if (block.mutationToDom) {\n    const extraState = block.mutationToDom();\n    if (extraState !== null) {\n      state['extraState'] = Xml.domToText(extraState).replace(\n        ' xmlns=\"https://developers.google.com/blockly/xml\"',\n        '',\n      );\n    }\n  }\n}\n\n/**\n * Adds the state of all of the icons on the block to the given state object.\n *\n * @param block The block to serialize the icon state of.\n * @param state The state object to append to.\n * @param doFullSerialization Whether or not to serialize the full state of the\n *     icon (rather than possibly saving a reference to some state).\n */\nfunction saveIcons(block: Block, state: State, doFullSerialization: boolean) {\n  const icons = Object.create(null);\n  for (const icon of block.getIcons()) {\n    if (isSerializable(icon)) {\n      const state = icon.saveState(doFullSerialization);\n      if (state) icons[icon.getType().toString()] = state;\n    }\n  }\n\n  if (Object.keys(icons).length) {\n    state['icons'] = icons;\n  }\n}\n\n/**\n * Adds the state of all of the fields on the block to the given state object.\n *\n * @param block The block to serialize the field state of.\n * @param state The state object to append to.\n * @param doFullSerialization Whether or not to serialize the full state of the\n *     field (rather than possibly saving a reference to some state).\n */\nfunction saveFields(block: Block, state: State, doFullSerialization: boolean) {\n  const fields = Object.create(null);\n  for (let i = 0; i < block.inputList.length; i++) {\n    const input = block.inputList[i];\n    for (let j = 0; j < input.fieldRow.length; j++) {\n      const field = input.fieldRow[j];\n      if (field.isSerializable()) {\n        fields[field.name!] = field.saveState(doFullSerialization);\n      }\n    }\n  }\n  if (Object.keys(fields).length) {\n    state['fields'] = fields;\n  }\n}\n\n/**\n * Adds the state of all of the child blocks of the given block (which are\n * connected to inputs) to the given state object.\n *\n * @param block The block to serialize the input blocks of.\n * @param state The state object to append to.\n * @param doFullSerialization Whether or not to do full serialization.\n */\nfunction saveInputBlocks(\n  block: Block,\n  state: State,\n  doFullSerialization: boolean,\n) {\n  const inputs = Object.create(null);\n  for (let i = 0; i < block.inputList.length; i++) {\n    const input = block.inputList[i];\n    if (!input.connection) continue;\n    const connectionState = saveConnection(\n      input.connection as Connection,\n      doFullSerialization,\n    );\n    if (connectionState) {\n      inputs[input.name] = connectionState;\n    }\n  }\n\n  if (Object.keys(inputs).length) {\n    state['inputs'] = inputs;\n  }\n}\n\n/**\n * Adds the state of all of the next blocks of the given block to the given\n * state object.\n *\n * @param block The block to serialize the next blocks of.\n * @param state The state object to append to.\n * @param doFullSerialization Whether or not to do full serialization.\n */\nfunction saveNextBlocks(\n  block: Block,\n  state: State,\n  doFullSerialization: boolean,\n) {\n  if (!block.nextConnection) {\n    return;\n  }\n  const connectionState = saveConnection(\n    block.nextConnection,\n    doFullSerialization,\n  );\n  if (connectionState) {\n    state['next'] = connectionState;\n  }\n}\n\n/**\n * Returns the state of the given connection (ie the state of any connected\n * shadow or real blocks).\n *\n * @param connection The connection to serialize the connected blocks of.\n * @returns An object containing the state of any connected shadow block, or any\n *     connected real block.\n * @param doFullSerialization Whether or not to do full serialization.\n */\nfunction saveConnection(\n  connection: Connection,\n  doFullSerialization: boolean,\n): ConnectionState | null {\n  const shadow = connection.getShadowState(true);\n  const child = connection.targetBlock();\n  if (!shadow && !child) {\n    return null;\n  }\n  const state = Object.create(null);\n  if (shadow) {\n    state['shadow'] = shadow;\n  }\n  if (child && !child.isShadow()) {\n    state['block'] = save(child, {doFullSerialization});\n  }\n  return state;\n}\n\n/**\n * Loads the block represented by the given state into the given workspace.\n *\n * @param state The state of a block to deserialize into the workspace.\n * @param workspace The workspace to add the block to.\n * @param param1 recordUndo: If true, events triggered by this function will be\n *     undo-able by the user. False by default.\n * @returns The block that was just loaded.\n */\nexport function append(\n  state: State,\n  workspace: Workspace,\n  {recordUndo = false}: {recordUndo?: boolean} = {},\n): Block {\n  const block = appendInternal(state, workspace, {recordUndo});\n  if (workspace.rendered) renderManagement.triggerQueuedRenders();\n  return block;\n}\n\n/**\n * Loads the block represented by the given state into the given workspace.\n * This is defined internally so that the extra parameters don't clutter our\n * external API.\n * But it is exported so that other places within Blockly can call it directly\n * with the extra parameters.\n *\n * @param state The state of a block to deserialize into the workspace.\n * @param workspace The workspace to add the block to.\n * @param param1 parentConnection: If provided, the system will attempt to\n *     connect the block to this connection after it is created. Undefined by\n *     default. isShadow: If true, the block will be set to a shadow block after\n *     it is created. False by default. recordUndo: If true, events triggered by\n *     this function will be undo-able by the user. False by default.\n * @returns The block that was just appended.\n * @internal\n */\nexport function appendInternal(\n  state: State,\n  workspace: Workspace,\n  {\n    parentConnection = undefined,\n    isShadow = false,\n    recordUndo = false,\n  }: {\n    parentConnection?: Connection;\n    isShadow?: boolean;\n    recordUndo?: boolean;\n  } = {},\n): Block {\n  const prevRecordUndo = eventUtils.getRecordUndo();\n  eventUtils.setRecordUndo(recordUndo);\n  const existingGroup = eventUtils.getGroup();\n  if (!existingGroup) {\n    eventUtils.setGroup(true);\n  }\n  eventUtils.disable();\n\n  let block;\n  try {\n    block = appendPrivate(state, workspace, {parentConnection, isShadow});\n  } finally {\n    eventUtils.enable();\n  }\n\n  if (eventUtils.isEnabled()) {\n    eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block));\n  }\n  eventUtils.setGroup(existingGroup);\n  eventUtils.setRecordUndo(prevRecordUndo);\n\n  // Adding connections to the connection db is expensive. This defers that\n  // operation to decrease load time.\n  if (workspace.rendered) {\n    const blockSvg = block as BlockSvg;\n    setTimeout(() => {\n      if (!blockSvg.disposed) {\n        blockSvg.setConnectionTracking(true);\n      }\n    }, 1);\n  }\n\n  return block;\n}\n\n/**\n * Loads the block represented by the given state into the given workspace.\n * This is defined privately so that it can be called recursively without firing\n * eroneous events. Events (and other things we only want to occur on the top\n * block) are handled by appendInternal.\n *\n * @param state The state of a block to deserialize into the workspace.\n * @param workspace The workspace to add the block to.\n * @param param1 parentConnection: If provided, the system will attempt to\n *     connect the block to this connection after it is created. Undefined by\n *     default. isShadow: The block will be set to a shadow block after it is\n *     created. False by default.\n * @returns The block that was just appended.\n */\nfunction appendPrivate(\n  state: State,\n  workspace: Workspace,\n  {\n    parentConnection = undefined,\n    isShadow = false,\n  }: {parentConnection?: Connection; isShadow?: boolean} = {},\n): Block {\n  if (!state['type']) {\n    throw new MissingBlockType(state);\n  }\n\n  const block = workspace.newBlock(state['type'], state['id']);\n  block.setShadow(isShadow);\n  loadCoords(block, state);\n  loadAttributes(block, state);\n  loadExtraState(block, state);\n  tryToConnectParent(parentConnection, block, state);\n  loadIcons(block, state);\n  loadFields(block, state);\n  loadInputBlocks(block, state);\n  loadNextBlocks(block, state);\n  initBlock(block, workspace.rendered);\n\n  return block;\n}\n\n/**\n * Applies any coordinate information available on the state object to the\n * block.\n *\n * @param block The block to set the position of.\n * @param state The state object to reference.\n */\nfunction loadCoords(block: Block, state: State) {\n  let x = state['x'] === undefined ? 0 : state['x'];\n  const y = state['y'] === undefined ? 0 : state['y'];\n\n  const workspace = block.workspace;\n  x = workspace.RTL ? workspace.getWidth() - x : x;\n\n  block.moveBy(x, y);\n}\n\n/**\n * Applies any attribute information available on the state object to the block.\n *\n * @param block The block to set the attributes of.\n * @param state The state object to reference.\n */\nfunction loadAttributes(block: Block, state: State) {\n  if (state['collapsed']) {\n    block.setCollapsed(true);\n  }\n  if (state['deletable'] === false) {\n    block.setDeletable(false);\n  }\n  if (state['movable'] === false) {\n    block.setMovable(false);\n  }\n  if (state['editable'] === false) {\n    block.setEditable(false);\n  }\n  if (state['enabled'] === false) {\n    block.setEnabled(false);\n  }\n  if (state['inline'] !== undefined) {\n    block.setInputsInline(state['inline']);\n  }\n  if (state['data'] !== undefined) {\n    block.data = state['data'];\n  }\n}\n\n/**\n * Applies any extra state information available on the state object to the\n * block.\n *\n * @param block The block to set the extra state of.\n * @param state The state object to reference.\n */\nfunction loadExtraState(block: Block, state: State) {\n  if (!state['extraState']) {\n    return;\n  }\n  if (block.loadExtraState) {\n    block.loadExtraState(state['extraState']);\n  } else if (block.domToMutation) {\n    block.domToMutation(utilsXml.textToDom(state['extraState']));\n  }\n}\n\n/**\n * Attempts to connect the block to the parent connection, if it exists.\n *\n * @param parentConnection The parent connection to try to connect the block to.\n * @param child The block to try to connect to the parent.\n * @param state The state which defines the given block\n */\nfunction tryToConnectParent(\n  parentConnection: Connection | undefined,\n  child: Block,\n  state: State,\n) {\n  if (!parentConnection) {\n    return;\n  }\n\n  if (parentConnection.getSourceBlock().isShadow() && !child.isShadow()) {\n    throw new RealChildOfShadow(state);\n  }\n\n  let connected = false;\n  let childConnection;\n  if (parentConnection.type === inputTypes.VALUE) {\n    childConnection = child.outputConnection;\n    if (!childConnection) {\n      throw new MissingConnection('output', child, state);\n    }\n    connected = parentConnection.connect(childConnection);\n  } else {\n    // Statement type.\n    childConnection = child.previousConnection;\n    if (!childConnection) {\n      throw new MissingConnection('previous', child, state);\n    }\n    connected = parentConnection.connect(childConnection);\n  }\n\n  if (!connected) {\n    const checker = child.workspace.connectionChecker;\n    throw new BadConnectionCheck(\n      checker.getErrorMessage(\n        checker.canConnectWithReason(childConnection, parentConnection, false),\n        childConnection,\n        parentConnection,\n      ),\n      parentConnection.type === inputTypes.VALUE\n        ? 'output connection'\n        : 'previous connection',\n      child,\n      state,\n    );\n  }\n}\n\n/**\n * Applies icon state to the icons on the block, based on the given state\n * object.\n *\n * @param block The block to set the icon state of.\n * @param state The state object to reference.\n */\nfunction loadIcons(block: Block, state: State) {\n  if (!state['icons']) return;\n\n  const iconTypes = Object.keys(state['icons']);\n  for (const iconType of iconTypes) {\n    const iconState = state['icons'][iconType];\n    let icon = block.getIcon(iconType);\n    if (!icon) {\n      const constructor = registry.getClass(\n        registry.Type.ICON,\n        iconType,\n        false,\n      );\n      if (!constructor) throw new UnregisteredIcon(iconType, block, state);\n      icon = new constructor(block);\n      block.addIcon(icon);\n    }\n    if (isSerializable(icon)) icon.loadState(iconState);\n  }\n}\n\n/**\n * Applies any field information available on the state object to the block.\n *\n * @param block The block to set the field state of.\n * @param state The state object to reference.\n */\nfunction loadFields(block: Block, state: State) {\n  if (!state['fields']) {\n    return;\n  }\n  const keys = Object.keys(state['fields']);\n  for (let i = 0; i < keys.length; i++) {\n    const fieldName = keys[i];\n    const fieldState = state['fields'][fieldName];\n    const field = block.getField(fieldName);\n    if (!field) {\n      console.warn(\n        `Ignoring non-existant field ${fieldName} in block ${block.type}`,\n      );\n      continue;\n    }\n    field.loadState(fieldState);\n  }\n}\n\n/**\n * Creates any child blocks (attached to inputs) defined by the given state\n * and attaches them to the given block.\n *\n * @param block The block to attach input blocks to.\n * @param state The state object to reference.\n */\nfunction loadInputBlocks(block: Block, state: State) {\n  if (!state['inputs']) {\n    return;\n  }\n  const keys = Object.keys(state['inputs']);\n  for (let i = 0; i < keys.length; i++) {\n    const inputName = keys[i];\n    const input = block.getInput(inputName);\n    if (!input || !input.connection) {\n      throw new MissingConnection(inputName, block, state);\n    }\n    loadConnection(input.connection, state['inputs'][inputName]);\n  }\n}\n\n/**\n * Creates any next blocks defined by the given state and attaches them to the\n * given block.\n *\n * @param block The block to attach next blocks to.\n * @param state The state object to reference.\n */\nfunction loadNextBlocks(block: Block, state: State) {\n  if (!state['next']) {\n    return;\n  }\n  if (!block.nextConnection) {\n    throw new MissingConnection('next', block, state);\n  }\n  loadConnection(block.nextConnection, state['next']);\n}\n/**\n * Applies the state defined by connectionState to the given connection, ie\n * assigns shadows and attaches child blocks.\n *\n * @param connection The connection to deserialize the connected blocks of.\n * @param connectionState The object containing the state of any connected\n *     shadow block, or any connected real block.\n */\nfunction loadConnection(\n  connection: Connection,\n  connectionState: ConnectionState,\n) {\n  if (connectionState['shadow']) {\n    connection.setShadowState(connectionState['shadow']);\n  }\n  if (connectionState['block']) {\n    appendPrivate(\n      connectionState['block'],\n      connection.getSourceBlock().workspace,\n      {parentConnection: connection},\n    );\n  }\n}\n\n// TODO(#5146): Remove this from the serialization system.\n/**\n * Initializes the give block, eg init the model, inits the svg, renders, etc.\n *\n * @param block The block to initialize.\n * @param rendered Whether the block is a rendered or headless block.\n */\nfunction initBlock(block: Block, rendered: boolean) {\n  if (rendered) {\n    const blockSvg = block as BlockSvg;\n    // Adding connections to the connection db is expensive. This defers that\n    // operation to decrease load time.\n    blockSvg.setConnectionTracking(false);\n\n    blockSvg.initSvg();\n    blockSvg.queueRender();\n    blockSvg.updateDisabled();\n\n    // fixes #6076 JSO deserialization doesn't\n    // set .iconXY_ property so here it will be set\n    for (const icon of blockSvg.getIcons()) {\n      icon.onLocationChange(blockSvg.getRelativeToSurfaceXY());\n    }\n  } else {\n    block.initModel();\n  }\n}\n\n// Alias to disambiguate saving within the serializer.\nconst saveBlock = save;\n\n/**\n * Serializer for saving and loading block state.\n */\nexport class BlockSerializer implements ISerializer {\n  priority: number;\n\n  /* eslint-disable-next-line require-jsdoc */\n  constructor() {\n    /** The priority for deserializing blocks. */\n    this.priority = priorities.BLOCKS;\n  }\n\n  /**\n   * Serializes the blocks of the given workspace.\n   *\n   * @param workspace The workspace to save the blocks of.\n   * @returns The state of the workspace's blocks, or null if there are no\n   *     blocks.\n   */\n  save(\n    workspace: Workspace,\n  ): {languageVersion: number; blocks: State[]} | null {\n    const blockStates = [];\n    for (const block of workspace.getTopBlocks(false)) {\n      const state = saveBlock(block, {\n        addCoordinates: true,\n        doFullSerialization: false,\n      });\n      if (state) {\n        blockStates.push(state);\n      }\n    }\n    if (blockStates.length) {\n      return {\n        'languageVersion': 0, // Currently unused.\n        'blocks': blockStates,\n      };\n    }\n    return null;\n  }\n\n  /**\n   * Deserializes the blocks defined by the given state into the given\n   * workspace.\n   *\n   * @param state The state of the blocks to deserialize.\n   * @param workspace The workspace to deserialize into.\n   */\n  load(\n    state: {languageVersion: number; blocks: State[]},\n    workspace: Workspace,\n  ) {\n    const blockStates = state['blocks'];\n    for (const state of blockStates) {\n      append(state, workspace, {recordUndo: eventUtils.getRecordUndo()});\n    }\n  }\n\n  /**\n   * Disposes of any blocks that exist on the workspace.\n   *\n   * @param workspace The workspace to clear the blocks of.\n   */\n  clear(workspace: Workspace) {\n    // Cannot use workspace.clear() because that also removes variables.\n    for (const block of workspace.getTopBlocks(false)) {\n      block.dispose(false);\n    }\n  }\n}\n\nserializationRegistry.register('blocks', new BlockSerializer());\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport {BlockSvg} from '../block_svg.js';\nimport * as registry from './registry.js';\nimport {ICopyData} from '../interfaces/i_copyable.js';\nimport {IPaster} from '../interfaces/i_paster.js';\nimport {State, append} from '../serialization/blocks.js';\nimport {Coordinate} from '../utils/coordinate.js';\nimport {WorkspaceSvg} from '../workspace_svg.js';\nimport * as eventUtils from '../events/utils.js';\nimport {config} from '../config.js';\n\nexport class BlockPaster implements IPaster {\n  static TYPE = 'block';\n\n  paste(\n    copyData: BlockCopyData,\n    workspace: WorkspaceSvg,\n    coordinate?: Coordinate,\n  ): BlockSvg | null {\n    if (!workspace.isCapacityAvailable(copyData.typeCounts!)) return null;\n\n    if (coordinate) {\n      copyData.blockState['x'] = coordinate.x;\n      copyData.blockState['y'] = coordinate.y;\n    }\n\n    eventUtils.disable();\n    let block;\n    try {\n      block = append(copyData.blockState, workspace) as BlockSvg;\n      moveBlockToNotConflict(block);\n    } finally {\n      eventUtils.enable();\n    }\n\n    if (!block) return block;\n\n    if (eventUtils.isEnabled() && !block.isShadow()) {\n      eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block));\n    }\n    block.select();\n    return block;\n  }\n}\n\n/**\n * Moves the given block to a location where it does not: (1) overlap exactly\n * with any other blocks, or (2) look like it is connected to any other blocks.\n *\n * Exported for testing.\n *\n * @param block The block to move to an unambiguous location.\n * @internal\n */\nexport function moveBlockToNotConflict(block: BlockSvg) {\n  const workspace = block.workspace;\n  const snapRadius = config.snapRadius;\n  const coord = block.getRelativeToSurfaceXY();\n  const offset = new Coordinate(0, 0);\n  // getRelativeToSurfaceXY is really expensive, so we want to cache this.\n  const otherCoords = workspace\n    .getAllBlocks(false)\n    .filter((otherBlock) => otherBlock.id != block.id)\n    .map((b) => b.getRelativeToSurfaceXY());\n\n  while (\n    blockOverlapsOtherExactly(Coordinate.sum(coord, offset), otherCoords) ||\n    blockIsInSnapRadius(block, offset, snapRadius)\n  ) {\n    if (workspace.RTL) {\n      offset.translate(-snapRadius, snapRadius * 2);\n    } else {\n      offset.translate(snapRadius, snapRadius * 2);\n    }\n  }\n\n  block!.moveTo(Coordinate.sum(coord, offset));\n}\n\n/**\n * @returns true if the given block coordinates are less than a delta of 1 from\n *     any of the other coordinates.\n */\nfunction blockOverlapsOtherExactly(\n  coord: Coordinate,\n  otherCoords: Coordinate[],\n): boolean {\n  return otherCoords.some(\n    (otherCoord) =>\n      Math.abs(otherCoord.x - coord.x) <= 1 &&\n      Math.abs(otherCoord.y - coord.y) <= 1,\n  );\n}\n\n/**\n * @returns true if the given block (when offset by the given amount) is close\n *     enough to any other connections (within the snap radius) that it looks\n *     like they could connect.\n */\nfunction blockIsInSnapRadius(\n  block: BlockSvg,\n  offset: Coordinate,\n  snapRadius: number,\n): boolean {\n  return block\n    .getConnections_(false)\n    .some((connection) => !!connection.closest(snapRadius, offset).connection);\n}\n\nexport interface BlockCopyData extends ICopyData {\n  blockState: State;\n  typeCounts: {[key: string]: number};\n}\n\nregistry.register(BlockPaster.TYPE, new BlockPaster());\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.clipboard\n\nimport type {ICopyData, ICopyable} from './interfaces/i_copyable.js';\nimport {BlockPaster} from './clipboard/block_paster.js';\nimport * as globalRegistry from './registry.js';\nimport {WorkspaceSvg} from './workspace_svg.js';\nimport * as registry from './clipboard/registry.js';\nimport {Coordinate} from './utils/coordinate.js';\nimport * as deprecation from './utils/deprecation.js';\n\n/** Metadata about the object that is currently on the clipboard. */\nlet stashedCopyData: ICopyData | null = null;\n\nlet stashedWorkspace: WorkspaceSvg | null = null;\n\n/**\n * Copy a copyable element onto the local clipboard.\n *\n * @param toCopy The copyable element to be copied.\n * @deprecated v11. Use `myCopyable.toCopyData()` instead. To be removed v12.\n * @internal\n */\nexport function copy(toCopy: ICopyable): T | null {\n  deprecation.warn(\n    'Blockly.clipboard.copy',\n    'v11',\n    'v12',\n    'myCopyable.toCopyData()',\n  );\n  return TEST_ONLY.copyInternal(toCopy);\n}\n\n/**\n * Private version of copy for stubbing in tests.\n */\nfunction copyInternal(toCopy: ICopyable): T | null {\n  const data = toCopy.toCopyData();\n  stashedCopyData = data;\n  stashedWorkspace = (toCopy as any).workspace ?? null;\n  return data;\n}\n\n/**\n * Paste a pasteable element into the workspace.\n *\n * @param copyData The data to paste into the workspace.\n * @param workspace The workspace to paste the data into.\n * @param coordinate The location to paste the thing at.\n * @returns The pasted thing if the paste was successful, null otherwise.\n */\nexport function paste(\n  copyData: T,\n  workspace: WorkspaceSvg,\n  coordinate?: Coordinate,\n): ICopyable | null;\n\n/**\n * Pastes the last copied ICopyable into the workspace.\n *\n * @returns the pasted thing if the paste was successful, null otherwise.\n */\nexport function paste(): ICopyable | null;\n\n/**\n * Pastes the given data into the workspace, or the last copied ICopyable if\n * no data is passed.\n *\n * @param copyData The data to paste into the workspace.\n * @param workspace The workspace to paste the data into.\n * @param coordinate The location to paste the thing at.\n * @returns The pasted thing if the paste was successful, null otherwise.\n */\nexport function paste(\n  copyData?: T,\n  workspace?: WorkspaceSvg,\n  coordinate?: Coordinate,\n): ICopyable | null {\n  if (!copyData || !workspace) {\n    if (!stashedCopyData || !stashedWorkspace) return null;\n    return pasteFromData(stashedCopyData, stashedWorkspace);\n  }\n  return pasteFromData(copyData, workspace, coordinate);\n}\n\n/**\n * Paste a pasteable element into the workspace.\n *\n * @param copyData The data to paste into the workspace.\n * @param workspace The workspace to paste the data into.\n * @param coordinate The location to paste the thing at.\n * @returns The pasted thing if the paste was successful, null otherwise.\n */\nfunction pasteFromData(\n  copyData: T,\n  workspace: WorkspaceSvg,\n  coordinate?: Coordinate,\n): ICopyable | null {\n  workspace = workspace.getRootWorkspace() ?? workspace;\n  return (globalRegistry\n    .getObject(globalRegistry.Type.PASTER, copyData.paster, false)\n    ?.paste(copyData, workspace, coordinate) ?? null) as ICopyable | null;\n}\n\n/**\n * Duplicate this copy-paste-able element.\n *\n * @param toDuplicate The element to be duplicated.\n * @returns The element that was duplicated, or null if the duplication failed.\n * @deprecated v11. Use\n *     `Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)` instead.\n *     To be removed v12.\n * @internal\n */\nexport function duplicate<\n  U extends ICopyData,\n  T extends ICopyable & IHasWorkspace,\n>(toDuplicate: T): T | null {\n  deprecation.warn(\n    'Blockly.clipboard.duplicate',\n    'v11',\n    'v12',\n    'Blockly.clipboard.paste(myCopyable.toCopyData(), myWorkspace)',\n  );\n  return TEST_ONLY.duplicateInternal(toDuplicate);\n}\n\n/**\n * Private version of duplicate for stubbing in tests.\n */\nfunction duplicateInternal<\n  U extends ICopyData,\n  T extends ICopyable & IHasWorkspace,\n>(toDuplicate: T): T | null {\n  const data = toDuplicate.toCopyData();\n  if (!data) return null;\n  return paste(data, toDuplicate.workspace) as T;\n}\n\ninterface IHasWorkspace {\n  workspace: WorkspaceSvg;\n}\n\nexport const TEST_ONLY = {\n  duplicateInternal,\n  copyInternal,\n};\n\nexport {BlockPaster, registry};\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.aria\n\n/** ARIA states/properties prefix. */\nconst ARIA_PREFIX = 'aria-';\n\n/** ARIA role attribute. */\nconst ROLE_ATTRIBUTE = 'role';\n\n/**\n * ARIA role values.\n * Copied from Closure's goog.a11y.aria.Role\n */\nexport enum Role {\n  // ARIA role for an interactive control of tabular data.\n  GRID = 'grid',\n\n  // ARIA role for a cell in a grid.\n  GRIDCELL = 'gridcell',\n  // ARIA role for a group of related elements like tree item siblings.\n  GROUP = 'group',\n\n  // ARIA role for a listbox.\n  LISTBOX = 'listbox',\n\n  // ARIA role for a popup menu.\n  MENU = 'menu',\n\n  // ARIA role for menu item elements.\n  MENUITEM = 'menuitem',\n  // ARIA role for a checkbox box element inside a menu.\n  MENUITEMCHECKBOX = 'menuitemcheckbox',\n  // ARIA role for option items that are  children of combobox, listbox, menu,\n  // radiogroup, or tree elements.\n  OPTION = 'option',\n  // ARIA role for ignorable cosmetic elements with no semantic significance.\n  PRESENTATION = 'presentation',\n\n  // ARIA role for a row of cells in a grid.\n  ROW = 'row',\n  // ARIA role for a tree.\n  TREE = 'tree',\n\n  // ARIA role for a tree item that sometimes may be expanded or collapsed.\n  TREEITEM = 'treeitem',\n}\n\n/**\n * ARIA states and properties.\n * Copied from Closure's goog.a11y.aria.State\n */\nexport enum State {\n  // ARIA property for setting the currently active descendant of an element,\n  // for example the selected item in a list box. Value: ID of an element.\n  ACTIVEDESCENDANT = 'activedescendant',\n  // ARIA property defines the total number of columns in a table, grid, or\n  // treegrid.\n  // Value: integer.\n  COLCOUNT = 'colcount',\n  // ARIA state for a disabled item. Value: one of {true, false}.\n  DISABLED = 'disabled',\n\n  // ARIA state for setting whether the element like a tree node is expanded.\n  // Value: one of {true, false, undefined}.\n  EXPANDED = 'expanded',\n\n  // ARIA state indicating that the entered value does not conform. Value:\n  // one of {false, true, 'grammar', 'spelling'}\n  INVALID = 'invalid',\n\n  // ARIA property that provides a label to override any other text, value, or\n  // contents used to describe this element. Value: string.\n  LABEL = 'label',\n  // ARIA property for setting the element which labels another element.\n  // Value: space-separated IDs of elements.\n  LABELLEDBY = 'labelledby',\n\n  // ARIA property for setting the level of an element in the hierarchy.\n  // Value: integer.\n  LEVEL = 'level',\n  // ARIA property indicating if the element is horizontal or vertical.\n  // Value: one of {'vertical', 'horizontal'}.\n  ORIENTATION = 'orientation',\n\n  // ARIA property that defines an element's number of position in a list.\n  // Value: integer.\n  POSINSET = 'posinset',\n\n  // ARIA property defines the total number of rows in a table, grid, or\n  // treegrid.\n  // Value: integer.\n  ROWCOUNT = 'rowcount',\n\n  // ARIA state for setting the currently selected item in the list.\n  // Value: one of {true, false, undefined}.\n  SELECTED = 'selected',\n  // ARIA property defining the number of items in a list. Value: integer.\n  SETSIZE = 'setsize',\n\n  // ARIA property for slider maximum value. Value: number.\n  VALUEMAX = 'valuemax',\n\n  // ARIA property for slider minimum value. Value: number.\n  VALUEMIN = 'valuemin',\n}\n\n/**\n * Sets the role of an element.\n *\n * Similar to Closure's goog.a11y.aria\n *\n * @param element DOM node to set role of.\n * @param roleName Role name.\n */\nexport function setRole(element: Element, roleName: Role) {\n  element.setAttribute(ROLE_ATTRIBUTE, roleName);\n}\n\n/**\n * Sets the state or property of an element.\n * Copied from Closure's goog.a11y.aria\n *\n * @param element DOM node where we set state.\n * @param stateName State attribute being set.\n *     Automatically adds prefix 'aria-' to the state name if the attribute is\n * not an extra attribute.\n * @param value Value for the state attribute.\n */\nexport function setState(\n  element: Element,\n  stateName: State,\n  value: string | boolean | number | string[],\n) {\n  if (Array.isArray(value)) {\n    value = value.join(' ');\n  }\n  const attrStateName = ARIA_PREFIX + stateName;\n  element.setAttribute(attrStateName, `${value}`);\n}\n","/**\n * @license\n * Copyright 2013 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.WidgetDiv\n\nimport * as common from './common.js';\nimport * as dom from './utils/dom.js';\nimport type {Field} from './field.js';\nimport type {Rect} from './utils/rect.js';\nimport type {Size} from './utils/size.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\n\n/** The object currently using this container. */\nlet owner: unknown = null;\n\n/** Optional cleanup function set by whichever object uses the widget. */\nlet dispose: (() => void) | null = null;\n\n/** A class name representing the current owner's workspace renderer. */\nlet rendererClassName = '';\n\n/** A class name representing the current owner's workspace theme. */\nlet themeClassName = '';\n\n/** The HTML container for popup overlays (e.g. editor widgets). */\nlet containerDiv: HTMLDivElement | null;\n\n/**\n * Returns the HTML container for editor widgets.\n *\n * @returns The editor widget container.\n */\nexport function getDiv(): HTMLDivElement | null {\n  return containerDiv;\n}\n\n/**\n * Allows unit tests to reset the div. Do not use outside of tests.\n *\n * @param newDiv The new value for the DIV field.\n * @internal\n */\nexport function testOnly_setDiv(newDiv: HTMLDivElement | null) {\n  containerDiv = newDiv;\n}\n\n/**\n * Create the widget div and inject it onto the page.\n */\nexport function createDom() {\n  if (containerDiv) {\n    return; // Already created.\n  }\n\n  containerDiv = document.createElement('div') as HTMLDivElement;\n  containerDiv.className = 'blocklyWidgetDiv';\n  const container = common.getParentContainer() || document.body;\n  container.appendChild(containerDiv);\n}\n\n/**\n * Initialize and display the widget div.  Close the old one if needed.\n *\n * @param newOwner The object that will be using this container.\n * @param rtl Right-to-left (true) or left-to-right (false).\n * @param newDispose Optional cleanup function to be run when the widget is\n *     closed.\n */\nexport function show(newOwner: unknown, rtl: boolean, newDispose: () => void) {\n  hide();\n  owner = newOwner;\n  dispose = newDispose;\n  const div = containerDiv;\n  if (!div) return;\n  div.style.direction = rtl ? 'rtl' : 'ltr';\n  div.style.display = 'block';\n  const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg;\n  rendererClassName = mainWorkspace.getRenderer().getClassName();\n  themeClassName = mainWorkspace.getTheme().getClassName();\n  if (rendererClassName) {\n    dom.addClass(div, rendererClassName);\n  }\n  if (themeClassName) {\n    dom.addClass(div, themeClassName);\n  }\n}\n\n/**\n * Destroy the widget and hide the div.\n */\nexport function hide() {\n  if (!isVisible()) {\n    return;\n  }\n  owner = null;\n\n  const div = containerDiv;\n  if (!div) return;\n  div.style.display = 'none';\n  div.style.left = '';\n  div.style.top = '';\n  dispose && dispose();\n  dispose = null;\n  div.textContent = '';\n\n  if (rendererClassName) {\n    dom.removeClass(div, rendererClassName);\n    rendererClassName = '';\n  }\n  if (themeClassName) {\n    dom.removeClass(div, themeClassName);\n    themeClassName = '';\n  }\n  (common.getMainWorkspace() as WorkspaceSvg).markFocused();\n}\n\n/**\n * Is the container visible?\n *\n * @returns True if visible.\n */\nexport function isVisible(): boolean {\n  return !!owner;\n}\n\n/**\n * Destroy the widget and hide the div if it is being used by the specified\n * object.\n *\n * @param oldOwner The object that was using this container.\n */\nexport function hideIfOwner(oldOwner: unknown) {\n  if (owner === oldOwner) {\n    hide();\n  }\n}\n/**\n * Set the widget div's position and height.  This function does nothing clever:\n * it will not ensure that your widget div ends up in the visible window.\n *\n * @param x Horizontal location (window coordinates, not body).\n * @param y Vertical location (window coordinates, not body).\n * @param height The height of the widget div (pixels).\n */\nfunction positionInternal(x: number, y: number, height: number) {\n  containerDiv!.style.left = x + 'px';\n  containerDiv!.style.top = y + 'px';\n  containerDiv!.style.height = height + 'px';\n}\n\n/**\n * Position the widget div based on an anchor rectangle.\n * The widget should be placed adjacent to but not overlapping the anchor\n * rectangle.  The preferred position is directly below and aligned to the left\n * (LTR) or right (RTL) side of the anchor.\n *\n * @param viewportBBox The bounding rectangle of the current viewport, in window\n *     coordinates.\n * @param anchorBBox The bounding rectangle of the anchor, in window\n *     coordinates.\n * @param widgetSize The size of the widget that is inside the widget div, in\n *     window coordinates.\n * @param rtl Whether the workspace is in RTL mode.  This determines horizontal\n *     alignment.\n * @internal\n */\nexport function positionWithAnchor(\n  viewportBBox: Rect,\n  anchorBBox: Rect,\n  widgetSize: Size,\n  rtl: boolean,\n) {\n  const y = calculateY(viewportBBox, anchorBBox, widgetSize);\n  const x = calculateX(viewportBBox, anchorBBox, widgetSize, rtl);\n\n  if (y < 0) {\n    positionInternal(x, 0, widgetSize.height + y);\n  } else {\n    positionInternal(x, y, widgetSize.height);\n  }\n}\n\n/**\n * Calculate an x position (in window coordinates) such that the widget will not\n * be offscreen on the right or left.\n *\n * @param viewportBBox The bounding rectangle of the current viewport, in window\n *     coordinates.\n * @param anchorBBox The bounding rectangle of the anchor, in window\n *     coordinates.\n * @param widgetSize The dimensions of the widget inside the widget div.\n * @param rtl Whether the Blockly workspace is in RTL mode.\n * @returns A valid x-coordinate for the top left corner of the widget div, in\n *     window coordinates.\n */\nfunction calculateX(\n  viewportBBox: Rect,\n  anchorBBox: Rect,\n  widgetSize: Size,\n  rtl: boolean,\n): number {\n  if (rtl) {\n    // Try to align the right side of the field and the right side of widget.\n    const widgetLeft = anchorBBox.right - widgetSize.width;\n    // Don't go offscreen left.\n    const x = Math.max(widgetLeft, viewportBBox.left);\n    // But really don't go offscreen right:\n    return Math.min(x, viewportBBox.right - widgetSize.width);\n  } else {\n    // Try to align the left side of the field and the left side of widget.\n    // Don't go offscreen right.\n    const x = Math.min(anchorBBox.left, viewportBBox.right - widgetSize.width);\n    // But left is more important, because that's where the text is.\n    return Math.max(x, viewportBBox.left);\n  }\n}\n\n/**\n * Calculate a y position (in window coordinates) such that the widget will not\n * be offscreen on the top or bottom.\n *\n * @param viewportBBox The bounding rectangle of the current viewport, in window\n *     coordinates.\n * @param anchorBBox The bounding rectangle of the anchor, in window\n *     coordinates.\n * @param widgetSize The dimensions of the widget inside the widget div.\n * @returns A valid y-coordinate for the top left corner of the widget div, in\n *     window coordinates.\n */\nfunction calculateY(\n  viewportBBox: Rect,\n  anchorBBox: Rect,\n  widgetSize: Size,\n): number {\n  // Flip the widget vertically if off the bottom.\n  // The widget could go off the top of the window, but it would also go off\n  // the bottom.  The window is just too small.\n  if (anchorBBox.bottom + widgetSize.height >= viewportBBox.bottom) {\n    // The bottom of the widget is at the top of the field.\n    return anchorBBox.top - widgetSize.height;\n  } else {\n    // The top of the widget is at the bottom of the field.\n    return anchorBBox.bottom;\n  }\n}\n\n/**\n * Determine if the owner is a field for purposes of repositioning.\n * We can't simply check `instanceof Field` as that would introduce a circular\n * dependency.\n */\nfunction isRepositionable(item: any): item is Field {\n  return !!item?.repositionForWindowResize;\n}\n\n/**\n * Reposition the widget div if the owner of it says to.\n * If the owner isn't a field, just give up and hide it.\n */\nexport function repositionForWindowResize(): void {\n  if (!isRepositionable(owner) || !owner.repositionForWindowResize()) {\n    // If the owner is not a Field, or if the owner returns false from the\n    // reposition method, we should hide the widget div. Otherwise, we'll assume\n    // the owner handled any needed resize.\n    hide();\n  }\n}\n","/**\n * @license\n * Copyright 2011 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.ContextMenu\n\nimport type {Block} from './block.js';\nimport type {BlockSvg} from './block_svg.js';\nimport * as browserEvents from './browser_events.js';\nimport * as clipboard from './clipboard.js';\nimport {config} from './config.js';\nimport * as dom from './utils/dom.js';\nimport type {\n  ContextMenuOption,\n  LegacyContextMenuOption,\n} from './contextmenu_registry.js';\nimport * as eventUtils from './events/utils.js';\nimport {Menu} from './menu.js';\nimport {MenuItem} from './menuitem.js';\nimport {Msg} from './msg.js';\nimport * as aria from './utils/aria.js';\nimport {Coordinate} from './utils/coordinate.js';\nimport {Rect} from './utils/rect.js';\nimport * as serializationBlocks from './serialization/blocks.js';\nimport * as svgMath from './utils/svg_math.js';\nimport * as WidgetDiv from './widgetdiv.js';\nimport {WorkspaceCommentSvg} from './workspace_comment_svg.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\nimport * as Xml from './xml.js';\n\n/**\n * Which block is the context menu attached to?\n */\nlet currentBlock: Block | null = null;\n\nconst dummyOwner = {};\n\n/**\n * Gets the block the context menu is currently attached to.\n *\n * @returns The block the context menu is attached to.\n */\nexport function getCurrentBlock(): Block | null {\n  return currentBlock;\n}\n\n/**\n * Sets the block the context menu is currently attached to.\n *\n * @param block The block the context menu is attached to.\n */\nexport function setCurrentBlock(block: Block | null) {\n  currentBlock = block;\n}\n\n/**\n * Menu object.\n */\nlet menu_: Menu | null = null;\n\n/**\n * Construct the menu based on the list of options and show the menu.\n *\n * @param e Mouse event.\n * @param options Array of menu options.\n * @param rtl True if RTL, false if LTR.\n */\nexport function show(\n  e: Event,\n  options: (ContextMenuOption | LegacyContextMenuOption)[],\n  rtl: boolean,\n) {\n  WidgetDiv.show(dummyOwner, rtl, dispose);\n  if (!options.length) {\n    hide();\n    return;\n  }\n  const menu = populate_(options, rtl);\n  menu_ = menu;\n\n  position_(menu, e, rtl);\n  // 1ms delay is required for focusing on context menus because some other\n  // mouse event is still waiting in the queue and clears focus.\n  setTimeout(function () {\n    menu.focus();\n  }, 1);\n  currentBlock = null; // May be set by Blockly.Block.\n}\n\n/**\n * Create the context menu object and populate it with the given options.\n *\n * @param options Array of menu options.\n * @param rtl True if RTL, false if LTR.\n * @returns The menu that will be shown on right click.\n */\nfunction populate_(\n  options: (ContextMenuOption | LegacyContextMenuOption)[],\n  rtl: boolean,\n): Menu {\n  /* Here's what one option object looks like:\n      {text: 'Make It So',\n       enabled: true,\n       callback: Blockly.MakeItSo}\n    */\n  const menu = new Menu();\n  menu.setRole(aria.Role.MENU);\n  for (let i = 0; i < options.length; i++) {\n    const option = options[i];\n    const menuItem = new MenuItem(option.text);\n    menuItem.setRightToLeft(rtl);\n    menuItem.setRole(aria.Role.MENUITEM);\n    menu.addChild(menuItem);\n    menuItem.setEnabled(option.enabled);\n    if (option.enabled) {\n      const actionHandler = function () {\n        hide();\n        requestAnimationFrame(() => {\n          setTimeout(() => {\n            // If .scope does not exist on the option, then the callback\n            // will not be expecting a scope parameter, so there should be\n            // no problems. Just assume it is a ContextMenuOption and we'll\n            // pass undefined if it's not.\n            option.callback((option as ContextMenuOption).scope);\n          }, 0);\n        });\n      };\n      menuItem.onAction(actionHandler, {});\n    }\n  }\n  return menu;\n}\n\n/**\n * Add the menu to the page and position it correctly.\n *\n * @param menu The menu to add and position.\n * @param e Mouse event for the right click that is making the context\n *     menu appear.\n * @param rtl True if RTL, false if LTR.\n */\nfunction position_(menu: Menu, e: Event, rtl: boolean) {\n  // Record windowSize and scrollOffset before adding menu.\n  const viewportBBox = svgMath.getViewportBBox();\n  const mouseEvent = e as MouseEvent;\n  // This one is just a point, but we'll pretend that it's a rect so we can use\n  // some helper functions.\n  const anchorBBox = new Rect(\n    mouseEvent.clientY + viewportBBox.top,\n    mouseEvent.clientY + viewportBBox.top,\n    mouseEvent.clientX + viewportBBox.left,\n    mouseEvent.clientX + viewportBBox.left,\n  );\n\n  createWidget_(menu);\n  const menuSize = menu.getSize();\n\n  if (rtl) {\n    anchorBBox.left += menuSize.width;\n    anchorBBox.right += menuSize.width;\n    viewportBBox.left += menuSize.width;\n    viewportBBox.right += menuSize.width;\n  }\n\n  WidgetDiv.positionWithAnchor(viewportBBox, anchorBBox, menuSize, rtl);\n  // Calling menuDom.focus() has to wait until after the menu has been placed\n  // correctly.  Otherwise it will cause a page scroll to get the misplaced menu\n  // in view.  See issue #1329.\n  menu.focus();\n}\n\n/**\n * Create and render the menu widget inside Blockly's widget div.\n *\n * @param menu The menu to add to the widget div.\n */\nfunction createWidget_(menu: Menu) {\n  const div = WidgetDiv.getDiv();\n  if (!div) {\n    throw Error('Attempting to create a context menu when widget div is null');\n  }\n  const menuDom = menu.render(div);\n  dom.addClass(menuDom, 'blocklyContextMenu');\n  // Prevent system context menu when right-clicking a Blockly context menu.\n  browserEvents.conditionalBind(\n    menuDom as EventTarget,\n    'contextmenu',\n    null,\n    haltPropagation,\n  );\n  // Focus only after the initial render to avoid issue #1329.\n  menu.focus();\n}\n/**\n * Halts the propagation of the event without doing anything else.\n *\n * @param e An event.\n */\nfunction haltPropagation(e: Event) {\n  // This event has been handled.  No need to bubble up to the document.\n  e.preventDefault();\n  e.stopPropagation();\n}\n\n/**\n * Hide the context menu.\n */\nexport function hide() {\n  WidgetDiv.hideIfOwner(dummyOwner);\n  currentBlock = null;\n}\n\n/**\n * Dispose of the menu.\n */\nexport function dispose() {\n  if (menu_) {\n    menu_.dispose();\n    menu_ = null;\n  }\n}\n\n/**\n * Create a callback function that creates and configures a block,\n *   then places the new block next to the original and returns it.\n *\n * @param block Original block.\n * @param state XML or JSON object representation of the new block.\n * @returns Function that creates a block.\n */\nexport function callbackFactory(\n  block: Block,\n  state: Element | serializationBlocks.State,\n): () => BlockSvg {\n  return () => {\n    eventUtils.disable();\n    let newBlock: BlockSvg;\n    try {\n      if (state instanceof Element) {\n        newBlock = Xml.domToBlockInternal(state, block.workspace!) as BlockSvg;\n      } else {\n        newBlock = serializationBlocks.appendInternal(\n          state,\n          block.workspace,\n        ) as BlockSvg;\n      }\n      // Move the new block next to the old block.\n      const xy = block.getRelativeToSurfaceXY();\n      if (block.RTL) {\n        xy.x -= config.snapRadius;\n      } else {\n        xy.x += config.snapRadius;\n      }\n      xy.y += config.snapRadius * 2;\n      newBlock.moveBy(xy.x, xy.y);\n    } finally {\n      eventUtils.enable();\n    }\n    if (eventUtils.isEnabled() && !newBlock.isShadow()) {\n      eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(newBlock));\n    }\n    newBlock.select();\n    return newBlock;\n  };\n}\n\n// Helper functions for creating context menu options.\n\n/**\n * Make a context menu option for deleting the current workspace comment.\n *\n * @param comment The workspace comment where the\n *     right-click originated.\n * @returns A menu option,\n *     containing text, enabled, and a callback.\n * @internal\n */\nexport function commentDeleteOption(\n  comment: WorkspaceCommentSvg,\n): LegacyContextMenuOption {\n  const deleteOption = {\n    text: Msg['REMOVE_COMMENT'],\n    enabled: true,\n    callback: function () {\n      eventUtils.setGroup(true);\n      comment.dispose();\n      eventUtils.setGroup(false);\n    },\n  };\n  return deleteOption;\n}\n\n/**\n * Make a context menu option for duplicating the current workspace comment.\n *\n * @param comment The workspace comment where the\n *     right-click originated.\n * @returns A menu option,\n *     containing text, enabled, and a callback.\n * @internal\n */\nexport function commentDuplicateOption(\n  comment: WorkspaceCommentSvg,\n): LegacyContextMenuOption {\n  const duplicateOption = {\n    text: Msg['DUPLICATE_COMMENT'],\n    enabled: true,\n    callback: function () {\n      const data = comment.toCopyData();\n      if (!data) return;\n      clipboard.paste(data, comment.workspace);\n    },\n  };\n  return duplicateOption;\n}\n\n/**\n * Make a context menu option for adding a comment on the workspace.\n *\n * @param ws The workspace where the right-click\n *     originated.\n * @param e The right-click mouse event.\n * @returns A menu option, containing text, enabled, and a callback.\n *     comments are not bundled in.\n * @internal\n */\nexport function workspaceCommentOption(\n  ws: WorkspaceSvg,\n  e: Event,\n): ContextMenuOption {\n  /**\n   * Helper function to create and position a comment correctly based on the\n   * location of the mouse event.\n   */\n  function addWsComment() {\n    const comment = new WorkspaceCommentSvg(\n      ws,\n      Msg['WORKSPACE_COMMENT_DEFAULT_TEXT'],\n      WorkspaceCommentSvg.DEFAULT_SIZE,\n      WorkspaceCommentSvg.DEFAULT_SIZE,\n    );\n\n    const injectionDiv = ws.getInjectionDiv();\n    // Bounding rect coordinates are in client coordinates, meaning that they\n    // are in pixels relative to the upper left corner of the visible browser\n    // window.  These coordinates change when you scroll the browser window.\n    const boundingRect = injectionDiv.getBoundingClientRect();\n\n    // The client coordinates offset by the injection div's upper left corner.\n    const mouseEvent = e as MouseEvent;\n    const clientOffsetPixels = new Coordinate(\n      mouseEvent.clientX - boundingRect.left,\n      mouseEvent.clientY - boundingRect.top,\n    );\n\n    // The offset in pixels between the main workspace's origin and the upper\n    // left corner of the injection div.\n    const mainOffsetPixels = ws.getOriginOffsetInPixels();\n\n    // The position of the new comment in pixels relative to the origin of the\n    // main workspace.\n    const finalOffset = Coordinate.difference(\n      clientOffsetPixels,\n      mainOffsetPixels,\n    );\n    // The position of the new comment in main workspace coordinates.\n    finalOffset.scale(1 / ws.scale);\n\n    const commentX = finalOffset.x;\n    const commentY = finalOffset.y;\n    comment.moveBy(commentX, commentY);\n    if (ws.rendered) {\n      comment.initSvg();\n      comment.render();\n      comment.select();\n    }\n  }\n\n  const wsCommentOption = {\n    enabled: true,\n  } as ContextMenuOption;\n  wsCommentOption.text = Msg['ADD_COMMENT'];\n  wsCommentOption.callback = function () {\n    addWsComment();\n  };\n  return wsCommentOption;\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.math\n\n/**\n * Converts degrees to radians.\n * Copied from Closure's goog.math.toRadians.\n *\n * @param angleDegrees Angle in degrees.\n * @returns Angle in radians.\n */\nexport function toRadians(angleDegrees: number): number {\n  return (angleDegrees * Math.PI) / 180;\n}\n\n/**\n * Converts radians to degrees.\n * Copied from Closure's goog.math.toDegrees.\n *\n * @param angleRadians Angle in radians.\n * @returns Angle in degrees.\n */\nexport function toDegrees(angleRadians: number): number {\n  return (angleRadians * 180) / Math.PI;\n}\n\n/**\n * Clamp the provided number between the lower bound and the upper bound.\n *\n * @param lowerBound The desired lower bound.\n * @param number The number to clamp.\n * @param upperBound The desired upper bound.\n * @returns The clamped number.\n */\nexport function clamp(\n  lowerBound: number,\n  number: number,\n  upperBound: number,\n): number {\n  if (upperBound < lowerBound) {\n    const temp = upperBound;\n    upperBound = lowerBound;\n    lowerBound = temp;\n  }\n  return Math.max(lowerBound, Math.min(number, upperBound));\n}\n","/**\n * @license\n * Copyright 2016 Massachusetts Institute of Technology\n * All rights reserved.\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * A div that floats on top of the workspace, for drop-down menus.\n *\n * @class\n */\n// Former goog.module ID: Blockly.dropDownDiv\n\nimport type {BlockSvg} from './block_svg.js';\nimport * as common from './common.js';\nimport * as dom from './utils/dom.js';\nimport type {Field} from './field.js';\nimport * as math from './utils/math.js';\nimport {Rect} from './utils/rect.js';\nimport type {Size} from './utils/size.js';\nimport * as style from './utils/style.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\n\n/**\n * Arrow size in px. Should match the value in CSS\n * (need to position pre-render).\n */\nexport const ARROW_SIZE = 16;\n\n/**\n * Drop-down border size in px. Should match the value in CSS (need to position\n * the arrow).\n */\nexport const BORDER_SIZE = 1;\n\n/**\n * Amount the arrow must be kept away from the edges of the main drop-down div,\n * in px.\n */\nexport const ARROW_HORIZONTAL_PADDING = 12;\n\n/** Amount drop-downs should be padded away from the source, in px. */\nexport const PADDING_Y = 16;\n\n/** Length of animations in seconds. */\nexport const ANIMATION_TIME = 0.25;\n\n/**\n * Timer for animation out, to be cleared if we need to immediately hide\n * without disrupting new shows.\n */\nlet animateOutTimer: ReturnType | null = null;\n\n/** Callback for when the drop-down is hidden. */\nlet onHide: Function | null = null;\n\n/** A class name representing the current owner's workspace renderer. */\nlet renderedClassName = '';\n\n/** A class name representing the current owner's workspace theme. */\nlet themeClassName = '';\n\n/** The content element. */\nlet div: HTMLDivElement;\n\n/** The content element. */\nlet content: HTMLDivElement;\n\n/** The arrow element. */\nlet arrow: HTMLDivElement;\n\n/**\n * Drop-downs will appear within the bounds of this element if possible.\n * Set in setBoundsElement.\n */\nlet boundsElement: Element | null = null;\n\n/** The object currently using the drop-down. */\nlet owner: Field | null = null;\n\n/** Whether the dropdown was positioned to a field or the source block. */\nlet positionToField: boolean | null = null;\n\n/**\n * Dropdown bounds info object used to encapsulate sizing information about a\n * bounding element (bounding box and width/height).\n */\nexport interface BoundsInfo {\n  top: number;\n  left: number;\n  bottom: number;\n  right: number;\n  width: number;\n  height: number;\n}\n\n/** Dropdown position metrics. */\nexport interface PositionMetrics {\n  initialX: number;\n  initialY: number;\n  finalX: number;\n  finalY: number;\n  arrowX: number | null;\n  arrowY: number | null;\n  arrowAtTop: boolean | null;\n  arrowVisible: boolean;\n}\n\n/**\n * Create and insert the DOM element for this div.\n *\n * @internal\n */\nexport function createDom() {\n  if (div) {\n    return; // Already created.\n  }\n  div = document.createElement('div');\n  div.className = 'blocklyDropDownDiv';\n  const parentDiv = common.getParentContainer() || document.body;\n  parentDiv.appendChild(div);\n\n  content = document.createElement('div');\n  content.className = 'blocklyDropDownContent';\n  div.appendChild(content);\n\n  arrow = document.createElement('div');\n  arrow.className = 'blocklyDropDownArrow';\n  div.appendChild(arrow);\n\n  div.style.opacity = '0';\n  // Transition animation for transform: translate() and opacity.\n  div.style.transition =\n    'transform ' + ANIMATION_TIME + 's, ' + 'opacity ' + ANIMATION_TIME + 's';\n\n  // Handle focusin/out events to add a visual indicator when\n  // a child is focused or blurred.\n  div.addEventListener('focusin', function () {\n    dom.addClass(div, 'blocklyFocused');\n  });\n  div.addEventListener('focusout', function () {\n    dom.removeClass(div, 'blocklyFocused');\n  });\n}\n\n/**\n * Set an element to maintain bounds within. Drop-downs will appear\n * within the box of this element if possible.\n *\n * @param boundsElem Element to bind drop-down to.\n */\nexport function setBoundsElement(boundsElem: Element | null) {\n  boundsElement = boundsElem;\n}\n\n/**\n * @returns The field that currently owns this, or null.\n */\nexport function getOwner(): Field | null {\n  return owner;\n}\n\n/**\n * Provide the div for inserting content into the drop-down.\n *\n * @returns Div to populate with content.\n */\nexport function getContentDiv(): Element {\n  return content;\n}\n\n/** Clear the content of the drop-down. */\nexport function clearContent() {\n  content.textContent = '';\n  content.style.width = '';\n}\n\n/**\n * Set the colour for the drop-down.\n *\n * @param backgroundColour Any CSS colour for the background.\n * @param borderColour Any CSS colour for the border.\n */\nexport function setColour(backgroundColour: string, borderColour: string) {\n  div.style.backgroundColor = backgroundColour;\n  div.style.borderColor = borderColour;\n}\n\n/**\n * Shortcut to show and place the drop-down with positioning determined\n * by a particular block. The primary position will be below the block,\n * and the secondary position above the block. Drop-down will be\n * constrained to the block's workspace.\n *\n * @param field The field showing the drop-down.\n * @param block Block to position the drop-down around.\n * @param opt_onHide Optional callback for when the drop-down is hidden.\n * @param opt_secondaryYOffset Optional Y offset for above-block positioning.\n * @returns True if the menu rendered below block; false if above.\n */\nexport function showPositionedByBlock(\n  field: Field,\n  block: BlockSvg,\n  opt_onHide?: Function,\n  opt_secondaryYOffset?: number,\n): boolean {\n  return showPositionedByRect(\n    getScaledBboxOfBlock(block),\n    field as Field,\n    opt_onHide,\n    opt_secondaryYOffset,\n  );\n}\n\n/**\n * Shortcut to show and place the drop-down with positioning determined\n * by a particular field. The primary position will be below the field,\n * and the secondary position above the field. Drop-down will be\n * constrained to the block's workspace.\n *\n * @param field The field to position the dropdown against.\n * @param opt_onHide Optional callback for when the drop-down is hidden.\n * @param opt_secondaryYOffset Optional Y offset for above-block positioning.\n * @returns True if the menu rendered below block; false if above.\n */\nexport function showPositionedByField(\n  field: Field,\n  opt_onHide?: Function,\n  opt_secondaryYOffset?: number,\n): boolean {\n  positionToField = true;\n  return showPositionedByRect(\n    getScaledBboxOfField(field as Field),\n    field as Field,\n    opt_onHide,\n    opt_secondaryYOffset,\n  );\n}\n/**\n * Get the scaled bounding box of a block.\n *\n * @param block The block.\n * @returns The scaled bounding box of the block.\n */\nfunction getScaledBboxOfBlock(block: BlockSvg): Rect {\n  const blockSvg = block.getSvgRoot();\n  const scale = block.workspace.scale;\n  const scaledHeight = block.height * scale;\n  const scaledWidth = block.width * scale;\n  const xy = style.getPageOffset(blockSvg);\n  return new Rect(xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth);\n}\n\n/**\n * Get the scaled bounding box of a field.\n *\n * @param field The field.\n * @returns The scaled bounding box of the field.\n */\nfunction getScaledBboxOfField(field: Field): Rect {\n  const bBox = field.getScaledBBox();\n  return new Rect(bBox.top, bBox.bottom, bBox.left, bBox.right);\n}\n\n/**\n * Helper method to show and place the drop-down with positioning determined\n * by a scaled bounding box.  The primary position will be below the rect,\n * and the secondary position above the rect. Drop-down will be constrained to\n * the block's workspace.\n *\n * @param bBox The scaled bounding box.\n * @param field The field to position the dropdown against.\n * @param opt_onHide Optional callback for when the drop-down is hidden.\n * @param opt_secondaryYOffset Optional Y offset for above-block positioning.\n * @returns True if the menu rendered below block; false if above.\n */\nfunction showPositionedByRect(\n  bBox: Rect,\n  field: Field,\n  opt_onHide?: Function,\n  opt_secondaryYOffset?: number,\n): boolean {\n  // If we can fit it, render below the block.\n  const primaryX = bBox.left + (bBox.right - bBox.left) / 2;\n  const primaryY = bBox.bottom;\n  // If we can't fit it, render above the entire parent block.\n  const secondaryX = primaryX;\n  let secondaryY = bBox.top;\n  if (opt_secondaryYOffset) {\n    secondaryY += opt_secondaryYOffset;\n  }\n  const sourceBlock = field.getSourceBlock() as BlockSvg;\n  // Set bounds to main workspace; show the drop-down.\n  let workspace = sourceBlock.workspace;\n  while (workspace.options.parentWorkspace) {\n    workspace = workspace.options.parentWorkspace;\n  }\n  setBoundsElement(workspace.getParentSvg().parentNode as Element | null);\n  return show(\n    field,\n    sourceBlock.RTL,\n    primaryX,\n    primaryY,\n    secondaryX,\n    secondaryY,\n    opt_onHide,\n  );\n}\n\n/**\n * Show and place the drop-down.\n * The drop-down is placed with an absolute \"origin point\" (x, y) - i.e.,\n * the arrow will point at this origin and box will positioned below or above\n * it.  If we can maintain the container bounds at the primary point, the arrow\n * will point there, and the container will be positioned below it.\n * If we can't maintain the container bounds at the primary point, fall-back to\n * the secondary point and position above.\n *\n * @param newOwner The object showing the drop-down\n * @param rtl Right-to-left (true) or left-to-right (false).\n * @param primaryX Desired origin point x, in absolute px.\n * @param primaryY Desired origin point y, in absolute px.\n * @param secondaryX Secondary/alternative origin point x, in absolute px.\n * @param secondaryY Secondary/alternative origin point y, in absolute px.\n * @param opt_onHide Optional callback for when the drop-down is hidden.\n * @returns True if the menu rendered at the primary origin point.\n * @internal\n */\nexport function show(\n  newOwner: Field,\n  rtl: boolean,\n  primaryX: number,\n  primaryY: number,\n  secondaryX: number,\n  secondaryY: number,\n  opt_onHide?: Function,\n): boolean {\n  owner = newOwner as Field;\n  onHide = opt_onHide || null;\n  // Set direction.\n  div.style.direction = rtl ? 'rtl' : 'ltr';\n\n  const mainWorkspace = common.getMainWorkspace() as WorkspaceSvg;\n  renderedClassName = mainWorkspace.getRenderer().getClassName();\n  themeClassName = mainWorkspace.getTheme().getClassName();\n  if (renderedClassName) {\n    dom.addClass(div, renderedClassName);\n  }\n  if (themeClassName) {\n    dom.addClass(div, themeClassName);\n  }\n\n  // When we change `translate` multiple times in close succession,\n  // Chrome may choose to wait and apply them all at once.\n  // Since we want the translation to initial X, Y to be immediate,\n  // and the translation to final X, Y to be animated,\n  // we saw problems where both would be applied after animation was turned on,\n  // making the dropdown appear to fly in from (0, 0).\n  // Using both `left`, `top` for the initial translation and then `translate`\n  // for the animated transition to final X, Y is a workaround.\n  return positionInternal(primaryX, primaryY, secondaryX, secondaryY);\n}\n\nconst internal = {\n  /**\n   * Get sizing info about the bounding element.\n   *\n   * @returns An object containing size information about the bounding element\n   *     (bounding box and width/height).\n   */\n  getBoundsInfo: function (): BoundsInfo {\n    const boundPosition = style.getPageOffset(boundsElement as Element);\n    const boundSize = style.getSize(boundsElement as Element);\n\n    return {\n      left: boundPosition.x,\n      right: boundPosition.x + boundSize.width,\n      top: boundPosition.y,\n      bottom: boundPosition.y + boundSize.height,\n      width: boundSize.width,\n      height: boundSize.height,\n    };\n  },\n\n  /**\n   * Helper to position the drop-down and the arrow, maintaining bounds.\n   * See explanation of origin points in show.\n   *\n   * @param primaryX Desired origin point x, in absolute px.\n   * @param primaryY Desired origin point y, in absolute px.\n   * @param secondaryX Secondary/alternative origin point x, in absolute px.\n   * @param secondaryY Secondary/alternative origin point y, in absolute px.\n   * @returns Various final metrics, including rendered positions for drop-down\n   *     and arrow.\n   */\n  getPositionMetrics: function (\n    primaryX: number,\n    primaryY: number,\n    secondaryX: number,\n    secondaryY: number,\n  ): PositionMetrics {\n    const boundsInfo = internal.getBoundsInfo();\n    const divSize = style.getSize(div as Element);\n\n    // Can we fit in-bounds below the target?\n    if (primaryY + divSize.height < boundsInfo.bottom) {\n      return getPositionBelowMetrics(primaryX, primaryY, boundsInfo, divSize);\n    }\n    // Can we fit in-bounds above the target?\n    if (secondaryY - divSize.height > boundsInfo.top) {\n      return getPositionAboveMetrics(\n        secondaryX,\n        secondaryY,\n        boundsInfo,\n        divSize,\n      );\n    }\n    // Can we fit outside the workspace bounds (but inside the window)\n    // below?\n    if (primaryY + divSize.height < document.documentElement.clientHeight) {\n      return getPositionBelowMetrics(primaryX, primaryY, boundsInfo, divSize);\n    }\n    // Can we fit outside the workspace bounds (but inside the window)\n    // above?\n    if (secondaryY - divSize.height > document.documentElement.clientTop) {\n      return getPositionAboveMetrics(\n        secondaryX,\n        secondaryY,\n        boundsInfo,\n        divSize,\n      );\n    }\n\n    // Last resort, render at top of page.\n    return getPositionTopOfPageMetrics(primaryX, boundsInfo, divSize);\n  },\n};\n\n/**\n * Get the metrics for positioning the div below the source.\n *\n * @param primaryX Desired origin point x, in absolute px.\n * @param primaryY Desired origin point y, in absolute px.\n * @param boundsInfo An object containing size information about the bounding\n *     element (bounding box and width/height).\n * @param divSize An object containing information about the size of the\n *     DropDownDiv (width & height).\n * @returns Various final metrics, including rendered positions for drop-down\n *     and arrow.\n */\nfunction getPositionBelowMetrics(\n  primaryX: number,\n  primaryY: number,\n  boundsInfo: BoundsInfo,\n  divSize: Size,\n): PositionMetrics {\n  const xCoords = getPositionX(\n    primaryX,\n    boundsInfo.left,\n    boundsInfo.right,\n    divSize.width,\n  );\n\n  const arrowY = -(ARROW_SIZE / 2 + BORDER_SIZE);\n  const finalY = primaryY + PADDING_Y;\n\n  return {\n    initialX: xCoords.divX,\n    initialY: primaryY,\n    finalX: xCoords.divX, // X position remains constant during animation.\n    finalY,\n    arrowX: xCoords.arrowX,\n    arrowY,\n    arrowAtTop: true,\n    arrowVisible: true,\n  };\n}\n\n/**\n * Get the metrics for positioning the div above the source.\n *\n * @param secondaryX Secondary/alternative origin point x, in absolute px.\n * @param secondaryY Secondary/alternative origin point y, in absolute px.\n * @param boundsInfo An object containing size information about the bounding\n *     element (bounding box and width/height).\n * @param divSize An object containing information about the size of the\n *     DropDownDiv (width & height).\n * @returns Various final metrics, including rendered positions for drop-down\n *     and arrow.\n */\nfunction getPositionAboveMetrics(\n  secondaryX: number,\n  secondaryY: number,\n  boundsInfo: BoundsInfo,\n  divSize: Size,\n): PositionMetrics {\n  const xCoords = getPositionX(\n    secondaryX,\n    boundsInfo.left,\n    boundsInfo.right,\n    divSize.width,\n  );\n\n  const arrowY = divSize.height - BORDER_SIZE * 2 - ARROW_SIZE / 2;\n  const finalY = secondaryY - divSize.height - PADDING_Y;\n  const initialY = secondaryY - divSize.height; // No padding on Y.\n\n  return {\n    initialX: xCoords.divX,\n    initialY,\n    finalX: xCoords.divX, // X position remains constant during animation.\n    finalY,\n    arrowX: xCoords.arrowX,\n    arrowY,\n    arrowAtTop: false,\n    arrowVisible: true,\n  };\n}\n\n/**\n * Get the metrics for positioning the div at the top of the page.\n *\n * @param sourceX Desired origin point x, in absolute px.\n * @param boundsInfo An object containing size information about the bounding\n *     element (bounding box and width/height).\n * @param divSize An object containing information about the size of the\n *     DropDownDiv (width & height).\n * @returns Various final metrics, including rendered positions for drop-down\n *     and arrow.\n */\nfunction getPositionTopOfPageMetrics(\n  sourceX: number,\n  boundsInfo: BoundsInfo,\n  divSize: Size,\n): PositionMetrics {\n  const xCoords = getPositionX(\n    sourceX,\n    boundsInfo.left,\n    boundsInfo.right,\n    divSize.width,\n  );\n\n  // No need to provide arrow-specific information because it won't be visible.\n  return {\n    initialX: xCoords.divX,\n    initialY: 0,\n    finalX: xCoords.divX, // X position remains constant during animation.\n    finalY: 0, // Y position remains constant during animation.\n    arrowAtTop: null,\n    arrowX: null,\n    arrowY: null,\n    arrowVisible: false,\n  };\n}\n\n/**\n * Get the x positions for the left side of the DropDownDiv and the arrow,\n * accounting for the bounds of the workspace.\n *\n * @param sourceX Desired origin point x, in absolute px.\n * @param boundsLeft The left edge of the bounding element, in absolute px.\n * @param boundsRight The right edge of the bounding element, in absolute px.\n * @param divWidth The width of the div in px.\n * @returns An object containing metrics for the x positions of the left side of\n *     the DropDownDiv and the arrow.\n * @internal\n */\nexport function getPositionX(\n  sourceX: number,\n  boundsLeft: number,\n  boundsRight: number,\n  divWidth: number,\n): {divX: number; arrowX: number} {\n  let divX = sourceX;\n  // Offset the topLeft coord so that the dropdowndiv is centered.\n  divX -= divWidth / 2;\n  // Fit the dropdowndiv within the bounds of the workspace.\n  divX = math.clamp(boundsLeft, divX, boundsRight - divWidth);\n\n  let arrowX = sourceX;\n  // Offset the arrow coord so that the arrow is centered.\n  arrowX -= ARROW_SIZE / 2;\n  // Convert the arrow position to be relative to the top left of the div.\n  let relativeArrowX = arrowX - divX;\n  const horizPadding = ARROW_HORIZONTAL_PADDING;\n  // Clamp the arrow position so that it stays attached to the dropdowndiv.\n  relativeArrowX = math.clamp(\n    horizPadding,\n    relativeArrowX,\n    divWidth - horizPadding - ARROW_SIZE,\n  );\n\n  return {arrowX: relativeArrowX, divX};\n}\n\n/**\n * Is the container visible?\n *\n * @returns True if visible.\n */\nexport function isVisible(): boolean {\n  return !!owner;\n}\n\n/**\n * Hide the menu only if it is owned by the provided object.\n *\n * @param divOwner Object which must be owning the drop-down to hide.\n * @param opt_withoutAnimation True if we should hide the dropdown without\n *     animating.\n * @returns True if hidden.\n */\nexport function hideIfOwner(\n  divOwner: Field,\n  opt_withoutAnimation?: boolean,\n): boolean {\n  if (owner === divOwner) {\n    if (opt_withoutAnimation) {\n      hideWithoutAnimation();\n    } else {\n      hide();\n    }\n    return true;\n  }\n  return false;\n}\n\n/** Hide the menu, triggering animation. */\nexport function hide() {\n  // Start the animation by setting the translation and fading out.\n  // Reset to (initialX, initialY) - i.e., no translation.\n  div.style.transform = 'translate(0, 0)';\n  div.style.opacity = '0';\n  // Finish animation - reset all values to default.\n  animateOutTimer = setTimeout(function () {\n    hideWithoutAnimation();\n  }, ANIMATION_TIME * 1000);\n  if (onHide) {\n    onHide();\n    onHide = null;\n  }\n}\n\n/** Hide the menu, without animation. */\nexport function hideWithoutAnimation() {\n  if (!isVisible()) {\n    return;\n  }\n  if (animateOutTimer) {\n    clearTimeout(animateOutTimer);\n  }\n\n  // Reset style properties in case this gets called directly\n  // instead of hide() - see discussion on #2551.\n  div.style.transform = '';\n  div.style.left = '';\n  div.style.top = '';\n  div.style.opacity = '0';\n  div.style.display = 'none';\n  div.style.backgroundColor = '';\n  div.style.borderColor = '';\n\n  if (onHide) {\n    onHide();\n    onHide = null;\n  }\n  clearContent();\n  owner = null;\n\n  if (renderedClassName) {\n    dom.removeClass(div, renderedClassName);\n    renderedClassName = '';\n  }\n  if (themeClassName) {\n    dom.removeClass(div, themeClassName);\n    themeClassName = '';\n  }\n  (common.getMainWorkspace() as WorkspaceSvg).markFocused();\n}\n\n/**\n * Set the dropdown div's position.\n *\n * @param primaryX Desired origin point x, in absolute px.\n * @param primaryY Desired origin point y, in absolute px.\n * @param secondaryX Secondary/alternative origin point x, in absolute px.\n * @param secondaryY Secondary/alternative origin point y, in absolute px.\n * @returns True if the menu rendered at the primary origin point.\n */\nfunction positionInternal(\n  primaryX: number,\n  primaryY: number,\n  secondaryX: number,\n  secondaryY: number,\n): boolean {\n  const metrics = internal.getPositionMetrics(\n    primaryX,\n    primaryY,\n    secondaryX,\n    secondaryY,\n  );\n\n  // Update arrow CSS.\n  if (metrics.arrowVisible) {\n    arrow.style.display = '';\n    arrow.style.transform =\n      'translate(' +\n      metrics.arrowX +\n      'px,' +\n      metrics.arrowY +\n      'px) rotate(45deg)';\n    arrow.setAttribute(\n      'class',\n      metrics.arrowAtTop\n        ? 'blocklyDropDownArrow blocklyArrowTop'\n        : 'blocklyDropDownArrow blocklyArrowBottom',\n    );\n  } else {\n    arrow.style.display = 'none';\n  }\n\n  const initialX = Math.floor(metrics.initialX);\n  const initialY = Math.floor(metrics.initialY);\n  const finalX = Math.floor(metrics.finalX);\n  const finalY = Math.floor(metrics.finalY);\n\n  // First apply initial translation.\n  div.style.left = initialX + 'px';\n  div.style.top = initialY + 'px';\n\n  // Show the div.\n  div.style.display = 'block';\n  div.style.opacity = '1';\n  // Add final translate, animated through `transition`.\n  // Coordinates are relative to (initialX, initialY),\n  // where the drop-down is absolutely positioned.\n  const dx = finalX - initialX;\n  const dy = finalY - initialY;\n  div.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';\n\n  return !!metrics.arrowAtTop;\n}\n\n/**\n * Repositions the dropdownDiv on window resize. If it doesn't know how to\n * calculate the new position, it will just hide it instead.\n *\n * @internal\n */\nexport function repositionForWindowResize() {\n  // This condition mainly catches the dropdown div when it is being used as a\n  // dropdown.  It is important not to close it in this case because on Android,\n  // when a field is focused, the soft keyboard opens triggering a window resize\n  // event and we want the dropdown div to stick around so users can type into\n  // it.\n  if (owner) {\n    const block = owner.getSourceBlock() as BlockSvg;\n    const bBox = positionToField\n      ? getScaledBboxOfField(owner)\n      : getScaledBboxOfBlock(block);\n    // If we can fit it, render below the block.\n    const primaryX = bBox.left + (bBox.right - bBox.left) / 2;\n    const primaryY = bBox.bottom;\n    // If we can't fit it, render above the entire parent block.\n    const secondaryX = primaryX;\n    const secondaryY = bBox.top;\n    positionInternal(primaryX, primaryY, secondaryX, secondaryY);\n  } else {\n    hide();\n  }\n}\n\nexport const TEST_ONLY = internal;\n","/**\n * @license\n * Copyright 2018 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.blockAnimations\n\nimport type {BlockSvg} from './block_svg.js';\nimport * as dom from './utils/dom.js';\nimport {Svg} from './utils/svg.js';\n\n/** A bounding box for a cloned block. */\ninterface CloneRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\n/** PID of disconnect UI animation.  There can only be one at a time. */\nlet disconnectPid: ReturnType | null = null;\n\n/** The wobbling block.  There can only be one at a time. */\nlet wobblingBlock: BlockSvg | null = null;\n\n/**\n * Play some UI effects (sound, animation) when disposing of a block.\n *\n * @param block The block being disposed of.\n * @internal\n */\nexport function disposeUiEffect(block: BlockSvg) {\n  // Disposing is going to take so long the animation won't play anyway.\n  if (block.getDescendants(false).length > 100) return;\n\n  const workspace = block.workspace;\n  const svgGroup = block.getSvgRoot();\n  workspace.getAudioManager().play('delete');\n\n  const xy = workspace.getSvgXY(svgGroup);\n  // Deeply clone the current block.\n  const clone: SVGGElement = svgGroup.cloneNode(true) as SVGGElement;\n  clone.setAttribute('transform', 'translate(' + xy.x + ',' + xy.y + ')');\n  workspace.getParentSvg().appendChild(clone);\n  const cloneRect = {\n    'x': xy.x,\n    'y': xy.y,\n    'width': block.width,\n    'height': block.height,\n  };\n  disposeUiStep(clone, cloneRect, workspace.RTL, new Date(), workspace.scale);\n}\n/**\n * Animate a cloned block and eventually dispose of it.\n * This is a class method, not an instance method since the original block has\n * been destroyed and is no longer accessible.\n *\n * @param clone SVG element to animate and dispose of.\n * @param rect Starting rect of the clone.\n * @param rtl True if RTL, false if LTR.\n * @param start Date of animation's start.\n * @param workspaceScale Scale of workspace.\n */\nfunction disposeUiStep(\n  clone: Element,\n  rect: CloneRect,\n  rtl: boolean,\n  start: Date,\n  workspaceScale: number,\n) {\n  const ms = new Date().getTime() - start.getTime();\n  const percent = ms / 150;\n  if (percent > 1) {\n    dom.removeNode(clone);\n  } else {\n    const x =\n      rect.x + (((rtl ? -1 : 1) * rect.width * workspaceScale) / 2) * percent;\n    const y = rect.y + rect.height * workspaceScale * percent;\n    const scale = (1 - percent) * workspaceScale;\n    clone.setAttribute(\n      'transform',\n      'translate(' + x + ',' + y + ')' + ' scale(' + scale + ')',\n    );\n    setTimeout(disposeUiStep, 10, clone, rect, rtl, start, workspaceScale);\n  }\n}\n\n/**\n * Play some UI effects (sound, ripple) after a connection has been established.\n *\n * @param block The block being connected.\n * @internal\n */\nexport function connectionUiEffect(block: BlockSvg) {\n  const workspace = block.workspace;\n  const scale = workspace.scale;\n  workspace.getAudioManager().play('click');\n  if (scale < 1) {\n    return; // Too small to care about visual effects.\n  }\n  // Determine the absolute coordinates of the inferior block.\n  const xy = workspace.getSvgXY(block.getSvgRoot());\n  // Offset the coordinates based on the two connection types, fix scale.\n  if (block.outputConnection) {\n    xy.x += (block.RTL ? 3 : -3) * scale;\n    xy.y += 13 * scale;\n  } else if (block.previousConnection) {\n    xy.x += (block.RTL ? -23 : 23) * scale;\n    xy.y += 3 * scale;\n  }\n  const ripple = dom.createSvgElement(\n    Svg.CIRCLE,\n    {\n      'cx': xy.x,\n      'cy': xy.y,\n      'r': 0,\n      'fill': 'none',\n      'stroke': '#888',\n      'stroke-width': 10,\n    },\n    workspace.getParentSvg(),\n  );\n\n  const scaleAnimation = dom.createSvgElement(\n    Svg.ANIMATE,\n    {\n      'id': 'animationCircle',\n      'begin': 'indefinite',\n      'attributeName': 'r',\n      'dur': '150ms',\n      'from': 0,\n      'to': 25 * scale,\n    },\n    ripple,\n  );\n  const opacityAnimation = dom.createSvgElement(\n    Svg.ANIMATE,\n    {\n      'id': 'animationOpacity',\n      'begin': 'indefinite',\n      'attributeName': 'opacity',\n      'dur': '150ms',\n      'from': 1,\n      'to': 0,\n    },\n    ripple,\n  );\n\n  scaleAnimation.beginElement();\n  opacityAnimation.beginElement();\n\n  setTimeout(() => void dom.removeNode(ripple), 150);\n}\n\n/**\n * Play some UI effects (sound, animation) when disconnecting a block.\n *\n * @param block The block being disconnected.\n * @internal\n */\nexport function disconnectUiEffect(block: BlockSvg) {\n  disconnectUiStop();\n  block.workspace.getAudioManager().play('disconnect');\n  if (block.workspace.scale < 1) {\n    return; // Too small to care about visual effects.\n  }\n  // Horizontal distance for bottom of block to wiggle.\n  const DISPLACEMENT = 10;\n  // Scale magnitude of skew to height of block.\n  const height = block.getHeightWidth().height;\n  let magnitude = (Math.atan(DISPLACEMENT / height) / Math.PI) * 180;\n  if (!block.RTL) {\n    magnitude *= -1;\n  }\n  // Start the animation.\n  wobblingBlock = block;\n  disconnectUiStep(block, magnitude, new Date());\n}\n\n/**\n * Animate a brief wiggle of a disconnected block.\n *\n * @param block Block to animate.\n * @param magnitude Maximum degrees skew (reversed for RTL).\n * @param start Date of animation's start.\n */\nfunction disconnectUiStep(block: BlockSvg, magnitude: number, start: Date) {\n  const DURATION = 200; // Milliseconds.\n  const WIGGLES = 3; // Half oscillations.\n\n  const ms = new Date().getTime() - start.getTime();\n  const percent = ms / DURATION;\n\n  let skew = '';\n  if (percent <= 1) {\n    const val = Math.round(\n      Math.sin(percent * Math.PI * WIGGLES) * (1 - percent) * magnitude,\n    );\n    skew = `skewX(${val})`;\n    disconnectPid = setTimeout(disconnectUiStep, 10, block, magnitude, start);\n  }\n\n  block\n    .getSvgRoot()\n    .setAttribute('transform', `${block.getTranslation()} ${skew}`);\n}\n\n/**\n * Stop the disconnect UI animation immediately.\n *\n * @internal\n */\nexport function disconnectUiStop() {\n  if (!wobblingBlock) return;\n  if (disconnectPid) {\n    clearTimeout(disconnectPid);\n    disconnectPid = null;\n  }\n  wobblingBlock\n    .getSvgRoot()\n    .setAttribute('transform', wobblingBlock.getTranslation());\n  wobblingBlock = null;\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.string\n\nimport * as deprecation from './deprecation.js';\n\n/**\n * Fast prefix-checker.\n * Copied from Closure's goog.string.startsWith.\n *\n * @param str The string to check.\n * @param prefix A string to look for at the start of `str`.\n * @returns True if `str` begins with `prefix`.\n * @deprecated Use built-in **string.startsWith** instead.\n */\nexport function startsWith(str: string, prefix: string): boolean {\n  deprecation.warn(\n    'Blockly.utils.string.startsWith()',\n    'April 2022',\n    'April 2023',\n    'Use built-in string.startsWith',\n  );\n  return str.startsWith(prefix);\n}\n\n/**\n * Given an array of strings, return the length of the shortest one.\n *\n * @param array Array of strings.\n * @returns Length of shortest string.\n */\nexport function shortestStringLength(array: string[]): number {\n  if (!array.length) {\n    return 0;\n  }\n  return array.reduce(function (a, b) {\n    return a.length < b.length ? a : b;\n  }).length;\n}\n\n/**\n * Given an array of strings, return the length of the common prefix.\n * Words may not be split.  Any space after a word is included in the length.\n *\n * @param array Array of strings.\n * @param opt_shortest Length of shortest string.\n * @returns Length of common prefix.\n */\nexport function commonWordPrefix(\n  array: string[],\n  opt_shortest?: number,\n): number {\n  if (!array.length) {\n    return 0;\n  } else if (array.length === 1) {\n    return array[0].length;\n  }\n  let wordPrefix = 0;\n  const max = opt_shortest || shortestStringLength(array);\n  let len;\n  for (len = 0; len < max; len++) {\n    const letter = array[0][len];\n    for (let i = 1; i < array.length; i++) {\n      if (letter !== array[i][len]) {\n        return wordPrefix;\n      }\n    }\n    if (letter === ' ') {\n      wordPrefix = len + 1;\n    }\n  }\n  for (let i = 1; i < array.length; i++) {\n    const letter = array[i][len];\n    if (letter && letter !== ' ') {\n      return wordPrefix;\n    }\n  }\n  return max;\n}\n\n/**\n * Given an array of strings, return the length of the common suffix.\n * Words may not be split.  Any space after a word is included in the length.\n *\n * @param array Array of strings.\n * @param opt_shortest Length of shortest string.\n * @returns Length of common suffix.\n */\nexport function commonWordSuffix(\n  array: string[],\n  opt_shortest?: number,\n): number {\n  if (!array.length) {\n    return 0;\n  } else if (array.length === 1) {\n    return array[0].length;\n  }\n  let wordPrefix = 0;\n  const max = opt_shortest || shortestStringLength(array);\n  let len;\n  for (len = 0; len < max; len++) {\n    const letter = array[0].substr(-len - 1, 1);\n    for (let i = 1; i < array.length; i++) {\n      if (letter !== array[i].substr(-len - 1, 1)) {\n        return wordPrefix;\n      }\n    }\n    if (letter === ' ') {\n      wordPrefix = len + 1;\n    }\n  }\n  for (let i = 1; i < array.length; i++) {\n    const letter = array[i].charAt(array[i].length - len - 1);\n    if (letter && letter !== ' ') {\n      return wordPrefix;\n    }\n  }\n  return max;\n}\n\n/**\n * Wrap text to the specified width.\n *\n * @param text Text to wrap.\n * @param limit Width to wrap each line.\n * @returns Wrapped text.\n */\nexport function wrap(text: string, limit: number): string {\n  const lines = text.split('\\n');\n  for (let i = 0; i < lines.length; i++) {\n    lines[i] = wrapLine(lines[i], limit);\n  }\n  return lines.join('\\n');\n}\n\n/**\n * Wrap single line of text to the specified width.\n *\n * @param text Text to wrap.\n * @param limit Width to wrap each line.\n * @returns Wrapped text.\n */\nfunction wrapLine(text: string, limit: number): string {\n  if (text.length <= limit) {\n    // Short text, no need to wrap.\n    return text;\n  }\n  // Split the text into words.\n  const words = text.trim().split(/\\s+/);\n  // Set limit to be the length of the largest word.\n  for (let i = 0; i < words.length; i++) {\n    if (words[i].length > limit) {\n      limit = words[i].length;\n    }\n  }\n\n  let lastScore;\n  let score = -Infinity;\n  let lastText;\n  let lineCount = 1;\n  do {\n    lastScore = score;\n    lastText = text;\n    // Create a list of booleans representing if a space (false) or\n    // a break (true) appears after each word.\n    let wordBreaks = [];\n    // Seed the list with evenly spaced linebreaks.\n    const steps = words.length / lineCount;\n    let insertedBreaks = 1;\n    for (let i = 0; i < words.length - 1; i++) {\n      if (insertedBreaks < (i + 1.5) / steps) {\n        insertedBreaks++;\n        wordBreaks[i] = true;\n      } else {\n        wordBreaks[i] = false;\n      }\n    }\n    wordBreaks = wrapMutate(words, wordBreaks, limit);\n    score = wrapScore(words, wordBreaks, limit);\n    text = wrapToText(words, wordBreaks);\n    lineCount++;\n  } while (score > lastScore);\n  return lastText;\n}\n\n/**\n * Compute a score for how good the wrapping is.\n *\n * @param words Array of each word.\n * @param wordBreaks Array of line breaks.\n * @param limit Width to wrap each line.\n * @returns Larger the better.\n */\nfunction wrapScore(\n  words: string[],\n  wordBreaks: boolean[],\n  limit: number,\n): number {\n  // If this function becomes a performance liability, add caching.\n  // Compute the length of each line.\n  const lineLengths = [0];\n  const linePunctuation = [];\n  for (let i = 0; i < words.length; i++) {\n    lineLengths[lineLengths.length - 1] += words[i].length;\n    if (wordBreaks[i] === true) {\n      lineLengths.push(0);\n      linePunctuation.push(words[i].charAt(words[i].length - 1));\n    } else if (wordBreaks[i] === false) {\n      lineLengths[lineLengths.length - 1]++;\n    }\n  }\n  const maxLength = Math.max(...lineLengths);\n\n  let score = 0;\n  for (let i = 0; i < lineLengths.length; i++) {\n    // Optimize for width.\n    // -2 points per char over limit (scaled to the power of 1.5).\n    score -= Math.pow(Math.abs(limit - lineLengths[i]), 1.5) * 2;\n    // Optimize for even lines.\n    // -1 point per char smaller than max (scaled to the power of 1.5).\n    score -= Math.pow(maxLength - lineLengths[i], 1.5);\n    // Optimize for structure.\n    // Add score to line endings after punctuation.\n    if ('.?!'.indexOf(linePunctuation[i]) !== -1) {\n      score += limit / 3;\n    } else if (',;)]}'.indexOf(linePunctuation[i]) !== -1) {\n      score += limit / 4;\n    }\n  }\n  // All else being equal, the last line should not be longer than the\n  // previous line.  For example, this looks wrong:\n  // aaa bbb\n  // ccc ddd eee\n  if (\n    lineLengths.length > 1 &&\n    lineLengths[lineLengths.length - 1] <= lineLengths[lineLengths.length - 2]\n  ) {\n    score += 0.5;\n  }\n  return score;\n}\n/**\n * Mutate the array of line break locations until an optimal solution is found.\n * No line breaks are added or deleted, they are simply moved around.\n *\n * @param words Array of each word.\n * @param wordBreaks Array of line breaks.\n * @param limit Width to wrap each line.\n * @returns New array of optimal line breaks.\n */\nfunction wrapMutate(\n  words: string[],\n  wordBreaks: boolean[],\n  limit: number,\n): boolean[] {\n  let bestScore = wrapScore(words, wordBreaks, limit);\n  let bestBreaks;\n  // Try shifting every line break forward or backward.\n  for (let i = 0; i < wordBreaks.length - 1; i++) {\n    if (wordBreaks[i] === wordBreaks[i + 1]) {\n      continue;\n    }\n    const mutatedWordBreaks = new Array().concat(wordBreaks);\n    mutatedWordBreaks[i] = !mutatedWordBreaks[i];\n    mutatedWordBreaks[i + 1] = !mutatedWordBreaks[i + 1];\n    const mutatedScore = wrapScore(words, mutatedWordBreaks, limit);\n    if (mutatedScore > bestScore) {\n      bestScore = mutatedScore;\n      bestBreaks = mutatedWordBreaks;\n    }\n  }\n  if (bestBreaks) {\n    // Found an improvement.  See if it may be improved further.\n    return wrapMutate(words, bestBreaks, limit);\n  }\n  // No improvements found.  Done.\n  return wordBreaks;\n}\n\n/**\n * Reassemble the array of words into text, with the specified line breaks.\n *\n * @param words Array of each word.\n * @param wordBreaks Array of line breaks.\n * @returns Plain text.\n */\nfunction wrapToText(words: string[], wordBreaks: boolean[]): string {\n  const text = [];\n  for (let i = 0; i < words.length; i++) {\n    text.push(words[i]);\n    if (wordBreaks[i] !== undefined) {\n      text.push(wordBreaks[i] ? '\\n' : ' ');\n    }\n  }\n  return text.join('');\n}\n\n/**\n * Is the given string a number (includes negative and decimals).\n *\n * @param str Input string.\n * @returns True if number, false otherwise.\n */\nexport function isNumber(str: string): boolean {\n  return /^\\s*-?\\d+(\\.\\d+)?\\s*$/.test(str);\n}\n","/**\n * @license\n * Copyright 2011 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Tooltip\n\nimport * as browserEvents from './browser_events.js';\nimport * as common from './common.js';\nimport * as blocklyString from './utils/string.js';\n\n/**\n * A type which can define a tooltip.\n * Either a string, an object containing a tooltip property, or a function which\n * returns either a string, or another arbitrarily nested function which\n * eventually unwinds to a string.\n */\nexport type TipInfo =\n  | string\n  | {tooltip: AnyDuringMigration}\n  | (() => TipInfo | string | Function);\n\n/**\n * A function that renders custom tooltip UI.\n * 1st parameter: the div element to render content into.\n * 2nd parameter: the element being moused over (i.e., the element for which the\n * tooltip should be shown).\n */\nexport type CustomTooltip = (p1: Element, p2: Element) => AnyDuringMigration;\n\n/**\n * An optional function that renders custom tooltips into the provided DIV. If\n * this is defined, the function will be called instead of rendering the default\n * tooltip UI.\n */\nlet customTooltip: CustomTooltip | undefined = undefined;\n\n/**\n * Sets a custom function that will be called if present instead of the default\n * tooltip UI.\n *\n * @param customFn A custom tooltip used to render an alternate tooltip UI.\n */\nexport function setCustomTooltip(customFn: CustomTooltip) {\n  customTooltip = customFn;\n}\n\n/**\n * Gets the custom tooltip function.\n *\n * @returns The custom tooltip function, if defined.\n */\nexport function getCustomTooltip(): CustomTooltip | undefined {\n  return customTooltip;\n}\n\n/** Is a tooltip currently showing? */\nlet visible = false;\n\n/**\n * Returns whether or not a tooltip is showing\n *\n * @returns True if a tooltip is showing\n */\nexport function isVisible(): boolean {\n  return visible;\n}\n\n/** Is someone else blocking the tooltip from being shown? */\nlet blocked = false;\n\n/**\n * Maximum width (in characters) of a tooltip.\n */\nexport const LIMIT = 50;\n\n/** PID of suspended thread to clear tooltip on mouse out. */\nlet mouseOutPid: AnyDuringMigration = 0;\n\n/** PID of suspended thread to show the tooltip. */\nlet showPid: AnyDuringMigration = 0;\n\n/**\n * Last observed X location of the mouse pointer (freezes when tooltip appears).\n */\nlet lastX = 0;\n\n/**\n * Last observed Y location of the mouse pointer (freezes when tooltip appears).\n */\nlet lastY = 0;\n\n/** Current element being pointed at. */\nlet element: AnyDuringMigration = null;\n\n/**\n * Once a tooltip has opened for an element, that element is 'poisoned' and\n * cannot respawn a tooltip until the pointer moves over a different element.\n */\nlet poisonedElement: AnyDuringMigration = null;\n\n/**\n * Horizontal offset between mouse cursor and tooltip.\n */\nexport const OFFSET_X = 0;\n\n/**\n * Vertical offset between mouse cursor and tooltip.\n */\nexport const OFFSET_Y = 10;\n\n/**\n * Radius mouse can move before killing tooltip.\n */\nexport const RADIUS_OK = 10;\n\n/**\n * Delay before tooltip appears.\n */\nexport const HOVER_MS = 750;\n\n/**\n * Horizontal padding between tooltip and screen edge.\n */\nexport const MARGINS = 5;\n\n/** The HTML container.  Set once by createDom. */\nlet containerDiv: HTMLDivElement | null = null;\n\n/**\n * Returns the HTML tooltip container.\n *\n * @returns The HTML tooltip container.\n */\nexport function getDiv(): HTMLDivElement | null {\n  return containerDiv;\n}\n\n/**\n * Returns the tooltip text for the given element.\n *\n * @param object The object to get the tooltip text of.\n * @returns The tooltip text of the element.\n */\nexport function getTooltipOfObject(object: AnyDuringMigration | null): string {\n  const obj = getTargetObject(object);\n  if (obj) {\n    let tooltip = obj.tooltip;\n    while (typeof tooltip === 'function') {\n      tooltip = tooltip();\n    }\n    if (typeof tooltip !== 'string') {\n      throw Error('Tooltip function must return a string.');\n    }\n    return tooltip;\n  }\n  return '';\n}\n\n/**\n * Returns the target object that the given object is targeting for its\n * tooltip. Could be the object itself.\n *\n * @param obj The object are trying to find the target tooltip object of.\n * @returns The target tooltip object.\n */\nfunction getTargetObject(\n  obj: object | null,\n): {tooltip: AnyDuringMigration} | null {\n  while (obj && (obj as any).tooltip) {\n    if (\n      typeof (obj as any).tooltip === 'string' ||\n      typeof (obj as any).tooltip === 'function'\n    ) {\n      return obj as {tooltip: string | (() => string)};\n    }\n    obj = (obj as any).tooltip;\n  }\n  return null;\n}\n\n/**\n * Create the tooltip div and inject it onto the page.\n */\nexport function createDom() {\n  if (containerDiv) {\n    return; // Already created.\n  }\n  // Create an HTML container for popup overlays (e.g. editor widgets).\n  containerDiv = document.createElement('div');\n  containerDiv.className = 'blocklyTooltipDiv';\n  const container = common.getParentContainer() || document.body;\n  container.appendChild(containerDiv);\n}\n\n/**\n * Binds the required mouse events onto an SVG element.\n *\n * @param element SVG element onto which tooltip is to be bound.\n */\nexport function bindMouseEvents(element: Element) {\n  // TODO (#6097): Don't stash wrapper info on the DOM.\n  (element as AnyDuringMigration).mouseOverWrapper_ = browserEvents.bind(\n    element,\n    'pointerover',\n    null,\n    onMouseOver,\n  );\n  (element as AnyDuringMigration).mouseOutWrapper_ = browserEvents.bind(\n    element,\n    'pointerout',\n    null,\n    onMouseOut,\n  );\n\n  // Don't use bindEvent_ for mousemove since that would create a\n  // corresponding touch handler, even though this only makes sense in the\n  // context of a mouseover/mouseout.\n  element.addEventListener('pointermove', onMouseMove, false);\n}\n\n/**\n * Unbinds tooltip mouse events from the SVG element.\n *\n * @param element SVG element onto which tooltip is bound.\n */\nexport function unbindMouseEvents(element: Element | null) {\n  if (!element) {\n    return;\n  }\n  // TODO (#6097): Don't stash wrapper info on the DOM.\n  browserEvents.unbind((element as AnyDuringMigration).mouseOverWrapper_);\n  browserEvents.unbind((element as AnyDuringMigration).mouseOutWrapper_);\n  element.removeEventListener('pointermove', onMouseMove);\n}\n\n/**\n * Hide the tooltip if the mouse is over a different object.\n * Initialize the tooltip to potentially appear for this object.\n *\n * @param e Mouse event.\n */\nfunction onMouseOver(e: PointerEvent) {\n  if (blocked) {\n    // Someone doesn't want us to show tooltips.\n    return;\n  }\n  // If the tooltip is an object, treat it as a pointer to the next object in\n  // the chain to look at.  Terminate when a string or function is found.\n  const newElement = getTargetObject(e.currentTarget);\n  if (element !== newElement) {\n    hide();\n    poisonedElement = null;\n    element = newElement;\n  }\n  // Forget about any immediately preceding mouseOut event.\n  clearTimeout(mouseOutPid);\n}\n\n/**\n * Hide the tooltip if the mouse leaves the object and enters the workspace.\n *\n * @param _e Mouse event.\n */\nfunction onMouseOut(_e: PointerEvent) {\n  if (blocked) {\n    // Someone doesn't want us to show tooltips.\n    return;\n  }\n  // Moving from one element to another (overlapping or with no gap) generates\n  // a mouseOut followed instantly by a mouseOver.  Fork off the mouseOut\n  // event and kill it if a mouseOver is received immediately.\n  // This way the task only fully executes if mousing into the void.\n  mouseOutPid = setTimeout(function () {\n    element = null;\n    poisonedElement = null;\n    hide();\n  }, 1);\n  clearTimeout(showPid);\n  showPid = 0;\n}\n\n/**\n * When hovering over an element, schedule a tooltip to be shown.  If a tooltip\n * is already visible, hide it if the mouse strays out of a certain radius.\n *\n * @param e Mouse event.\n */\nfunction onMouseMove(e: Event) {\n  if (!element || !(element as AnyDuringMigration).tooltip) {\n    // No tooltip here to show.\n    return;\n  } else if (blocked) {\n    // Someone doesn't want us to show tooltips.  We are probably handling a\n    // user gesture, such as a click or drag.\n    return;\n  }\n  if (visible) {\n    // Compute the distance between the mouse position when the tooltip was\n    // shown and the current mouse position.  Pythagorean theorem.\n    // AnyDuringMigration because:  Property 'pageX' does not exist on type\n    // 'Event'.\n    const dx = lastX - (e as AnyDuringMigration).pageX;\n    // AnyDuringMigration because:  Property 'pageY' does not exist on type\n    // 'Event'.\n    const dy = lastY - (e as AnyDuringMigration).pageY;\n    if (Math.sqrt(dx * dx + dy * dy) > RADIUS_OK) {\n      hide();\n    }\n  } else if (poisonedElement !== element) {\n    // The mouse moved, clear any previously scheduled tooltip.\n    clearTimeout(showPid);\n    // Maybe this time the mouse will stay put.  Schedule showing of tooltip.\n    // AnyDuringMigration because:  Property 'pageX' does not exist on type\n    // 'Event'.\n    lastX = (e as AnyDuringMigration).pageX;\n    // AnyDuringMigration because:  Property 'pageY' does not exist on type\n    // 'Event'.\n    lastY = (e as AnyDuringMigration).pageY;\n    showPid = setTimeout(show, HOVER_MS);\n  }\n}\n\n/**\n * Dispose of the tooltip.\n *\n * @internal\n */\nexport function dispose() {\n  element = null;\n  poisonedElement = null;\n  hide();\n}\n\n/**\n * Hide the tooltip.\n */\nexport function hide() {\n  if (visible) {\n    visible = false;\n    if (containerDiv) {\n      containerDiv.style.display = 'none';\n    }\n  }\n  if (showPid) {\n    clearTimeout(showPid);\n    showPid = 0;\n  }\n}\n\n/**\n * Hide any in-progress tooltips and block showing new tooltips until the next\n * call to unblock().\n *\n * @internal\n */\nexport function block() {\n  hide();\n  blocked = true;\n}\n\n/**\n * Unblock tooltips: allow them to be scheduled and shown according to their own\n * logic.\n *\n * @internal\n */\nexport function unblock() {\n  blocked = false;\n}\n\n/** Renders the tooltip content into the tooltip div. */\nfunction renderContent() {\n  if (!containerDiv || !element) {\n    // This shouldn't happen, but if it does, we can't render.\n    return;\n  }\n  if (typeof customTooltip === 'function') {\n    customTooltip(containerDiv, element);\n  } else {\n    renderDefaultContent();\n  }\n}\n\n/** Renders the default tooltip UI. */\nfunction renderDefaultContent() {\n  let tip = getTooltipOfObject(element);\n  tip = blocklyString.wrap(tip, LIMIT);\n  // Create new text, line by line.\n  const lines = tip.split('\\n');\n  for (let i = 0; i < lines.length; i++) {\n    const div = document.createElement('div');\n    div.appendChild(document.createTextNode(lines[i]));\n    containerDiv!.appendChild(div);\n  }\n}\n\n/**\n * Gets the coordinates for the tooltip div, taking into account the edges of\n * the screen to prevent showing the tooltip offscreen.\n *\n * @param rtl True if the tooltip should be in right-to-left layout.\n * @returns Coordinates at which the tooltip div should be placed.\n */\nfunction getPosition(rtl: boolean): {x: number; y: number} {\n  // Position the tooltip just below the cursor.\n  const windowWidth = document.documentElement.clientWidth;\n  const windowHeight = document.documentElement.clientHeight;\n\n  let anchorX = lastX;\n  if (rtl) {\n    anchorX -= OFFSET_X + containerDiv!.offsetWidth;\n  } else {\n    anchorX += OFFSET_X;\n  }\n\n  let anchorY = lastY + OFFSET_Y;\n  if (anchorY + containerDiv!.offsetHeight > windowHeight + window.scrollY) {\n    // Falling off the bottom of the screen; shift the tooltip up.\n    anchorY -= containerDiv!.offsetHeight + 2 * OFFSET_Y;\n  }\n\n  if (rtl) {\n    // Prevent falling off left edge in RTL mode.\n    anchorX = Math.max(MARGINS - window.scrollX, anchorX);\n  } else {\n    if (\n      anchorX + containerDiv!.offsetWidth >\n      windowWidth + window.scrollX - 2 * MARGINS\n    ) {\n      // Falling off the right edge of the screen;\n      // clamp the tooltip on the edge.\n      anchorX = windowWidth - containerDiv!.offsetWidth - 2 * MARGINS;\n    }\n  }\n\n  return {x: anchorX, y: anchorY};\n}\n\n/** Create the tooltip and show it. */\nfunction show() {\n  if (blocked) {\n    // Someone doesn't want us to show tooltips.\n    return;\n  }\n  poisonedElement = element;\n  if (!containerDiv) {\n    return;\n  }\n  // Erase all existing text.\n  containerDiv.textContent = '';\n\n  // Add new content.\n  renderContent();\n\n  // Display the tooltip.\n  const rtl = (element as any).RTL;\n  containerDiv.style.direction = rtl ? 'rtl' : 'ltr';\n  containerDiv.style.display = 'block';\n  visible = true;\n\n  const {x, y} = getPosition(rtl);\n  containerDiv.style.left = x + 'px';\n  containerDiv.style.top = y + 'px';\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.object\n\n/**\n * Complete a deep merge of all members of a source object with a target object.\n *\n * @param target Target.\n * @param source Source.\n * @returns The resulting object.\n */\nexport function deepMerge(\n  target: AnyDuringMigration,\n  source: AnyDuringMigration,\n): AnyDuringMigration {\n  for (const x in source) {\n    if (source[x] !== null && typeof source[x] === 'object') {\n      target[x] = deepMerge(target[x] || Object.create(null), source[x]);\n    } else {\n      target[x] = source[x];\n    }\n  }\n  return target;\n}\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nexport interface IHasBubble {\n  /** @returns True if the bubble is currently open, false otherwise. */\n  bubbleIsVisible(): boolean;\n\n  /** Sets whether the bubble is open or not. */\n  setBubbleVisible(visible: boolean): void;\n}\n\n/** Type guard that checks whether the given object is a IHasBubble. */\nexport function hasBubble(obj: any): obj is IHasBubble {\n  return (\n    obj.bubbleIsVisible !== undefined && obj.setBubbleVisible !== undefined\n  );\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.colour\n\n/**\n * The richness of block colours, regardless of the hue.\n * Must be in the range of 0 (inclusive) to 1 (exclusive).\n */\nlet hsvSaturation = 0.45;\n\n/**\n * Get the richness of block colours, regardless of the hue.\n *\n * @returns The current richness.\n * @internal\n */\nexport function getHsvSaturation(): number {\n  return hsvSaturation;\n}\n\n/**\n * Set the richness of block colours, regardless of the hue.\n *\n * @param newSaturation The new richness, in the range of  0 (inclusive) to 1\n *     (exclusive)\n * @internal\n */\nexport function setHsvSaturation(newSaturation: number) {\n  hsvSaturation = newSaturation;\n}\n\n/**\n * The intensity of block colours, regardless of the hue.\n * Must be in the range of 0 (inclusive) to 1 (exclusive).\n */\nlet hsvValue = 0.65;\n\n/**\n * Get the intensity of block colours, regardless of the hue.\n *\n * @returns The current intensity.\n * @internal\n */\nexport function getHsvValue(): number {\n  return hsvValue;\n}\n\n/**\n * Set the intensity of block colours, regardless of the hue.\n *\n * @param newValue The new intensity, in the range of  0 (inclusive) to 1\n *     (exclusive)\n * @internal\n */\nexport function setHsvValue(newValue: number) {\n  hsvValue = newValue;\n}\n\n/**\n * Parses a colour from a string.\n * .parse('red') = '#ff0000'\n * .parse('#f00') = '#ff0000'\n * .parse('#ff0000') = '#ff0000'\n * .parse('0xff0000') = '#ff0000'\n * .parse('rgb(255, 0, 0)') = '#ff0000'\n *\n * @param str Colour in some CSS format.\n * @returns A string containing a hex representation of the colour, or null if\n *     can't be parsed.\n */\nexport function parse(str: string | number): string | null {\n  str = `${str}`.toLowerCase().trim();\n  let hex = names[str];\n  if (hex) {\n    // e.g. 'red'\n    return hex;\n  }\n  hex = str.substring(0, 2) === '0x' ? '#' + str.substring(2) : str;\n  hex = hex[0] === '#' ? hex : '#' + hex;\n  if (/^#[0-9a-f]{6}$/.test(hex)) {\n    // e.g. '#00ff88'\n    return hex;\n  }\n  if (/^#[0-9a-f]{3}$/.test(hex)) {\n    // e.g. '#0f8'\n    return ['#', hex[1], hex[1], hex[2], hex[2], hex[3], hex[3]].join('');\n  }\n  const rgb = str.match(/^(?:rgb)?\\s*\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)$/);\n  if (rgb) {\n    // e.g. 'rgb(0, 128, 255)'\n    const r = Number(rgb[1]);\n    const g = Number(rgb[2]);\n    const b = Number(rgb[3]);\n    if (r >= 0 && r < 256 && g >= 0 && g < 256 && b >= 0 && b < 256) {\n      return rgbToHex(r, g, b);\n    }\n  }\n  return null;\n}\n\n/**\n * Converts a colour from RGB to hex representation.\n *\n * @param r Amount of red, int between 0 and 255.\n * @param g Amount of green, int between 0 and 255.\n * @param b Amount of blue, int between 0 and 255.\n * @returns Hex representation of the colour.\n */\nexport function rgbToHex(r: number, g: number, b: number): string {\n  const rgb = (r << 16) | (g << 8) | b;\n  if (r < 0x10) {\n    return '#' + (0x1000000 | rgb).toString(16).substr(1);\n  }\n  return '#' + rgb.toString(16);\n}\n\n/**\n * Converts a colour to RGB.\n *\n * @param colour String representing colour in any colour format ('#ff0000',\n *     'red', '0xff000', etc).\n * @returns RGB representation of the colour.\n */\nexport function hexToRgb(colour: string): number[] {\n  const hex = parse(colour);\n  if (!hex) {\n    return [0, 0, 0];\n  }\n\n  const rgb = parseInt(hex.substr(1), 16);\n  const r = rgb >> 16;\n  const g = (rgb >> 8) & 255;\n  const b = rgb & 255;\n\n  return [r, g, b];\n}\n\n/**\n * Converts an HSV triplet to hex representation.\n *\n * @param h Hue value in [0, 360].\n * @param s Saturation value in [0, 1].\n * @param v Brightness in [0, 255].\n * @returns Hex representation of the colour.\n */\nexport function hsvToHex(h: number, s: number, v: number): string {\n  let red = 0;\n  let green = 0;\n  let blue = 0;\n  if (s === 0) {\n    red = v;\n    green = v;\n    blue = v;\n  } else {\n    const sextant = Math.floor(h / 60);\n    const remainder = h / 60 - sextant;\n    const val1 = v * (1 - s);\n    const val2 = v * (1 - s * remainder);\n    const val3 = v * (1 - s * (1 - remainder));\n    switch (sextant) {\n      case 1:\n        red = val2;\n        green = v;\n        blue = val1;\n        break;\n      case 2:\n        red = val1;\n        green = v;\n        blue = val3;\n        break;\n      case 3:\n        red = val1;\n        green = val2;\n        blue = v;\n        break;\n      case 4:\n        red = val3;\n        green = val1;\n        blue = v;\n        break;\n      case 5:\n        red = v;\n        green = val1;\n        blue = val2;\n        break;\n      case 6:\n      case 0:\n        red = v;\n        green = val3;\n        blue = val1;\n        break;\n    }\n  }\n  return rgbToHex(Math.floor(red), Math.floor(green), Math.floor(blue));\n}\n\n/**\n * Blend two colours together, using the specified factor to indicate the\n * weight given to the first colour.\n *\n * @param colour1 First colour.\n * @param colour2 Second colour.\n * @param factor The weight to be given to colour1 over colour2.\n *     Values should be in the range [0, 1].\n * @returns Combined colour represented in hex.\n */\nexport function blend(\n  colour1: string,\n  colour2: string,\n  factor: number,\n): string | null {\n  const hex1 = parse(colour1);\n  if (!hex1) {\n    return null;\n  }\n  const hex2 = parse(colour2);\n  if (!hex2) {\n    return null;\n  }\n  const rgb1 = hexToRgb(hex1);\n  const rgb2 = hexToRgb(hex2);\n  const r = Math.round(rgb2[0] + factor * (rgb1[0] - rgb2[0]));\n  const g = Math.round(rgb2[1] + factor * (rgb1[1] - rgb2[1]));\n  const b = Math.round(rgb2[2] + factor * (rgb1[2] - rgb2[2]));\n  return rgbToHex(r, g, b);\n}\n\n/**\n * A map that contains the 16 basic colour keywords as defined by W3C:\n * https://www.w3.org/TR/2018/REC-css-color-3-20180619/#html4\n * The keys of this map are the lowercase \"readable\" names of the colours,\n * while the values are the \"hex\" values.\n */\nexport const names: {[key: string]: string} = {\n  'aqua': '#00ffff',\n  'black': '#000000',\n  'blue': '#0000ff',\n  'fuchsia': '#ff00ff',\n  'gray': '#808080',\n  'green': '#008000',\n  'lime': '#00ff00',\n  'maroon': '#800000',\n  'navy': '#000080',\n  'olive': '#808000',\n  'purple': '#800080',\n  'red': '#ff0000',\n  'silver': '#c0c0c0',\n  'teal': '#008080',\n  'white': '#ffffff',\n  'yellow': '#ffff00',\n};\n\n/**\n * Convert a hue (HSV model) into an RGB hex triplet.\n *\n * @param hue Hue on a colour wheel (0-360).\n * @returns RGB code, e.g. '#5ba65b'.\n */\nexport function hueToHex(hue: number): string {\n  return hsvToHex(hue, hsvSaturation, hsvValue * 255);\n}\n","/**\n * @license\n * Copyright 2021 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.parsing\n\nimport {Msg} from '../msg.js';\n\nimport * as colourUtils from './colour.js';\n\n/**\n * Internal implementation of the message reference and interpolation token\n * parsing used by tokenizeInterpolation() and replaceMessageReferences().\n *\n * @param message Text which might contain string table references and\n *     interpolation tokens.\n * @param parseInterpolationTokens Option to parse numeric interpolation\n *     tokens (%1, %2, ...) when true.\n * @param tokenizeNewlines Split individual newline characters into separate\n *     tokens when true.\n * @returns Array of strings and numbers.\n */\nfunction tokenizeInterpolationInternal(\n  message: string,\n  parseInterpolationTokens: boolean,\n  tokenizeNewlines: boolean,\n): (string | number)[] {\n  const tokens = [];\n  const chars = message.split('');\n  chars.push(''); // End marker.\n  // Parse the message with a finite state machine.\n  // 0 - Base case.\n  // 1 - % found.\n  // 2 - Digit found.\n  // 3 - Message ref found.\n  let state = 0;\n  const buffer = new Array();\n  let number = null;\n  for (let i = 0; i < chars.length; i++) {\n    const c = chars[i];\n    if (state === 0) {\n      // Start escape.\n      if (c === '%') {\n        const text = buffer.join('');\n        if (text) {\n          tokens.push(text);\n        }\n        buffer.length = 0;\n        state = 1;\n      } else if (tokenizeNewlines && c === '\\n') {\n        // Output newline characters as single-character tokens, to be replaced\n        // with endOfRow dummies during interpolation.\n        const text = buffer.join('');\n        if (text) {\n          tokens.push(text);\n        }\n        buffer.length = 0;\n        tokens.push(c);\n      } else {\n        buffer.push(c); // Regular char.\n      }\n    } else if (state === 1) {\n      if (c === '%') {\n        buffer.push(c); // Escaped %: %%\n        state = 0;\n      } else if (parseInterpolationTokens && '0' <= c && c <= '9') {\n        state = 2;\n        number = c;\n        const text = buffer.join('');\n        if (text) {\n          tokens.push(text);\n        }\n        buffer.length = 0;\n      } else if (c === '{') {\n        state = 3;\n      } else {\n        buffer.push('%', c); // Not recognized. Return as literal.\n        state = 0;\n      }\n    } else if (state === 2) {\n      if ('0' <= c && c <= '9') {\n        number += c; // Multi-digit number.\n      } else {\n        tokens.push(parseInt(number ?? '', 10));\n        i--; // Parse this char again.\n        state = 0;\n      }\n    } else if (state === 3) {\n      // String table reference\n      if (c === '') {\n        // Premature end before closing '}'\n        buffer.splice(0, 0, '%{'); // Re-insert leading delimiter\n        i--; // Parse this char again.\n        state = 0; // and parse as string literal.\n      } else if (c !== '}') {\n        buffer.push(c);\n      } else {\n        const rawKey = buffer.join('');\n        if (/[A-Z]\\w*/i.test(rawKey)) {\n          // Strict matching\n          // Found a valid string key. Attempt case insensitive match.\n          const keyUpper = rawKey.toUpperCase();\n\n          // BKY_ is the prefix used to namespace the strings used in\n          // Blockly core files and the predefined blocks in ../blocks/.\n          // These strings are defined in ../msgs/ files.\n          const bklyKey = keyUpper.startsWith('BKY_')\n            ? keyUpper.substring(4)\n            : null;\n          if (bklyKey && bklyKey in Msg) {\n            const rawValue = Msg[bklyKey];\n            if (typeof rawValue === 'string') {\n              // Attempt to dereference substrings, too, appending to the\n              // end.\n              Array.prototype.push.apply(\n                tokens,\n                tokenizeInterpolationInternal(\n                  rawValue,\n                  parseInterpolationTokens,\n                  tokenizeNewlines,\n                ),\n              );\n            } else if (parseInterpolationTokens) {\n              // When parsing interpolation tokens, numbers are special\n              // placeholders (%1, %2, etc). Make sure all other values are\n              // strings.\n              tokens.push(`${rawValue}`);\n            } else {\n              tokens.push(rawValue);\n            }\n          } else {\n            // No entry found in the string table. Pass reference as string.\n            tokens.push('%{' + rawKey + '}');\n          }\n          buffer.length = 0; // Clear the array\n          state = 0;\n        } else {\n          tokens.push('%{' + rawKey + '}');\n          buffer.length = 0;\n          state = 0; // and parse as string literal.\n        }\n      }\n    }\n  }\n  let text = buffer.join('');\n  if (text) {\n    tokens.push(text);\n  }\n\n  // Merge adjacent text tokens into a single string (but if newlines should be\n  // tokenized, don't merge those with adjacent text).\n  const mergedTokens = [];\n  buffer.length = 0;\n  for (let i = 0; i < tokens.length; i++) {\n    if (\n      typeof tokens[i] === 'string' &&\n      !(tokenizeNewlines && tokens[i] === '\\n')\n    ) {\n      buffer.push(tokens[i] as string);\n    } else {\n      text = buffer.join('');\n      if (text) {\n        mergedTokens.push(text);\n      }\n      buffer.length = 0;\n      mergedTokens.push(tokens[i]);\n    }\n  }\n  text = buffer.join('');\n  if (text) {\n    mergedTokens.push(text);\n  }\n  buffer.length = 0;\n\n  return mergedTokens;\n}\n\n/**\n * Parse a string with any number of interpolation tokens (%1, %2, ...).\n * It will also replace string table references (e.g., %{bky_my_msg} and\n * %{BKY_MY_MSG} will both be replaced with the value in\n * Msg['MY_MSG']). Percentage sign characters '%' may be self-escaped\n * (e.g., '%%'). Newline characters will also be output as string tokens\n * containing a single newline character.\n *\n * @param message Text which might contain string table references and\n *     interpolation tokens.\n * @returns Array of strings and numbers.\n */\nexport function tokenizeInterpolation(message: string): (string | number)[] {\n  return tokenizeInterpolationInternal(message, true, true);\n}\n\n/**\n * Replaces string table references in a message, if the message is a string.\n * For example, \"%{bky_my_msg}\" and \"%{BKY_MY_MSG}\" will both be replaced with\n * the value in Msg['MY_MSG'].\n *\n * @param message Message, which may be a string that contains\n *     string table references.\n * @returns String with message references replaced.\n */\nexport function replaceMessageReferences(message: string | any): string {\n  if (typeof message !== 'string') {\n    return message;\n  }\n  const interpolatedResult = tokenizeInterpolationInternal(\n    message,\n    false,\n    false,\n  );\n  // When parseInterpolationTokens and tokenizeNewlines are false,\n  // interpolatedResult should be at most length 1.\n  return interpolatedResult.length ? String(interpolatedResult[0]) : '';\n}\n\n/**\n * Validates that any %{MSG_KEY} references in the message refer to keys of\n * the Msg string table.\n *\n * @param message Text which might contain string table references.\n * @returns True if all message references have matching values.\n *     Otherwise, false.\n */\nexport function checkMessageReferences(message: string): boolean {\n  let validSoFar = true;\n\n  const msgTable = Msg;\n  // TODO (#1169): Implement support for other string tables,\n  // prefixes other than BKY_.\n  const m = message.match(/%{BKY_[A-Z]\\w*}/gi);\n  if (m) {\n    for (let i = 0; i < m.length; i++) {\n      const msgKey = m[i].toUpperCase();\n      if (msgTable[msgKey.slice(6, -1)] === undefined) {\n        console.warn('No message string for ' + m[i] + ' in ' + message);\n        validSoFar = false; // Continue to report other errors.\n      }\n    }\n  }\n\n  return validSoFar;\n}\n\n/**\n * Parse a block colour from a number or string, as provided in a block\n * definition.\n *\n * @param colour HSV hue value (0 to 360), #RRGGBB string,\n *     or a message reference string pointing to one of those two values.\n * @returns An object containing the colour as\n *     a #RRGGBB string, and the hue if the input was an HSV hue value.\n * @throws {Error} If the colour cannot be parsed.\n */\nexport function parseBlockColour(colour: number | string): {\n  hue: number | null;\n  hex: string;\n} {\n  const dereferenced =\n    typeof colour === 'string' ? replaceMessageReferences(colour) : colour;\n\n  const hue = Number(dereferenced);\n  if (!isNaN(hue) && 0 <= hue && hue <= 360) {\n    return {\n      hue: hue,\n      hex: colourUtils.hsvToHex(\n        hue,\n        colourUtils.getHsvSaturation(),\n        colourUtils.getHsvValue() * 255,\n      ),\n    };\n  } else {\n    const hex = colourUtils.parse(dereferenced);\n    if (hex) {\n      // Only store hue if colour is set as a hue.\n      return {hue: null, hex: hex};\n    } else {\n      let errorMsg = 'Invalid colour: \"' + dereferenced + '\"';\n      if (colour !== dereferenced) {\n        errorMsg += ' (from \"' + colour + '\")';\n      }\n      throw Error(errorMsg);\n    }\n  }\n}\n","/**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type {Block} from '../block.js';\nimport {IProcedureModel} from './i_procedure_model.js';\n// Former goog.module ID: Blockly.procedures.IProcedureBlock\n\n/** The interface for a block which models a procedure. */\nexport interface IProcedureBlock {\n  getProcedureModel(): IProcedureModel;\n  doProcedureUpdate(): void;\n  isProcedureDef(): boolean;\n}\n\n/** A type guard which checks if the given block is a procedure block. */\nexport function isProcedureBlock(\n  block: Block | IProcedureBlock,\n): block is IProcedureBlock {\n  return (\n    (block as IProcedureBlock).getProcedureModel !== undefined &&\n    (block as IProcedureBlock).doProcedureUpdate !== undefined &&\n    (block as IProcedureBlock).isProcedureDef !== undefined\n  );\n}\n","/**\n * @license\n * Copyright 2022 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * An object that fires events optionally.\n *\n * @internal\n */\nexport interface IObservable {\n  startPublishing(): void;\n  stopPublishing(): void;\n}\n\n/**\n * Type guard for checking if an object fulfills IObservable.\n *\n * @internal\n */\nexport function isObservable(obj: any): obj is IObservable {\n  return obj.startPublishing !== undefined && obj.stopPublishing !== undefined;\n}\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.fieldRegistry\n\nimport type {Field, FieldProto} from './field.js';\nimport * as registry from './registry.js';\n\ninterface RegistryOptions {\n  type: string;\n  [key: string]: unknown;\n}\n\n/**\n * Registers a field type.\n * fieldRegistry.fromJson uses this registry to\n * find the appropriate field type.\n *\n * @param type The field type name as used in the JSON definition.\n * @param fieldClass The field class containing a fromJson function that can\n *     construct an instance of the field.\n * @throws {Error} if the type name is empty, the field is already registered,\n *     or the fieldClass is not an object containing a fromJson function.\n */\nexport function register(type: string, fieldClass: FieldProto) {\n  registry.register(registry.Type.FIELD, type, fieldClass);\n}\n\n/**\n * Unregisters the field registered with the given type.\n *\n * @param type The field type name as used in the JSON definition.\n */\nexport function unregister(type: string) {\n  registry.unregister(registry.Type.FIELD, type);\n}\n\n/**\n * Construct a Field from a JSON arg object.\n * Finds the appropriate registered field by the type name as registered using\n * fieldRegistry.register.\n *\n * @param options A JSON object with a type and options specific to the field\n *     type.\n * @returns The new field instance or null if a field wasn't found with the\n *     given type name\n * @internal\n */\nexport function fromJson(options: RegistryOptions): Field | null {\n  return TEST_ONLY.fromJsonInternal(options);\n}\n\n/**\n * Private version of fromJson for stubbing in tests.\n *\n * @param options\n */\nfunction fromJsonInternal(options: RegistryOptions): Field | null {\n  const fieldObject = registry.getObject(registry.Type.FIELD, options.type);\n  if (!fieldObject) {\n    console.warn(\n      'Blockly could not create a field of type ' +\n        options['type'] +\n        '. The field is probably not being registered. This could be because' +\n        ' the file is not loaded, the field does not register itself (Issue' +\n        ' #1584), or the registration is not being reached.',\n    );\n    return null;\n  } else if (typeof (fieldObject as any).fromJson !== 'function') {\n    throw new TypeError('returned Field was not a IRegistrableField');\n  } else {\n    type fromJson = (options: {}) => Field;\n    return (fieldObject as unknown as {fromJson: fromJson}).fromJson(options);\n  }\n}\n\nexport const TEST_ONLY = {\n  fromJsonInternal,\n};\n","/**\n * @license\n * Copyright 2012 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n/**\n * Dropdown input field.  Used for editable titles and variables.\n * In the interests of a consistent UI, the toolbox shares some functions and\n * properties with the context menu.\n *\n * @class\n */\n// Former goog.module ID: Blockly.FieldDropdown\n\nimport type {BlockSvg} from './block_svg.js';\nimport * as dropDownDiv from './dropdowndiv.js';\nimport {\n  Field,\n  FieldConfig,\n  FieldValidator,\n  UnattachedFieldError,\n} from './field.js';\nimport * as fieldRegistry from './field_registry.js';\nimport {Menu} from './menu.js';\nimport {MenuItem} from './menuitem.js';\nimport * as style from './utils/style.js';\nimport * as aria from './utils/aria.js';\nimport {Coordinate} from './utils/coordinate.js';\nimport * as dom from './utils/dom.js';\nimport * as parsing from './utils/parsing.js';\nimport * as utilsString from './utils/string.js';\nimport {Svg} from './utils/svg.js';\n\n/**\n * Class for an editable dropdown field.\n */\nexport class FieldDropdown extends Field {\n  /** Horizontal distance that a checkmark overhangs the dropdown. */\n  static CHECKMARK_OVERHANG = 25;\n\n  /**\n   * Maximum height of the dropdown menu, as a percentage of the viewport\n   * height.\n   */\n  static MAX_MENU_HEIGHT_VH = 0.45;\n\n  static ARROW_CHAR = '▾';\n\n  /** A reference to the currently selected menu item. */\n  private selectedMenuItem: MenuItem | null = null;\n\n  /** The dropdown menu. */\n  protected menu_: Menu | null = null;\n\n  /**\n   * SVG image element if currently selected option is an image, or null.\n   */\n  private imageElement: SVGImageElement | null = null;\n\n  /** Tspan based arrow element. */\n  private arrow: SVGTSpanElement | null = null;\n\n  /** SVG based arrow element. */\n  private svgArrow: SVGElement | null = null;\n\n  /**\n   * Serializable fields are saved by the serializer, non-serializable fields\n   * are not. Editable fields should also be serializable.\n   */\n  override SERIALIZABLE = true;\n\n  /** Mouse cursor style when over the hotspot that initiates the editor. */\n  override CURSOR = 'default';\n\n  protected menuGenerator_?: MenuGenerator;\n\n  /** A cache of the most recently generated options. */\n  private generatedOptions: MenuOption[] | null = null;\n\n  /**\n   * The prefix field label, of common words set after options are trimmed.\n   *\n   * @internal\n   */\n  override prefixField: string | null = null;\n\n  /**\n   * The suffix field label, of common words set after options are trimmed.\n   *\n   * @internal\n   */\n  override suffixField: string | null = null;\n  // TODO(b/109816955): remove '!', see go/strict-prop-init-fix.\n  private selectedOption!: MenuOption;\n  override clickTarget_: SVGElement | null = null;\n\n  /**\n   * @param menuGenerator A non-empty array of options for a dropdown list, or a\n   *     function which generates these options. Also accepts Field.SKIP_SETUP\n   *     if you wish to skip setup (only used by subclasses that want to handle\n   *     configuration and setting the field value after their own constructors\n   *     have run).\n   * @param validator A function that is called to validate changes to the\n   *     field's value. Takes in a language-neutral dropdown option & returns a\n   *     validated language-neutral dropdown option, or null to abort the\n   *     change.\n   * @param config A map of options used to configure the field.\n   *     See the [field creation documentation]{@link\n   * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation}\n   * for a list of properties this parameter supports.\n   * @throws {TypeError} If `menuGenerator` options are incorrectly structured.\n   */\n  constructor(\n    menuGenerator: MenuGenerator,\n    validator?: FieldDropdownValidator,\n    config?: FieldDropdownConfig,\n  );\n  constructor(menuGenerator: typeof Field.SKIP_SETUP);\n  constructor(\n    menuGenerator: MenuGenerator | typeof Field.SKIP_SETUP,\n    validator?: FieldDropdownValidator,\n    config?: FieldDropdownConfig,\n  ) {\n    super(Field.SKIP_SETUP);\n\n    // If we pass SKIP_SETUP, don't do *anything* with the menu generator.\n    if (menuGenerator === Field.SKIP_SETUP) return;\n\n    if (Array.isArray(menuGenerator)) {\n      validateOptions(menuGenerator);\n      const trimmed = trimOptions(menuGenerator);\n      this.menuGenerator_ = trimmed.options;\n      this.prefixField = trimmed.prefix || null;\n      this.suffixField = trimmed.suffix || null;\n    } else {\n      this.menuGenerator_ = menuGenerator;\n    }\n\n    /**\n     * The currently selected option. The field is initialized with the\n     * first option selected.\n     */\n    this.selectedOption = this.getOptions(false)[0];\n\n    if (config) {\n      this.configure_(config);\n    }\n    this.setValue(this.selectedOption[1]);\n    if (validator) {\n      this.setValidator(validator);\n    }\n  }\n\n  /**\n   * Sets the field's value based on the given XML element. Should only be\n   * called by Blockly.Xml.\n   *\n   * @param fieldElement The element containing info about the field's state.\n   * @internal\n   */\n  override fromXml(fieldElement: Element) {\n    if (this.isOptionListDynamic()) {\n      this.getOptions(false);\n    }\n    this.setValue(fieldElement.textContent);\n  }\n\n  /**\n   * Sets the field's value based on the given state.\n   *\n   * @param state The state to apply to the dropdown field.\n   * @internal\n   */\n  override loadState(state: AnyDuringMigration) {\n    if (this.loadLegacyState(FieldDropdown, state)) {\n      return;\n    }\n    if (this.isOptionListDynamic()) {\n      this.getOptions(false);\n    }\n    this.setValue(state);\n  }\n\n  /**\n   * Create the block UI for this dropdown.\n   */\n  override initView() {\n    if (this.shouldAddBorderRect_()) {\n      this.createBorderRect_();\n    } else {\n      this.clickTarget_ = (this.sourceBlock_ as BlockSvg).getSvgRoot();\n    }\n    this.createTextElement_();\n\n    this.imageElement = dom.createSvgElement(Svg.IMAGE, {}, this.fieldGroup_);\n\n    if (this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW) {\n      this.createSVGArrow_();\n    } else {\n      this.createTextArrow_();\n    }\n\n    if (this.borderRect_) {\n      dom.addClass(this.borderRect_, 'blocklyDropdownRect');\n    }\n  }\n\n  /**\n   * Whether or not the dropdown should add a border rect.\n   *\n   * @returns True if the dropdown field should add a border rect.\n   */\n  protected shouldAddBorderRect_(): boolean {\n    return (\n      !this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW ||\n      (this.getConstants()!.FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW &&\n        !this.getSourceBlock()?.isShadow())\n    );\n  }\n\n  /** Create a tspan based arrow. */\n  protected createTextArrow_() {\n    this.arrow = dom.createSvgElement(Svg.TSPAN, {}, this.textElement_);\n    this.arrow!.appendChild(\n      document.createTextNode(\n        this.getSourceBlock()?.RTL\n          ? FieldDropdown.ARROW_CHAR + ' '\n          : ' ' + FieldDropdown.ARROW_CHAR,\n      ),\n    );\n    if (this.getSourceBlock()?.RTL) {\n      this.getTextElement().insertBefore(this.arrow, this.textContent_);\n    } else {\n      this.getTextElement().appendChild(this.arrow);\n    }\n  }\n\n  /** Create an SVG based arrow. */\n  protected createSVGArrow_() {\n    this.svgArrow = dom.createSvgElement(\n      Svg.IMAGE,\n      {\n        'height': this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',\n        'width': this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE + 'px',\n      },\n      this.fieldGroup_,\n    );\n    this.svgArrow!.setAttributeNS(\n      dom.XLINK_NS,\n      'xlink:href',\n      this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_DATAURI,\n    );\n  }\n\n  /**\n   * Create a dropdown menu under the text.\n   *\n   * @param e Optional mouse event that triggered the field to open, or\n   *     undefined if triggered programmatically.\n   */\n  protected override showEditor_(e?: MouseEvent) {\n    const block = this.getSourceBlock();\n    if (!block) {\n      throw new UnattachedFieldError();\n    }\n    this.dropdownCreate();\n    if (e && typeof e.clientX === 'number') {\n      this.menu_!.openingCoords = new Coordinate(e.clientX, e.clientY);\n    } else {\n      this.menu_!.openingCoords = null;\n    }\n\n    // Remove any pre-existing elements in the dropdown.\n    dropDownDiv.clearContent();\n    // Element gets created in render.\n    const menuElement = this.menu_!.render(dropDownDiv.getContentDiv());\n    dom.addClass(menuElement, 'blocklyDropdownMenu');\n\n    if (this.getConstants()!.FIELD_DROPDOWN_COLOURED_DIV) {\n      const primaryColour = block.isShadow()\n        ? block.getParent()!.getColour()\n        : block.getColour();\n      const borderColour = block.isShadow()\n        ? (block.getParent() as BlockSvg).style.colourTertiary\n        : (this.sourceBlock_ as BlockSvg).style.colourTertiary;\n      dropDownDiv.setColour(primaryColour, borderColour);\n    }\n\n    dropDownDiv.showPositionedByField(this, this.dropdownDispose_.bind(this));\n\n    // Focusing needs to be handled after the menu is rendered and positioned.\n    // Otherwise it will cause a page scroll to get the misplaced menu in\n    // view. See issue #1329.\n    this.menu_!.focus();\n\n    if (this.selectedMenuItem) {\n      this.menu_!.setHighlighted(this.selectedMenuItem);\n      style.scrollIntoContainerView(\n        this.selectedMenuItem.getElement()!,\n        dropDownDiv.getContentDiv(),\n        true,\n      );\n    }\n\n    this.applyColour();\n  }\n\n  /** Create the dropdown editor. */\n  private dropdownCreate() {\n    const block = this.getSourceBlock();\n    if (!block) {\n      throw new UnattachedFieldError();\n    }\n    const menu = new Menu();\n    menu.setRole(aria.Role.LISTBOX);\n    this.menu_ = menu;\n\n    const options = this.getOptions(false);\n    this.selectedMenuItem = null;\n    for (let i = 0; i < options.length; i++) {\n      const [label, value] = options[i];\n      const content = (() => {\n        if (typeof label === 'object') {\n          // Convert ImageProperties to an HTMLImageElement.\n          const image = new Image(label['width'], label['height']);\n          image.src = label['src'];\n          image.alt = label['alt'] || '';\n          return image;\n        }\n        return label;\n      })();\n      const menuItem = new MenuItem(content, value);\n      menuItem.setRole(aria.Role.OPTION);\n      menuItem.setRightToLeft(block.RTL);\n      menuItem.setCheckable(true);\n      menu.addChild(menuItem);\n      menuItem.setChecked(value === this.value_);\n      if (value === this.value_) {\n        this.selectedMenuItem = menuItem;\n      }\n      menuItem.onAction(this.handleMenuActionEvent, this);\n    }\n  }\n\n  /**\n   * Disposes of events and DOM-references belonging to the dropdown editor.\n   */\n  protected dropdownDispose_() {\n    if (this.menu_) {\n      this.menu_.dispose();\n    }\n    this.menu_ = null;\n    this.selectedMenuItem = null;\n    this.applyColour();\n  }\n\n  /**\n   * Handle an action in the dropdown menu.\n   *\n   * @param menuItem The MenuItem selected within menu.\n   */\n  private handleMenuActionEvent(menuItem: MenuItem) {\n    dropDownDiv.hideIfOwner(this, true);\n    this.onItemSelected_(this.menu_ as Menu, menuItem);\n  }\n\n  /**\n   * Handle the selection of an item in the dropdown menu.\n   *\n   * @param menu The Menu component clicked.\n   * @param menuItem The MenuItem selected within menu.\n   */\n  protected onItemSelected_(menu: Menu, menuItem: MenuItem) {\n    this.setValue(menuItem.getValue());\n  }\n\n  /**\n   * @returns True if the option list is generated by a function.\n   *     Otherwise false.\n   */\n  isOptionListDynamic(): boolean {\n    return typeof this.menuGenerator_ === 'function';\n  }\n\n  /**\n   * Return a list of the options for this dropdown.\n   *\n   * @param useCache For dynamic options, whether or not to use the cached\n   *     options or to re-generate them.\n   * @returns A non-empty array of option tuples:\n   *     (human-readable text or image, language-neutral name).\n   * @throws {TypeError} If generated options are incorrectly structured.\n   */\n  getOptions(useCache?: boolean): MenuOption[] {\n    if (!this.menuGenerator_) {\n      // A subclass improperly skipped setup without defining the menu\n      // generator.\n      throw TypeError('A menu generator was never defined.');\n    }\n    if (Array.isArray(this.menuGenerator_)) return this.menuGenerator_;\n    if (useCache && this.generatedOptions) return this.generatedOptions;\n\n    this.generatedOptions = this.menuGenerator_();\n    validateOptions(this.generatedOptions);\n    return this.generatedOptions;\n  }\n\n  /**\n   * Ensure that the input value is a valid language-neutral option.\n   *\n   * @param newValue The input value.\n   * @returns A valid language-neutral option, or null if invalid.\n   */\n  protected override doClassValidation_(newValue?: string): string | null {\n    const options = this.getOptions(true);\n    const isValueValid = options.some((option) => option[1] === newValue);\n\n    if (!isValueValid) {\n      if (this.sourceBlock_) {\n        console.warn(\n          \"Cannot set the dropdown's value to an unavailable option.\" +\n            ' Block type: ' +\n            this.sourceBlock_.type +\n            ', Field name: ' +\n            this.name +\n            ', Value: ' +\n            newValue,\n        );\n      }\n      return null;\n    }\n    return newValue as string;\n  }\n\n  /**\n   * Update the value of this dropdown field.\n   *\n   * @param newValue The value to be saved. The default validator guarantees\n   *     that this is one of the valid dropdown options.\n   */\n  protected override doValueUpdate_(newValue: string) {\n    super.doValueUpdate_(newValue);\n    const options = this.getOptions(true);\n    for (let i = 0, option; (option = options[i]); i++) {\n      if (option[1] === this.value_) {\n        this.selectedOption = option;\n      }\n    }\n  }\n\n  /**\n   * Updates the dropdown arrow to match the colour/style of the block.\n   */\n  override applyColour() {\n    const style = (this.sourceBlock_ as BlockSvg).style;\n    if (this.borderRect_) {\n      this.borderRect_.setAttribute('stroke', style.colourTertiary);\n      if (this.menu_) {\n        this.borderRect_.setAttribute('fill', style.colourTertiary);\n      } else {\n        this.borderRect_.setAttribute('fill', 'transparent');\n      }\n    }\n    // Update arrow's colour.\n    if (this.sourceBlock_ && this.arrow) {\n      if (this.sourceBlock_.isShadow()) {\n        this.arrow.style.fill = style.colourSecondary;\n      } else {\n        this.arrow.style.fill = style.colourPrimary;\n      }\n    }\n  }\n\n  /** Draws the border with the correct width. */\n  protected override render_() {\n    // Hide both elements.\n    this.getTextContent().nodeValue = '';\n    this.imageElement!.style.display = 'none';\n\n    // Show correct element.\n    const option = this.selectedOption && this.selectedOption[0];\n    if (option && typeof option === 'object') {\n      this.renderSelectedImage(option);\n    } else {\n      this.renderSelectedText();\n    }\n\n    this.positionBorderRect_();\n  }\n\n  /**\n   * Renders the selected option, which must be an image.\n   *\n   * @param imageJson Selected option that must be an image.\n   */\n  private renderSelectedImage(imageJson: ImageProperties) {\n    const block = this.getSourceBlock();\n    if (!block) {\n      throw new UnattachedFieldError();\n    }\n    this.imageElement!.style.display = '';\n    this.imageElement!.setAttributeNS(\n      dom.XLINK_NS,\n      'xlink:href',\n      imageJson.src,\n    );\n    this.imageElement!.setAttribute('height', String(imageJson.height));\n    this.imageElement!.setAttribute('width', String(imageJson.width));\n\n    const imageHeight = Number(imageJson.height);\n    const imageWidth = Number(imageJson.width);\n\n    // Height and width include the border rect.\n    const hasBorder = !!this.borderRect_;\n    const height = Math.max(\n      hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,\n      imageHeight + IMAGE_Y_PADDING,\n    );\n    const xPadding = hasBorder\n      ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING\n      : 0;\n    let arrowWidth = 0;\n    if (this.svgArrow) {\n      arrowWidth = this.positionSVGArrow(\n        imageWidth + xPadding,\n        height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,\n      );\n    } else {\n      arrowWidth = dom.getFastTextWidth(\n        this.arrow as SVGTSpanElement,\n        this.getConstants()!.FIELD_TEXT_FONTSIZE,\n        this.getConstants()!.FIELD_TEXT_FONTWEIGHT,\n        this.getConstants()!.FIELD_TEXT_FONTFAMILY,\n      );\n    }\n    this.size_.width = imageWidth + arrowWidth + xPadding * 2;\n    this.size_.height = height;\n\n    let arrowX = 0;\n    if (block.RTL) {\n      const imageX = xPadding + arrowWidth;\n      this.imageElement!.setAttribute('x', `${imageX}`);\n    } else {\n      arrowX = imageWidth + arrowWidth;\n      this.getTextElement().setAttribute('text-anchor', 'end');\n      this.imageElement!.setAttribute('x', `${xPadding}`);\n    }\n    this.imageElement!.setAttribute('y', String(height / 2 - imageHeight / 2));\n\n    this.positionTextElement_(arrowX + xPadding, imageWidth + arrowWidth);\n  }\n\n  /** Renders the selected option, which must be text. */\n  private renderSelectedText() {\n    // Retrieves the selected option to display through getText_.\n    this.getTextContent().nodeValue = this.getDisplayText_();\n    const textElement = this.getTextElement();\n    dom.addClass(textElement, 'blocklyDropdownText');\n    textElement.setAttribute('text-anchor', 'start');\n\n    // Height and width include the border rect.\n    const hasBorder = !!this.borderRect_;\n    const height = Math.max(\n      hasBorder ? this.getConstants()!.FIELD_DROPDOWN_BORDER_RECT_HEIGHT : 0,\n      this.getConstants()!.FIELD_TEXT_HEIGHT,\n    );\n    const textWidth = dom.getFastTextWidth(\n      this.getTextElement(),\n      this.getConstants()!.FIELD_TEXT_FONTSIZE,\n      this.getConstants()!.FIELD_TEXT_FONTWEIGHT,\n      this.getConstants()!.FIELD_TEXT_FONTFAMILY,\n    );\n    const xPadding = hasBorder\n      ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING\n      : 0;\n    let arrowWidth = 0;\n    if (this.svgArrow) {\n      arrowWidth = this.positionSVGArrow(\n        textWidth + xPadding,\n        height / 2 - this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE / 2,\n      );\n    }\n    this.size_.width = textWidth + arrowWidth + xPadding * 2;\n    this.size_.height = height;\n\n    this.positionTextElement_(xPadding, textWidth);\n  }\n\n  /**\n   * Position a drop-down arrow at the appropriate location at render-time.\n   *\n   * @param x X position the arrow is being rendered at, in px.\n   * @param y Y position the arrow is being rendered at, in px.\n   * @returns Amount of space the arrow is taking up, in px.\n   */\n  private positionSVGArrow(x: number, y: number): number {\n    if (!this.svgArrow) {\n      return 0;\n    }\n    const block = this.getSourceBlock();\n    if (!block) {\n      throw new UnattachedFieldError();\n    }\n    const hasBorder = !!this.borderRect_;\n    const xPadding = hasBorder\n      ? this.getConstants()!.FIELD_BORDER_RECT_X_PADDING\n      : 0;\n    const textPadding = this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_PADDING;\n    const svgArrowSize = this.getConstants()!.FIELD_DROPDOWN_SVG_ARROW_SIZE;\n    const arrowX = block.RTL ? xPadding : x + textPadding;\n    this.svgArrow.setAttribute(\n      'transform',\n      'translate(' + arrowX + ',' + y + ')',\n    );\n    return svgArrowSize + textPadding;\n  }\n\n  /**\n   * Use the `getText_` developer hook to override the field's text\n   * representation.  Get the selected option text.  If the selected option is\n   * an image we return the image alt text.\n   *\n   * @returns Selected option text.\n   */\n  protected override getText_(): string | null {\n    if (!this.selectedOption) {\n      return null;\n    }\n    const option = this.selectedOption[0];\n    if (typeof option === 'object') {\n      return option['alt'];\n    }\n    return option;\n  }\n\n  /**\n   * Construct a FieldDropdown from a JSON arg object.\n   *\n   * @param options A JSON object with options (options).\n   * @returns The new field instance.\n   * @nocollapse\n   * @internal\n   */\n  static fromJson(options: FieldDropdownFromJsonConfig): FieldDropdown {\n    if (!options.options) {\n      throw new Error(\n        'options are required for the dropdown field. The ' +\n          'options property must be assigned an array of ' +\n          '[humanReadableValue, languageNeutralValue] tuples.',\n      );\n    }\n    // `this` might be a subclass of FieldDropdown if that class doesn't\n    // override the static fromJson method.\n    return new this(options.options, undefined, options);\n  }\n}\n\n/**\n * Definition of a human-readable image dropdown option.\n */\nexport interface ImageProperties {\n  src: string;\n  alt: string;\n  width: number;\n  height: number;\n}\n\n/**\n * An individual option in the dropdown menu. The first element is the human-\n * readable value (text or image), and the second element is the language-\n * neutral value.\n */\nexport type MenuOption = [string | ImageProperties, string];\n\n/**\n * A function that generates an array of menu options for FieldDropdown\n * or its descendants.\n */\nexport type MenuGeneratorFunction = (this: FieldDropdown) => MenuOption[];\n\n/**\n * Either an array of menu options or a function that generates an array of\n * menu options for FieldDropdown or its descendants.\n */\nexport type MenuGenerator = MenuOption[] | MenuGeneratorFunction;\n\n/**\n * Config options for the dropdown field.\n */\nexport type FieldDropdownConfig = FieldConfig;\n\n/**\n * fromJson config for the dropdown field.\n */\nexport interface FieldDropdownFromJsonConfig extends FieldDropdownConfig {\n  options?: MenuOption[];\n}\n\n/**\n * A function that is called to validate changes to the field's value before\n * they are set.\n *\n * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values}\n * @param newValue The value to be validated.\n * @returns One of three instructions for setting the new value: `T`, `null`,\n * or `undefined`.\n *\n * - `T` to set this function's returned value instead of `newValue`.\n *\n * - `null` to invoke `doValueInvalid_` and not set a value.\n *\n * - `undefined` to set `newValue` as is.\n */\nexport type FieldDropdownValidator = FieldValidator;\n\n/**\n * The y offset from the top of the field to the top of the image, if an image\n * is selected.\n */\nconst IMAGE_Y_OFFSET = 5;\n\n/** The total vertical padding above and below an image. */\nconst IMAGE_Y_PADDING: number = IMAGE_Y_OFFSET * 2;\n\n/**\n * Factor out common words in statically defined options.\n * Create prefix and/or suffix labels.\n */\nfunction trimOptions(options: MenuOption[]): {\n  options: MenuOption[];\n  prefix?: string;\n  suffix?: string;\n} {\n  let hasImages = false;\n  const trimmedOptions = options.map(([label, value]): MenuOption => {\n    if (typeof label === 'string') {\n      return [parsing.replaceMessageReferences(label), value];\n    }\n\n    hasImages = true;\n    // Copy the image properties so they're not influenced by the original.\n    // NOTE: No need to deep copy since image properties are only 1 level deep.\n    const imageLabel =\n      label.alt !== null\n        ? {...label, alt: parsing.replaceMessageReferences(label.alt)}\n        : {...label};\n    return [imageLabel, value];\n  });\n\n  if (hasImages || options.length < 2) return {options: trimmedOptions};\n\n  const stringOptions = trimmedOptions as [string, string][];\n  const stringLabels = stringOptions.map(([label]) => label);\n\n  const shortest = utilsString.shortestStringLength(stringLabels);\n  const prefixLength = utilsString.commonWordPrefix(stringLabels, shortest);\n  const suffixLength = utilsString.commonWordSuffix(stringLabels, shortest);\n\n  if (\n    (!prefixLength && !suffixLength) ||\n    shortest <= prefixLength + suffixLength\n  ) {\n    // One or more strings will entirely vanish if we proceed.  Abort.\n    return {options: stringOptions};\n  }\n\n  const prefix = prefixLength\n    ? stringLabels[0].substring(0, prefixLength - 1)\n    : undefined;\n  const suffix = suffixLength\n    ? stringLabels[0].substr(1 - suffixLength)\n    : undefined;\n  return {\n    options: applyTrim(stringOptions, prefixLength, suffixLength),\n    prefix,\n    suffix,\n  };\n}\n\n/**\n * Use the calculated prefix and suffix lengths to trim all of the options in\n * the given array.\n *\n * @param options Array of option tuples:\n *     (human-readable text or image, language-neutral name).\n * @param prefixLength The length of the common prefix.\n * @param suffixLength The length of the common suffix\n * @returns A new array with all of the option text trimmed.\n */\nfunction applyTrim(\n  options: [string, string][],\n  prefixLength: number,\n  suffixLength: number,\n): MenuOption[] {\n  return options.map(([text, value]) => [\n    text.substring(prefixLength, text.length - suffixLength),\n    value,\n  ]);\n}\n\n/**\n * Validates the data structure to be processed as an options list.\n *\n * @param options The proposed dropdown options.\n * @throws {TypeError} If proposed options are incorrectly structured.\n */\nfunction validateOptions(options: MenuOption[]) {\n  if (!Array.isArray(options)) {\n    throw TypeError('FieldDropdown options must be an array.');\n  }\n  if (!options.length) {\n    throw TypeError('FieldDropdown options must not be an empty array.');\n  }\n  let foundError = false;\n  for (let i = 0; i < options.length; i++) {\n    const tuple = options[i];\n    if (!Array.isArray(tuple)) {\n      foundError = true;\n      console.error(\n        'Invalid option[' +\n          i +\n          ']: Each FieldDropdown option must be an ' +\n          'array. Found: ',\n        tuple,\n      );\n    } else if (typeof tuple[1] !== 'string') {\n      foundError = true;\n      console.error(\n        'Invalid option[' +\n          i +\n          ']: Each FieldDropdown option id must be ' +\n          'a string. Found ' +\n          tuple[1] +\n          ' in: ',\n        tuple,\n      );\n    } else if (\n      tuple[0] &&\n      typeof tuple[0] !== 'string' &&\n      typeof tuple[0].src !== 'string'\n    ) {\n      foundError = true;\n      console.error(\n        'Invalid option[' +\n          i +\n          ']: Each FieldDropdown option must have a ' +\n          'string label or image description. Found' +\n          tuple[0] +\n          ' in: ',\n        tuple,\n      );\n    }\n  }\n  if (foundError) {\n    throw TypeError('Found invalid FieldDropdown options.');\n  }\n}\n\nfieldRegistry.register('field_dropdown', FieldDropdown);\n","/**\n * @license\n * Copyright 2017 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Extensions\n\nimport type {Block} from './block.js';\nimport type {BlockSvg} from './block_svg.js';\nimport {FieldDropdown} from './field_dropdown.js';\nimport {MutatorIcon} from './icons/mutator_icon.js';\nimport * as parsing from './utils/parsing.js';\n\n/** The set of all registered extensions, keyed by extension name/id. */\nconst allExtensions = Object.create(null);\nexport const TEST_ONLY = {allExtensions};\n\n/**\n * Registers a new extension function. Extensions are functions that help\n * initialize blocks, usually adding dynamic behavior such as onchange\n * handlers and mutators. These are applied using Block.applyExtension(), or\n * the JSON \"extensions\" array attribute.\n *\n * @param name The name of this extension.\n * @param initFn The function to initialize an extended block.\n * @throws {Error} if the extension name is empty, the extension is already\n *     registered, or extensionFn is not a function.\n */\nexport function register(name: string, initFn: Function) {\n  if (typeof name !== 'string' || name.trim() === '') {\n    throw Error('Error: Invalid extension name \"' + name + '\"');\n  }\n  if (allExtensions[name]) {\n    throw Error('Error: Extension \"' + name + '\" is already registered.');\n  }\n  if (typeof initFn !== 'function') {\n    throw Error('Error: Extension \"' + name + '\" must be a function');\n  }\n  allExtensions[name] = initFn;\n}\n\n/**\n * Registers a new extension function that adds all key/value of mixinObj.\n *\n * @param name The name of this extension.\n * @param mixinObj The values to mix in.\n * @throws {Error} if the extension name is empty or the extension is already\n *     registered.\n */\nexport function registerMixin(name: string, mixinObj: AnyDuringMigration) {\n  if (!mixinObj || typeof mixinObj !== 'object') {\n    throw Error('Error: Mixin \"' + name + '\" must be a object');\n  }\n  register(name, function (this: Block) {\n    this.mixin(mixinObj);\n  });\n}\n\n/**\n * Registers a new extension function that adds a mutator to the block.\n * At register time this performs some basic sanity checks on the mutator.\n * The wrapper may also add a mutator dialog to the block, if both compose and\n * decompose are defined on the mixin.\n *\n * @param name The name of this mutator extension.\n * @param mixinObj The values to mix in.\n * @param opt_helperFn An optional function to apply after mixing in the object.\n * @param opt_blockList A list of blocks to appear in the flyout of the mutator\n *     dialog.\n * @throws {Error} if the mutation is invalid or can't be applied to the block.\n */\nexport function registerMutator(\n  name: string,\n  mixinObj: AnyDuringMigration,\n  opt_helperFn?: () => AnyDuringMigration,\n  opt_blockList?: string[],\n) {\n  const errorPrefix = 'Error when registering mutator \"' + name + '\": ';\n\n  checkHasMutatorProperties(errorPrefix, mixinObj);\n  const hasMutatorDialog = checkMutatorDialog(mixinObj, errorPrefix);\n\n  if (opt_helperFn && typeof opt_helperFn !== 'function') {\n    throw Error(errorPrefix + 'Extension \"' + name + '\" is not a function');\n  }\n\n  // Sanity checks passed.\n  register(name, function (this: Block) {\n    if (hasMutatorDialog) {\n      this.setMutator(new MutatorIcon(opt_blockList || [], this as BlockSvg));\n    }\n    // Mixin the object.\n    this.mixin(mixinObj);\n\n    if (opt_helperFn) {\n      opt_helperFn.apply(this);\n    }\n  });\n}\n\n/**\n * Unregisters the extension registered with the given name.\n *\n * @param name The name of the extension to unregister.\n */\nexport function unregister(name: string) {\n  if (isRegistered(name)) {\n    delete allExtensions[name];\n  } else {\n    console.warn(\n      'No extension mapping for name \"' + name + '\" found to unregister',\n    );\n  }\n}\n\n/**\n * Returns whether an extension is registered with the given name.\n *\n * @param name The name of the extension to check for.\n * @returns True if the extension is registered.  False if it is not registered.\n */\nexport function isRegistered(name: string): boolean {\n  return !!allExtensions[name];\n}\n\n/**\n * Applies an extension method to a block. This should only be called during\n * block construction.\n *\n * @param name The name of the extension.\n * @param block The block to apply the named extension to.\n * @param isMutator True if this extension defines a mutator.\n * @throws {Error} if the extension is not found.\n */\nexport function apply(name: string, block: Block, isMutator: boolean) {\n  const extensionFn = allExtensions[name];\n  if (typeof extensionFn !== 'function') {\n    throw Error('Error: Extension \"' + name + '\" not found.');\n  }\n  let mutatorProperties;\n  if (isMutator) {\n    // Fail early if the block already has mutation properties.\n    checkNoMutatorProperties(name, block);\n  } else {\n    // Record the old properties so we can make sure they don't change after\n    // applying the extension.\n    mutatorProperties = getMutatorProperties(block);\n  }\n  extensionFn.apply(block);\n\n  if (isMutator) {\n    const errorPrefix = 'Error after applying mutator \"' + name + '\": ';\n    checkHasMutatorProperties(errorPrefix, block);\n  } else {\n    if (\n      !mutatorPropertiesMatch(mutatorProperties as AnyDuringMigration[], block)\n    ) {\n      throw Error(\n        'Error when applying extension \"' +\n          name +\n          '\": ' +\n          'mutation properties changed when applying a non-mutator extension.',\n      );\n    }\n  }\n}\n\n/**\n * Check that the given block does not have any of the four mutator properties\n * defined on it.  This function should be called before applying a mutator\n * extension to a block, to make sure we are not overwriting properties.\n *\n * @param mutationName The name of the mutation to reference in error messages.\n * @param block The block to check.\n * @throws {Error} if any of the properties already exist on the block.\n */\nfunction checkNoMutatorProperties(mutationName: string, block: Block) {\n  const properties = getMutatorProperties(block);\n  if (properties.length) {\n    throw Error(\n      'Error: tried to apply mutation \"' +\n        mutationName +\n        '\" to a block that already has mutator functions.' +\n        '  Block id: ' +\n        block.id,\n    );\n  }\n}\n\n/**\n * Checks if the given object has both the 'mutationToDom' and 'domToMutation'\n * functions.\n *\n * @param object The object to check.\n * @param errorPrefix The string to prepend to any error message.\n * @returns True if the object has both functions.  False if it has neither\n *     function.\n * @throws {Error} if the object has only one of the functions, or either is not\n *     actually a function.\n */\nfunction checkXmlHooks(\n  object: AnyDuringMigration,\n  errorPrefix: string,\n): boolean {\n  return checkHasFunctionPair(\n    object.mutationToDom,\n    object.domToMutation,\n    errorPrefix + ' mutationToDom/domToMutation',\n  );\n}\n/**\n * Checks if the given object has both the 'saveExtraState' and 'loadExtraState'\n * functions.\n *\n * @param object The object to check.\n * @param errorPrefix The string to prepend to any error message.\n * @returns True if the object has both functions.  False if it has neither\n *     function.\n * @throws {Error} if the object has only one of the functions, or either is not\n *     actually a function.\n */\nfunction checkJsonHooks(\n  object: AnyDuringMigration,\n  errorPrefix: string,\n): boolean {\n  return checkHasFunctionPair(\n    object.saveExtraState,\n    object.loadExtraState,\n    errorPrefix + ' saveExtraState/loadExtraState',\n  );\n}\n\n/**\n * Checks if the given object has both the 'compose' and 'decompose' functions.\n *\n * @param object The object to check.\n * @param errorPrefix The string to prepend to any error message.\n * @returns True if the object has both functions.  False if it has neither\n *     function.\n * @throws {Error} if the object has only one of the functions, or either is not\n *     actually a function.\n */\nfunction checkMutatorDialog(\n  object: AnyDuringMigration,\n  errorPrefix: string,\n): boolean {\n  return checkHasFunctionPair(\n    object.compose,\n    object.decompose,\n    errorPrefix + ' compose/decompose',\n  );\n}\n\n/**\n * Checks that both or neither of the given functions exist and that they are\n * indeed functions.\n *\n * @param func1 The first function in the pair.\n * @param func2 The second function in the pair.\n * @param errorPrefix The string to prepend to any error message.\n * @returns True if the object has both functions.  False if it has neither\n *     function.\n * @throws {Error} If the object has only one of the functions, or either is not\n *     actually a function.\n */\nfunction checkHasFunctionPair(\n  func1: AnyDuringMigration,\n  func2: AnyDuringMigration,\n  errorPrefix: string,\n): boolean {\n  if (func1 && func2) {\n    if (typeof func1 !== 'function' || typeof func2 !== 'function') {\n      throw Error(errorPrefix + ' must be a function');\n    }\n    return true;\n  } else if (!func1 && !func2) {\n    return false;\n  }\n  throw Error(errorPrefix + 'Must have both or neither functions');\n}\n\n/**\n * Checks that the given object required mutator properties.\n *\n * @param errorPrefix The string to prepend to any error message.\n * @param object The object to inspect.\n */\nfunction checkHasMutatorProperties(\n  errorPrefix: string,\n  object: AnyDuringMigration,\n) {\n  const hasXmlHooks = checkXmlHooks(object, errorPrefix);\n  const hasJsonHooks = checkJsonHooks(object, errorPrefix);\n  if (!hasXmlHooks && !hasJsonHooks) {\n    throw Error(\n      errorPrefix +\n        'Mutations must contain either XML hooks, or JSON hooks, or both',\n    );\n  }\n  // A block with a mutator isn't required to have a mutation dialog, but\n  // it should still have both or neither of compose and decompose.\n  checkMutatorDialog(object, errorPrefix);\n}\n\n/**\n * Get a list of values of mutator properties on the given block.\n *\n * @param block The block to inspect.\n * @returns A list with all of the defined properties, which should be\n *     functions, but may be anything other than undefined.\n */\nfunction getMutatorProperties(block: Block): AnyDuringMigration[] {\n  const result = [];\n  // List each function explicitly by reference to allow for renaming\n  // during compilation.\n  if (block.domToMutation !== undefined) {\n    result.push(block.domToMutation);\n  }\n  if (block.mutationToDom !== undefined) {\n    result.push(block.mutationToDom);\n  }\n  if (block.saveExtraState !== undefined) {\n    result.push(block.saveExtraState);\n  }\n  if (block.loadExtraState !== undefined) {\n    result.push(block.loadExtraState);\n  }\n  if (block.compose !== undefined) {\n    result.push(block.compose);\n  }\n  if (block.decompose !== undefined) {\n    result.push(block.decompose);\n  }\n  return result;\n}\n\n/**\n * Check that the current mutator properties match a list of old mutator\n * properties.  This should be called after applying a non-mutator extension,\n * to verify that the extension didn't change properties it shouldn't.\n *\n * @param oldProperties The old values to compare to.\n * @param block The block to inspect for new values.\n * @returns True if the property lists match.\n */\nfunction mutatorPropertiesMatch(\n  oldProperties: AnyDuringMigration[],\n  block: Block,\n): boolean {\n  const newProperties = getMutatorProperties(block);\n  if (newProperties.length !== oldProperties.length) {\n    return false;\n  }\n  for (let i = 0; i < newProperties.length; i++) {\n    if (oldProperties[i] !== newProperties[i]) {\n      return false;\n    }\n  }\n  return true;\n}\n\n/**\n * Calls a function after the page has loaded, possibly immediately.\n *\n * @param fn Function to run.\n * @throws Error Will throw if no global document can be found (e.g., Node.js).\n * @internal\n */\nexport function runAfterPageLoad(fn: () => void) {\n  if (typeof document !== 'object') {\n    throw Error('runAfterPageLoad() requires browser document.');\n  }\n  if (document.readyState === 'complete') {\n    fn(); // Page has already loaded. Call immediately.\n  } else {\n    // Poll readyState.\n    const readyStateCheckInterval = setInterval(function () {\n      if (document.readyState === 'complete') {\n        clearInterval(readyStateCheckInterval);\n        fn();\n      }\n    }, 10);\n  }\n}\n\n/**\n * Builds an extension function that will map a dropdown value to a tooltip\n * string.\n *\n * @param dropdownName The name of the field whose value is the key to the\n *     lookup table.\n * @param lookupTable The table of field values to tooltip text.\n * @returns The extension function.\n */\nexport function buildTooltipForDropdown(\n  dropdownName: string,\n  lookupTable: {[key: string]: string},\n): (this: Block) => void {\n  // List of block types already validated, to minimize duplicate warnings.\n  const blockTypesChecked: string[] = [];\n\n  return function (this: Block) {\n    if (blockTypesChecked.indexOf(this.type) === -1) {\n      checkDropdownOptionsInTable(this, dropdownName, lookupTable);\n      blockTypesChecked.push(this.type);\n    }\n\n    this.setTooltip(\n      function (this: Block) {\n        const value = String(this.getFieldValue(dropdownName));\n        return parsing.replaceMessageReferences(lookupTable[value]);\n      }.bind(this),\n    );\n  };\n}\n\n/**\n * Checks all options keys are present in the provided string lookup table.\n * Emits console warnings when they are not.\n *\n * @param block The block containing the dropdown\n * @param dropdownName The name of the dropdown\n * @param lookupTable The string lookup table\n */\nfunction checkDropdownOptionsInTable(\n  block: Block,\n  dropdownName: string,\n  lookupTable: {[key: string]: string},\n) {\n  const dropdown = block.getField(dropdownName);\n  if (!(dropdown instanceof FieldDropdown) || dropdown.isOptionListDynamic()) {\n    return;\n  }\n\n  const options = dropdown.getOptions();\n  for (const [, key] of options) {\n    if (lookupTable[key] === undefined) {\n      console.warn(\n        `No tooltip mapping for value ${key} of field ` +\n          `${dropdownName} of block type ${block.type}.`,\n      );\n    }\n  }\n}\n\n/**\n * Builds an extension function that will install a dynamic tooltip. The\n * tooltip message should include the string '%1' and that string will be\n * replaced with the text of the named field.\n *\n * @param msgTemplate The template form to of the message text, with %1\n *     placeholder.\n * @param fieldName The field with the replacement text.\n * @returns The extension function.\n */\nexport function buildTooltipWithFieldText(\n  msgTemplate: string,\n  fieldName: string,\n): (this: Block) => void {\n  return function (this: Block) {\n    this.setTooltip(\n      function (this: Block) {\n        const field = this.getField(fieldName);\n        return parsing\n          .replaceMessageReferences(msgTemplate)\n          .replace('%1', field ? field.getText() : '');\n      }.bind(this),\n    );\n  };\n}\n\n/**\n * Configures the tooltip to mimic the parent block when connected. Otherwise,\n * uses the tooltip text at the time this extension is initialized. This takes\n * advantage of the fact that all other values from JSON are initialized before\n * extensions.\n */\nfunction extensionParentTooltip(this: Block) {\n  const tooltipWhenNotConnected = this.tooltip;\n  this.setTooltip(\n    function (this: Block) {\n      const parent = this.getParent();\n      return (\n        (parent && parent.getInputsInline() && parent.tooltip) ||\n        tooltipWhenNotConnected\n      );\n    }.bind(this),\n  );\n}\nregister('parent_tooltip_when_inline', extensionParentTooltip);\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.utils.svgPaths\n\n/**\n * Create a string representing the given x, y pair.  It does not matter whether\n * the coordinate is relative or absolute.  The result has leading\n * and trailing spaces, and separates the x and y coordinates with a comma but\n * no space.\n *\n * @param x The x coordinate.\n * @param y The y coordinate.\n * @returns A string of the format ' x,y '\n */\nexport function point(x: number, y: number): string {\n  return ' ' + x + ',' + y + ' ';\n}\n\n/**\n * Draw a cubic or quadratic curve.  See\n * developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#Cubic_B%C3%A9zier_Curve\n * These coordinates are unitless and hence in the user coordinate system.\n *\n * @param command The command to use.\n *     Should be one of: c, C, s, S, q, Q.\n * @param points  An array containing all of the points to pass to the curve\n *     command, in order.  The points are represented as strings of the format '\n *     x, y '.\n * @returns A string defining one or more Bezier curves.  See the MDN\n *     documentation for exact format.\n */\nexport function curve(command: string, points: string[]): string {\n  return ' ' + command + points.join('');\n}\n\n/**\n * Move the cursor to the given position without drawing a line.\n * The coordinates are absolute.\n * These coordinates are unitless and hence in the user coordinate system.\n * See developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Line_commands\n *\n * @param x The absolute x coordinate.\n * @param y The absolute y coordinate.\n * @returns A string of the format ' M x,y '\n */\nexport function moveTo(x: number, y: number): string {\n  return ' M ' + x + ',' + y + ' ';\n}\n\n/**\n * Move the cursor to the given position without drawing a line.\n * Coordinates are relative.\n * These coordinates are unitless and hence in the user coordinate system.\n * See developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Line_commands\n *\n * @param dx The relative x coordinate.\n * @param dy The relative y coordinate.\n * @returns A string of the format ' m dx,dy '\n */\nexport function moveBy(dx: number, dy: number): string {\n  return ' m ' + dx + ',' + dy + ' ';\n}\n\n/**\n * Draw a line from the current point to the end point, which is the current\n * point shifted by dx along the x-axis and dy along the y-axis.\n * These coordinates are unitless and hence in the user coordinate system.\n * See developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Line_commands\n *\n * @param dx The relative x coordinate.\n * @param dy The relative y coordinate.\n * @returns A string of the format ' l dx,dy '\n */\nexport function lineTo(dx: number, dy: number): string {\n  return ' l ' + dx + ',' + dy + ' ';\n}\n\n/**\n * Draw multiple lines connecting all of the given points in order.  This is\n * equivalent to a series of 'l' commands.\n * These coordinates are unitless and hence in the user coordinate system.\n * See developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Line_commands\n *\n * @param points An array containing all of the points to draw lines to, in\n *     order.  The points are represented as strings of the format ' dx,dy '.\n * @returns A string of the format ' l (dx,dy)+ '\n */\nexport function line(points: string[]): string {\n  return ' l' + points.join('');\n}\n\n/**\n * Draw a horizontal or vertical line.\n * The first argument specifies the direction and whether the given position is\n * relative or absolute.\n * These coordinates are unitless and hence in the user coordinate system.\n * See developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#LineTo_path_commands\n *\n * @param command The command to prepend to the coordinate.  This should be one\n *     of: V, v, H, h.\n * @param val The coordinate to pass to the command.  It may be absolute or\n *     relative.\n * @returns A string of the format ' command val '\n */\nexport function lineOnAxis(command: string, val: number): string {\n  return ' ' + command + ' ' + val + ' ';\n}\n\n/**\n * Draw an elliptical arc curve.\n * These coordinates are unitless and hence in the user coordinate system.\n * See developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#Elliptical_Arc_Curve\n *\n * @param command The command string.  Either 'a' or 'A'.\n * @param flags The flag string.  See the MDN documentation for a description\n *     and examples.\n * @param radius The radius of the arc to draw.\n * @param point The point to move the cursor to after drawing the arc, specified\n *     either in absolute or relative coordinates depending on the command.\n * @returns A string of the format 'command radius radius flags point'\n */\nexport function arc(\n  command: string,\n  flags: string,\n  radius: number,\n  point: string,\n): string {\n  return command + ' ' + radius + ' ' + radius + ' ' + flags + point;\n}\n","/**\n * @license\n * Copyright 2023 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\nimport type {Block} from '../block.js';\nimport type {IIcon} from '../interfaces/i_icon.js';\nimport * as registry from '../registry.js';\nimport {IconType} from './icon_types.js';\n\n/**\n * Registers the given icon so that it can be deserialized.\n *\n * @param type The type of the icon to register. This should be the same string\n *     that is returned from its `getType` method.\n * @param iconConstructor The icon class/constructor to register.\n */\nexport function register(\n  type: IconType,\n  iconConstructor: new (block: Block) => IIcon,\n) {\n  registry.register(registry.Type.ICON, type.toString(), iconConstructor);\n}\n\n/**\n * Unregisters the icon associated with the given type.\n *\n * @param type The type of the icon to unregister.\n */\nexport function unregister(type: string) {\n  registry.unregister(registry.Type.ICON, type);\n}\n","/**\n * @license\n * Copyright 2012 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.Procedures\n\n// Unused import preserved for side-effects. Remove if unneeded.\nimport './events/events_block_change.js';\n\nimport type {Block} from './block.js';\nimport type {BlockSvg} from './block_svg.js';\nimport {Blocks} from './blocks.js';\nimport * as common from './common.js';\nimport type {Abstract} from './events/events_abstract.js';\nimport type {BubbleOpen} from './events/events_bubble_open.js';\nimport * as eventUtils from './events/utils.js';\nimport {Field, UnattachedFieldError} from './field.js';\nimport {Msg} from './msg.js';\nimport {Names} from './names.js';\nimport {IParameterModel} from './interfaces/i_parameter_model.js';\nimport {IProcedureMap} from './interfaces/i_procedure_map.js';\nimport {IProcedureModel} from './interfaces/i_procedure_model.js';\nimport {\n  IProcedureBlock,\n  isProcedureBlock,\n} from './interfaces/i_procedure_block.js';\nimport {\n  isLegacyProcedureCallBlock,\n  isLegacyProcedureDefBlock,\n  ProcedureBlock,\n  ProcedureTuple,\n} from './interfaces/i_legacy_procedure_blocks.js';\nimport {ObservableProcedureMap} from './observable_procedure_map.js';\nimport * as utilsXml from './utils/xml.js';\nimport * as Variables from './variables.js';\nimport type {Workspace} from './workspace.js';\nimport type {WorkspaceSvg} from './workspace_svg.js';\nimport {MutatorIcon} from './icons.js';\n\n/**\n * String for use in the \"custom\" attribute of a category in toolbox XML.\n * This string indicates that the category should be dynamically populated with\n * procedure blocks.\n * See also Blockly.Variables.CATEGORY_NAME and\n * Blockly.VariablesDynamic.CATEGORY_NAME.\n */\nexport const CATEGORY_NAME = 'PROCEDURE';\n\n/**\n * The default argument for a procedures_mutatorarg block.\n */\nexport const DEFAULT_ARG = 'x';\n\n/**\n * Find all user-created procedure definitions in a workspace.\n *\n * @param root Root workspace.\n * @returns Pair of arrays, the first contains procedures without return\n *     variables, the second with. Each procedure is defined by a three-element\n *     list of name, parameter list, and return value boolean.\n */\nexport function allProcedures(\n  root: Workspace,\n): [ProcedureTuple[], ProcedureTuple[]] {\n  const proceduresNoReturn: ProcedureTuple[] = root\n    .getProcedureMap()\n    .getProcedures()\n    .filter((p) => !p.getReturnTypes())\n    .map((p) => [\n      p.getName(),\n      p.getParameters().map((pa) => pa.getName()),\n      false,\n    ]);\n  root.getBlocksByType('procedures_defnoreturn', false).forEach((b) => {\n    if (!isProcedureBlock(b) && isLegacyProcedureDefBlock(b)) {\n      proceduresNoReturn.push(b.getProcedureDef());\n    }\n  });\n\n  const proceduresReturn: ProcedureTuple[] = root\n    .getProcedureMap()\n    .getProcedures()\n    .filter((p) => !!p.getReturnTypes())\n    .map((p) => [\n      p.getName(),\n      p.getParameters().map((pa) => pa.getName()),\n      true,\n    ]);\n  root.getBlocksByType('procedures_defreturn', false).forEach((b) => {\n    if (!isProcedureBlock(b) && isLegacyProcedureDefBlock(b)) {\n      proceduresReturn.push(b.getProcedureDef());\n    }\n  });\n  proceduresNoReturn.sort(procTupleComparator);\n  proceduresReturn.sort(procTupleComparator);\n  return [proceduresNoReturn, proceduresReturn];\n}\n\n/**\n * Comparison function for case-insensitive sorting of the first element of\n * a tuple.\n *\n * @param ta First tuple.\n * @param tb Second tuple.\n * @returns -1, 0, or 1 to signify greater than, equality, or less than.\n */\nfunction procTupleComparator(ta: ProcedureTuple, tb: ProcedureTuple): number {\n  return ta[0].localeCompare(tb[0], undefined, {sensitivity: 'base'});\n}\n\n/**\n * Ensure two identically-named procedures don't exist.\n * Take the proposed procedure name, and return a legal name i.e. one that\n * is not empty and doesn't collide with other procedures.\n *\n * @param name Proposed procedure name.\n * @param block Block to disambiguate.\n * @returns Non-colliding name.\n */\nexport function findLegalName(name: string, block: Block): string {\n  if (block.isInFlyout) {\n    // Flyouts can have multiple procedures called 'do something'.\n    return name;\n  }\n  name = name || Msg['UNNAMED_KEY'] || 'unnamed';\n  while (!isLegalName(name, block.workspace, block)) {\n    // Collision with another procedure.\n    const r = name.match(/^(.*?)(\\d+)$/);\n    if (!r) {\n      name += '2';\n    } else {\n      name = r[1] + (parseInt(r[2]) + 1);\n    }\n  }\n  return name;\n}\n/**\n * Does this procedure have a legal name?  Illegal names include names of\n * procedures already defined.\n *\n * @param name The questionable name.\n * @param workspace The workspace to scan for collisions.\n * @param opt_exclude Optional block to exclude from comparisons (one doesn't\n *     want to collide with oneself).\n * @returns True if the name is legal.\n */\nfunction isLegalName(\n  name: string,\n  workspace: Workspace,\n  opt_exclude?: Block,\n): boolean {\n  return !isNameUsed(name, workspace, opt_exclude);\n}\n\n/**\n * Return if the given name is already a procedure name.\n *\n * @param name The questionable name.\n * @param workspace The workspace to scan for collisions.\n * @param opt_exclude Optional block to exclude from comparisons (one doesn't\n *     want to collide with oneself).\n * @returns True if the name is used, otherwise return false.\n */\nexport function isNameUsed(\n  name: string,\n  workspace: Workspace,\n  opt_exclude?: Block,\n): boolean {\n  for (const block of workspace.getAllBlocks(false)) {\n    if (block === opt_exclude) continue;\n\n    if (\n      isLegacyProcedureDefBlock(block) &&\n      Names.equals(block.getProcedureDef()[0], name)\n    ) {\n      return true;\n    }\n  }\n\n  const excludeModel =\n    opt_exclude && isProcedureBlock(opt_exclude)\n      ? opt_exclude?.getProcedureModel()\n      : undefined;\n  for (const model of workspace.getProcedureMap().getProcedures()) {\n    if (model === excludeModel) continue;\n    if (Names.equals(model.getName(), name)) return true;\n  }\n  return false;\n}\n\n/**\n * Rename a procedure.  Called by the editable field.\n *\n * @param name The proposed new name.\n * @returns The accepted name.\n */\nexport function rename(this: Field, name: string): string {\n  const block = this.getSourceBlock();\n  if (!block) {\n    throw new UnattachedFieldError();\n  }\n\n  // Strip leading and trailing whitespace.  Beyond this, all names are legal.\n  name = name.trim();\n\n  const legalName = findLegalName(name, block);\n  if (isProcedureBlock(block) && !block.isInsertionMarker()) {\n    block.getProcedureModel().setName(legalName);\n  }\n  const oldName = this.getValue();\n  if (oldName !== name && oldName !== legalName) {\n    // Rename any callers.\n    const blocks = block.workspace.getAllBlocks(false);\n    for (let i = 0; i < blocks.length; i++) {\n      // Assume it is a procedure so we can check.\n      const procedureBlock = blocks[i] as unknown as ProcedureBlock;\n      if (procedureBlock.renameProcedure) {\n        procedureBlock.renameProcedure(oldName as string, legalName);\n      }\n    }\n  }\n  return legalName;\n}\n\n/**\n * Construct the blocks required by the flyout for the procedure category.\n *\n * @param workspace The workspace containing procedures.\n * @returns Array of XML block elements.\n */\nexport function flyoutCategory(workspace: WorkspaceSvg): Element[] {\n  const xmlList = [];\n  if (Blocks['procedures_defnoreturn']) {\n    // \n    //     do something\n    // \n    const block = utilsXml.createElement('block');\n    block.setAttribute('type', 'procedures_defnoreturn');\n    block.setAttribute('gap', '16');\n    const nameField = utilsXml.createElement('field');\n    nameField.setAttribute('name', 'NAME');\n    nameField.appendChild(\n      utilsXml.createTextNode(Msg['PROCEDURES_DEFNORETURN_PROCEDURE']),\n    );\n    block.appendChild(nameField);\n    xmlList.push(block);\n  }\n  if (Blocks['procedures_defreturn']) {\n    // \n    //     do something\n    // \n    const block = utilsXml.createElement('block');\n    block.setAttribute('type', 'procedures_defreturn');\n    block.setAttribute('gap', '16');\n    const nameField = utilsXml.createElement('field');\n    nameField.setAttribute('name', 'NAME');\n    nameField.appendChild(\n      utilsXml.createTextNode(Msg['PROCEDURES_DEFRETURN_PROCEDURE']),\n    );\n    block.appendChild(nameField);\n    xmlList.push(block);\n  }\n  if (Blocks['procedures_ifreturn']) {\n    // \n    const block = utilsXml.createElement('block');\n    block.setAttribute('type', 'procedures_ifreturn');\n    block.setAttribute('gap', '16');\n    xmlList.push(block);\n  }\n  if (xmlList.length) {\n    // Add slightly larger gap between system blocks and user calls.\n    xmlList[xmlList.length - 1].setAttribute('gap', '24');\n  }\n\n  /**\n   * Add items to xmlList for each listed procedure.\n   *\n   * @param procedureList A list of procedures, each of which is defined by a\n   *     three-element list of name, parameter list, and return value boolean.\n   * @param templateName The type of the block to generate.\n   */\n  function populateProcedures(\n    procedureList: ProcedureTuple[],\n    templateName: string,\n  ) {\n    for (let i = 0; i < procedureList.length; i++) {\n      const name = procedureList[i][0];\n      const args = procedureList[i][1];\n      // \n      //   \n      //     \n      //   \n      // \n      const block = utilsXml.createElement('block');\n      block.setAttribute('type', templateName);\n      block.setAttribute('gap', '16');\n      const mutation = utilsXml.createElement('mutation');\n      mutation.setAttribute('name', name);\n      block.appendChild(mutation);\n      for (let j = 0; j < args.length; j++) {\n        const arg = utilsXml.createElement('arg');\n        arg.setAttribute('name', args[j]);\n        mutation.appendChild(arg);\n      }\n      xmlList.push(block);\n    }\n  }\n\n  const tuple = allProcedures(workspace);\n  populateProcedures(tuple[0], 'procedures_callnoreturn');\n  populateProcedures(tuple[1], 'procedures_callreturn');\n  return xmlList;\n}\n\n/**\n * Updates the procedure mutator's flyout so that the arg block is not a\n * duplicate of another arg.\n *\n * @param workspace The procedure mutator's workspace. This workspace's flyout\n *     is what is being updated.\n */\nfunction updateMutatorFlyout(workspace: WorkspaceSvg) {\n  const usedNames = [];\n  const blocks = workspace.getBlocksByType('procedures_mutatorarg', false);\n  for (let i = 0, block; (block = blocks[i]); i++) {\n    usedNames.push(block.getFieldValue('NAME'));\n  }\n\n  const xmlElement = utilsXml.createElement('xml');\n  const argBlock = utilsXml.createElement('block');\n  argBlock.setAttribute('type', 'procedures_mutatorarg');\n  const nameField = utilsXml.createElement('field');\n  nameField.setAttribute('name', 'NAME');\n  const argValue = Variables.generateUniqueNameFromOptions(\n    DEFAULT_ARG,\n    usedNames,\n  );\n  const fieldContent = utilsXml.createTextNode(argValue);\n\n  nameField.appendChild(fieldContent);\n  argBlock.appendChild(nameField);\n  xmlElement.appendChild(argBlock);\n\n  workspace.updateToolbox(xmlElement);\n}\n\n/**\n * Listens for when a procedure mutator is opened. Then it triggers a flyout\n * update and adds a mutator change listener to the mutator workspace.\n *\n * @param e The event that triggered this listener.\n * @internal\n */\nexport function mutatorOpenListener(e: Abstract) {\n  if (e.type !== eventUtils.BUBBLE_OPEN) {\n    return;\n  }\n  const bubbleEvent = e as BubbleOpen;\n  if (\n    !(bubbleEvent.bubbleType === 'mutator' && bubbleEvent.isOpen) ||\n    !bubbleEvent.blockId\n  ) {\n    return;\n  }\n  const workspaceId = bubbleEvent.workspaceId;\n  const block = common\n    .getWorkspaceById(workspaceId)!\n    .getBlockById(bubbleEvent.blockId) as BlockSvg;\n  const type = block.type;\n  if (type !== 'procedures_defnoreturn' && type !== 'procedures_defreturn') {\n    return;\n  }\n  const workspace = (\n    block.getIcon(MutatorIcon.TYPE) as MutatorIcon\n  ).getWorkspace()!;\n  updateMutatorFlyout(workspace);\n  workspace.addChangeListener(mutatorChangeListener);\n}\n/**\n * Listens for changes in a procedure mutator and triggers flyout updates when\n * necessary.\n *\n * @param e The event that triggered this listener.\n */\nfunction mutatorChangeListener(e: Abstract) {\n  if (\n    e.type !== eventUtils.BLOCK_CREATE &&\n    e.type !== eventUtils.BLOCK_DELETE &&\n    e.type !== eventUtils.BLOCK_CHANGE &&\n    e.type !== eventUtils.BLOCK_FIELD_INTERMEDIATE_CHANGE\n  ) {\n    return;\n  }\n  const workspaceId = e.workspaceId as string;\n  const workspace = common.getWorkspaceById(workspaceId) as WorkspaceSvg;\n  updateMutatorFlyout(workspace);\n}\n\n/**\n * Find all the callers of a named procedure.\n *\n * @param name Name of procedure.\n * @param workspace The workspace to find callers in.\n * @returns Array of caller blocks.\n */\nexport function getCallers(name: string, workspace: Workspace): Block[] {\n  return workspace.getAllBlocks(false).filter((block) => {\n    return (\n      blockIsModernCallerFor(block, name) ||\n      (isLegacyProcedureCallBlock(block) &&\n        Names.equals(block.getProcedureCall(), name))\n    );\n  });\n}\n\n/**\n * @returns True if the given block is a modern-style caller block of the given\n *     procedure name.\n */\nfunction blockIsModernCallerFor(block: Block, procName: string): boolean {\n  return (\n    isProcedureBlock(block) &&\n    !block.isProcedureDef() &&\n    block.getProcedureModel() &&\n    Names.equals(block.getProcedureModel().getName(), procName)\n  );\n}\n\n/**\n * When a procedure definition changes its parameters, find and edit all its\n * callers.\n *\n * @param defBlock Procedure definition block.\n */\nexport function mutateCallers(defBlock: Block) {\n  const oldRecordUndo = eventUtils.getRecordUndo();\n  const procedureBlock = defBlock as unknown as ProcedureBlock;\n  const name = procedureBlock.getProcedureDef()[0];\n  const xmlElement = defBlock.mutationToDom!(true);\n  const callers = getCallers(name, defBlock.workspace);\n  for (let i = 0, caller; (caller = callers[i]); i++) {\n    const oldMutationDom = caller.mutationToDom!();\n    const oldMutation = oldMutationDom && utilsXml.domToText(oldMutationDom);\n    if (caller.domToMutation) {\n      caller.domToMutation(xmlElement);\n    }\n    const newMutationDom = caller.mutationToDom!();\n    const newMutation = newMutationDom && utilsXml.domToText(newMutationDom);\n    if (oldMutation !== newMutation) {\n      // Fire a mutation on every caller block.  But don't record this as an\n      // undo action since it is deterministically tied to the procedure's\n      // definition mutation.\n      eventUtils.setRecordUndo(false);\n      eventUtils.fire(\n        new (eventUtils.get(eventUtils.BLOCK_CHANGE))(\n          caller,\n          'mutation',\n          null,\n          oldMutation,\n          newMutation,\n        ),\n      );\n      eventUtils.setRecordUndo(oldRecordUndo);\n    }\n  }\n}\n\n/**\n * Find the definition block for the named procedure.\n *\n * @param name Name of procedure.\n * @param workspace The workspace to search.\n * @returns The procedure definition block, or null not found.\n */\nexport function getDefinition(\n  name: string,\n  workspace: Workspace,\n): Block | null {\n  // Do not assume procedure is a top block. Some languages allow nested\n  // procedures. Also do not assume it is one of the built-in blocks. Only\n  // rely on isProcedureDef and getProcedureDef.\n  for (const block of workspace.getAllBlocks(false)) {\n    if (\n      isProcedureBlock(block) &&\n      block.isProcedureDef() &&\n      Names.equals(block.getProcedureModel().getName(), name)\n    ) {\n      return block;\n    }\n    if (\n      isLegacyProcedureDefBlock(block) &&\n      Names.equals(block.getProcedureDef()[0], name)\n    ) {\n      return block;\n    }\n  }\n  return null;\n}\n\nexport {\n  ObservableProcedureMap,\n  IParameterModel,\n  IProcedureBlock,\n  isProcedureBlock,\n  IProcedureMap,\n  IProcedureModel,\n  ProcedureTuple,\n};\n","/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Former goog.module ID: Blockly.blockRendering.ConstantProvider\n\nimport {ConnectionType} from '../../connection_type.js';\nimport type {RenderedConnection} from '../../rendered_connection.js';\nimport type {BlockStyle, Theme} from '../../theme.js';\nimport * as colour from '../../utils/colour.js';\nimport * as dom from '../../utils/dom.js';\nimport * as parsing from '../../utils/parsing.js';\nimport {Svg} from '../../utils/svg.js';\nimport * as svgPaths from '../../utils/svg_paths.js';\n\n/** An object containing sizing and path information about outside corners. */\nexport interface OutsideCorners {\n  topLeft: string;\n  topRight: string;\n  bottomRight: string;\n  bottomLeft: string;\n  rightHeight: number;\n}\n\n/** An object containing sizing and path information about inside corners. */\nexport interface InsideCorners {\n  width: number;\n  height: number;\n  pathTop: string;\n  pathBottom: string;\n}\n\n/** An object containing sizing and path information about a start hat. */\nexport interface StartHat {\n  height: number;\n  width: number;\n  path: string;\n}\n\n/** An object containing sizing and path information about a notch. */\nexport interface Notch {\n  type: number;\n  width: number;\n  height: number;\n  pathLeft: string;\n  pathRight: string;\n}\n\n/** An object containing sizing and path information about a puzzle tab. */\nexport interface PuzzleTab {\n  type: number;\n  width: number;\n  height: number;\n  pathDown: string | ((p1: number) => string);\n  pathUp: string | ((p1: number) => string);\n}\n\n/**\n * An object containing sizing and path information about collapsed block\n * indicators.\n */\nexport interface JaggedTeeth {\n  height: number;\n  width: number;\n  path: string;\n}\n\nexport type BaseShape = {\n  type: number;\n  width: number;\n  height: number;\n};\n\n/** An object containing sizing and type information about a dynamic shape. */\nexport type DynamicShape = {\n  type: number;\n  width: (p1: number) => number;\n  height: (p1: number) => number;\n  isDynamic: true;\n  connectionOffsetY: (p1: number) => number;\n  connectionOffsetX: (p1: number) => number;\n  pathDown: (p1: number) => string;\n  pathUp: (p1: number) => string;\n  pathRightDown: (p1: number) => string;\n  pathRightUp: (p1: number) => string;\n};\n\n/** An object containing sizing and type information about a shape. */\nexport type Shape = BaseShape | DynamicShape;\n\n/**\n * Returns whether the shape is dynamic or not.\n *\n * @param shape The shape to check for dynamic-ness.\n * @returns Whether the shape is a dynamic shape or not.\n */\nexport function isDynamicShape(shape: Shape): shape is DynamicShape {\n  return (shape as DynamicShape).isDynamic;\n}\n\n/**\n * An object that provides constants for rendering blocks.\n */\nexport class ConstantProvider {\n  /** The size of an empty spacer. */\n  NO_PADDING = 0;\n\n  /** The size of small padding. */\n  SMALL_PADDING = 3;\n\n  /** The size of medium padding. */\n  MEDIUM_PADDING = 5;\n\n  /** The size of medium-large padding. */\n  MEDIUM_LARGE_PADDING = 8;\n\n  /** The size of large padding. */\n  LARGE_PADDING = 10;\n  TALL_INPUT_FIELD_OFFSET_Y: number;\n\n  /** The height of the puzzle tab used for input and output connections. */\n  TAB_HEIGHT = 15;\n\n  /**\n   * The offset from the top of the block at which a puzzle tab is positioned.\n   */\n  TAB_OFFSET_FROM_TOP = 5;\n\n  /**\n   * Vertical overlap of the puzzle tab, used to make it look more like a\n   * puzzle piece.\n   */\n  TAB_VERTICAL_OVERLAP = 2.5;\n\n  /** The width of the puzzle tab used for input and output connections. */\n  TAB_WIDTH = 8;\n\n  /** The width of the notch used for previous and next connections. */\n  NOTCH_WIDTH = 15;\n\n  /** The height of the notch used for previous and next connections. */\n  NOTCH_HEIGHT = 4;\n\n  /** The minimum width of the block. */\n  MIN_BLOCK_WIDTH = 12;\n  EMPTY_BLOCK_SPACER_HEIGHT = 16;\n  DUMMY_INPUT_MIN_HEIGHT: number;\n  DUMMY_INPUT_SHADOW_MIN_HEIGHT: number;\n\n  /** Rounded corner radius. */\n  CORNER_RADIUS = 8;\n\n  /**\n   * Offset from the left side of a block or the inside of a statement input\n   * to the left side of the notch.\n   */\n  NOTCH_OFFSET_LEFT = 15;\n  STATEMENT_INPUT_NOTCH_OFFSET: number;\n\n  STATEMENT_BOTTOM_SPACER = 0;\n  STATEMENT_INPUT_PADDING_LEFT = 20;\n\n  /** Vertical padding between consecutive statement inputs. */\n  BETWEEN_STATEMENT_PADDING_Y = 4;\n  TOP_ROW_MIN_HEIGHT: number;\n  TOP_ROW_PRECEDES_STATEMENT_MIN_HEIGHT: number;\n  BOTTOM_ROW_MIN_HEIGHT: number;\n  BOTTOM_ROW_AFTER_STATEMENT_MIN_HEIGHT: number;\n\n  /**\n   * Whether to add a 'hat' on top of all blocks with no previous or output\n   * connections. Can be overridden by 'hat' property on Theme.BlockStyle.\n   */\n  ADD_START_HATS = false;\n\n  /** Height of the top hat. */\n  START_HAT_HEIGHT = 15;\n\n  /** Width of the top hat. */\n  START_HAT_WIDTH = 100;\n\n  SPACER_DEFAULT_HEIGHT = 15;\n\n  MIN_BLOCK_HEIGHT = 24;\n\n  EMPTY_INLINE_INPUT_PADDING = 14.5;\n  EMPTY_INLINE_INPUT_HEIGHT: number;\n\n  EXTERNAL_VALUE_INPUT_PADDING = 2;\n  EMPTY_STATEMENT_INPUT_HEIGHT: number;\n  START_POINT: string;\n\n  /** Height of SVG path for jagged teeth at the end of collapsed blocks. */\n  JAGGED_TEETH_HEIGHT = 12;\n\n  /** Width of SVG path for jagged teeth at the end of collapsed blocks. */\n  JAGGED_TEETH_WIDTH = 6;\n\n  /** Point size of text. */\n  FIELD_TEXT_FONTSIZE = 11;\n\n  /** Text font weight. */\n  FIELD_TEXT_FONTWEIGHT = 'normal';\n\n  /** Text font family. */\n  FIELD_TEXT_FONTFAMILY = 'sans-serif';\n\n  /**\n   * Height of text.  This constant is dynamically set in\n   * `setFontConstants_` to be the height of the text based on the font\n   * used.\n   */\n  FIELD_TEXT_HEIGHT = -1; // Dynamically set.\n\n  /**\n   * Text baseline.  This constant is dynamically set in `setFontConstants_`\n   * to be the baseline of the text based on the font used.\n   */\n  FIELD_TEXT_BASELINE = -1; // Dynamically set.\n\n  /** A field's border rect corner radius. */\n  FIELD_BORDER_RECT_RADIUS = 4;\n\n  /** A field's border rect default height. */\n  FIELD_BORDER_RECT_HEIGHT = 16;\n\n  /** A field's border rect X padding. */\n  FIELD_BORDER_RECT_X_PADDING = 5;\n\n  /** A field's border rect Y padding. */\n  FIELD_BORDER_RECT_Y_PADDING = 3;\n\n  /**\n   * The backing colour of a field's border rect.\n   */\n  FIELD_BORDER_RECT_COLOUR = '#fff';\n  FIELD_TEXT_BASELINE_CENTER: boolean;\n  FIELD_DROPDOWN_BORDER_RECT_HEIGHT: number;\n\n  /**\n   * Whether or not a dropdown field should add a border rect when in a shadow\n   * block.\n   */\n  FIELD_DROPDOWN_NO_BORDER_RECT_SHADOW = false;\n\n  /**\n   * Whether or not a dropdown field's div should be coloured to match the\n   * block colours.\n   */\n  FIELD_DROPDOWN_COLOURED_DIV = false;\n\n  /** Whether or not a dropdown field uses a text or SVG arrow. */\n  FIELD_DROPDOWN_SVG_ARROW = false;\n  FIELD_DROPDOWN_SVG_ARROW_PADDING: number;\n\n  /** A dropdown field's SVG arrow size. */\n  FIELD_DROPDOWN_SVG_ARROW_SIZE = 12;\n  FIELD_DROPDOWN_SVG_ARROW_DATAURI: string;\n\n  /**\n   * Whether or not to show a box shadow around the widget div. This is only a\n   * feature of full block fields.\n   */\n  FIELD_TEXTINPUT_BOX_SHADOW = false;\n\n  /**\n   * Whether or not the colour field should display its colour value on the\n   * entire block.\n   */\n  FIELD_COLOUR_FULL_BLOCK = false;\n\n  /** A colour field's default width. */\n  FIELD_COLOUR_DEFAULT_WIDTH = 26;\n  FIELD_COLOUR_DEFAULT_HEIGHT: number;\n  FIELD_CHECKBOX_X_OFFSET: number;\n  randomIdentifier: string;\n\n  /**\n   * The defs tag that contains all filters and patterns for this Blockly\n   * instance.\n   */\n  private defs: SVGElement | null = null;\n\n  /**\n   * The ID of the emboss filter, or the empty string if no filter is set.\n   */\n  embossFilterId = '';\n\n  /** The  element to use for highlighting, or null if not set. */\n  private embossFilter: SVGElement | null = null;\n\n  /**\n   * The ID of the disabled pattern, or the empty string if no pattern is set.\n   */\n  disabledPatternId = '';\n\n  /**\n   * The  element to use for disabled blocks, or null if not set.\n   */\n  private disabledPattern: SVGElement | null = null;\n\n  /**\n   * The ID of the debug filter, or the empty string if no pattern is set.\n   */\n  debugFilterId = '';\n\n  /**\n   * The  element to use for a debug highlight, or null if not set.\n   */\n  private debugFilter: SVGElement | null = null;\n\n  /** The 
+{% endblock pluginstyles %}
+{% block pluginscripts %}
+
+
+
+
+
 
+
+
+
+
+
+
+
+
 
+{% endblock pluginscripts %}
+{% block headtable %}
 
-  
-    
-      
-    
+{% endblock headtable %}
+
+{% block bodytab1 %}
+
+{% include "logics_blockly_toolbox.html" %}
+
+  
-

Blockly {{ _('Logik-Editor') }} {{ _('für SmartHomeNG') }} -

-
- -
@@ -51,31 +72,25 @@

Blockly {{ _('Logik-Editor') }} {{ _('für SmartHomeNG') }}

-
- {% if cmd != 'edit' and cmd != 'new' %} - - - - - - - {% else %} - - - - - {% endif %} - -
-
- - +
+ {% if cmd != 'edit' and cmd != 'new' %} + + + + + + + {% else %} + + + + {% endif %} +
+ + + + + @@ -83,26 +98,6 @@

Blockly {{ _('Logik-Editor') }} {{ _('für SmartHomeNG') }}

-  
-  
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+  
 
-{% endblock %}
+{% endblock bodytab1 %}
diff --git a/byd_bat/__init__.py b/byd_bat/__init__.py
new file mode 100644
index 000000000..d0a82b5de
--- /dev/null
+++ b/byd_bat/__init__.py
@@ -0,0 +1,3313 @@
+#!/usr/bin/env python3
+# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab
+#########################################################################
+#  Copyright 2024       Matthias Manhart             smarthome@beathis.ch
+#########################################################################
+#  This file is part of SmartHomeNG.
+#  https://www.smarthomeNG.de
+#  https://knx-user-forum.de/forum/supportforen/smarthome-py
+#
+#  Monitoring of BYD energy storage systems (HVM, HVS).
+#
+#  SmartHomeNG is free software: you can redistribute it and/or modify
+#  it under the terms of the GNU General Public License as published by
+#  the Free Software Foundation, either version 3 of the License, or
+#  (at your option) any later version.
+#
+#  SmartHomeNG is distributed in the hope that it will be useful,
+#  but WITHOUT ANY WARRANTY; without even the implied warranty of
+#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+#  GNU General Public License for more details.
+#
+#  You should have received a copy of the GNU General Public License
+#  along with SmartHomeNG. If not, see .
+#
+#########################################################################
+
+# -----------------------------------------------------------------------
+#
+# History
+# =======
+#
+# V0.0.1 230811 - erster Release
+#
+# V0.0.2 230812 - Korrektur Berechnung Batteriestrom
+#
+# V0.0.3 230819 - Code mit pycodestyle kontrolliert/angepasst
+#               - Anpassungen durch 'check_plugin'
+#
+# V0.0.4 230904 - Bilder JPG in PNG konvertiert fuer user_doc.rst
+#
+# V0.0.5 231030 - Diagnose ergaenzt: Bat-Voltag, V-Out, Current
+#               - Liste der Wechselrichter aktualisiert
+#               - Alle Plot-Dateien beim Plugin-Start loeschen
+#               - Anpassungen fuer mathplotlib 3.8.0 mit requirements.txt
+#               - webif aktualisiert (Uebersetzungen, Parameter)
+#
+# V0.0.6 231126 - Diagnose ergaenzt Temperatur max/min
+#               - Auslesen und anzeigen der Balancing-Flags im Plot
+#               - Plot diverse Fehler korrigiert
+#               - Plot-Dateien loeschen ueberarbeitet
+#               - Batterietypen HVS, HVM und LVS im Plot getestet
+#
+# V0.0.7 231209 - item_structs.byd_struct.enable_connection neu
+#                 true -> Kommunikation mit BYD aktiv, false -> keine Kommunikation
+#               - Temperatur Fehler beim Auslesen korrigiert
+#               - Neuer Parameter 'diag_cycle' fuer Abfrage der Diagnosedaten
+#
+# V0.0.8 240112 - Leere kleine PNG fuer nicht vorhandene Tuerme erzeugen
+#               - Zellen mit Balancing werden neu rot umrandet (Heatmap Spannungen)
+#               - Turm Diagnose Balancing neue Items 'active' und 'number'
+#               - Turm Diagnose neues Item 'volt_diff' fuer Spannungsdifferenz (Max-Min)
+#               - Turm neue Items 'soh' und 'state'
+#               - Neuer Parameter 'log_data' und 'log_age'
+#               - Neue Items 'state.charge_total', 'state.discharge_total' und 'state.eta'
+#               - Turm Diagnose neue Items 'charge_total' und 'discharge_total'
+#               - Liste der Wechselrichter aktualisiert
+#               - Funktion 'send_msg' mit CRC-Check ergaenzt
+#               - Berechnung diverser Werte fuer jedes Modul in jedem Turm 'diagnosis/towerX/modules/...'
+#               - Abfrage der Logdaten in BMU und BMS implementiert
+#               - Logdaten BYD werden in speziellen Logdateien pro Tag gespeichert
+#               - Logdaten werden speziell fuer Visualisierung aufbereitet 'visu/...'
+#               - Diverse Anpassungen in der Code-Struktur
+#
+# V0.1.0 240113 - Release
+#
+# V0.1.1 240115 - Neue Items 'info/last_state','info/last_diag','info/last_log'
+#               - Dummy-Plot-Dateien werden in 'imgpath' nicht mehr erstellt
+#               - Fehler korrigiert (self.decode_nop(data,x,MESSAGE_9_L))
+#               - Balkendiagramm Balancing Farbe geaendert auf gruen
+#               - Plot Spannung im Titel Details zu den Daten ergaenzt
+#               - Balkendiagramm Legende mit Farbcodes ergaenzt
+#
+# V0.1.2 240120 - Logdaten Verarbeitung ergaenzt (BMS 9,20)
+#
+# -----------------------------------------------------------------------
+#
+# Als Basis fuer die Implementierung wurde u.a. folgende Quelle verwendet:
+#
+# https://github.com/christianh17/ioBroker.bydhvs
+#
+# Diverse Notizen
+#
+# - Max. Anzahl Module: HVS=2-5, HVM=3-8, HVL=3-8, LVS=1-8
+# - Beginn Frame (Senden/Empfangen): 0x01 0x03 (Beispiel, es gibt auch andere Startsequenzen)
+# - Antwort 3.Byte (direkt nach Header): Anzahl Bytes Nutzdaten (?) (2 Byte CRC werden mitgezaehlt)
+#   packetLength = data[2] + 5; (3 header, 2 crc)
+#   https://crccalc.com/ (Input=Hex, CRC-16, CRC-16/MODBUS)
+# - Datenpaket wird mit CRC16/MODBUS am Ende abgeschlossen (2 Byte,LSB,MSB) (Nutzdaten+Längenbyte)
+# - Der Server im BYD akzeptiert nur 1 Verbindung auf Port 8080/TCP !
+# - Ein Register (Index) hat 2 Bytes und ist MSB,LSB aufgebaut
+#
+# -----------------------------------------------------------------------
+
+from lib.model.smartplugin import *
+from lib.item import Items
+from .webif import WebInterface
+import cherrypy
+
+import socket
+import time
+import matplotlib
+import matplotlib.pyplot as plt
+import matplotlib.patches as patches
+from matplotlib.lines import Line2D
+import numpy as np
+import os
+import json
+from datetime import datetime, timedelta
+from zoneinfo import ZoneInfo
+from decimal import Decimal,ROUND_DOWN
+
+#import random    # only for internal test [TEST]
+
+byd_ip_default = "192.168.16.254"
+
+scheduler_name = 'byd_bat'
+
+byd_sample_basics = 60                             # Abfrage fuer Basisdaten [s]
+byd_sample_diag = 300                              # Abfrage fuer Diagnosedaten [s]
+byd_sample_log = 300                               # Abfrage fuer Logdaten [s]
+
+byd_timeout_1s = 1.0
+byd_timeout_2s = 2.0
+byd_timeout_8s = 8.0
+byd_timeout_10s = 10.0
+
+byd_towers_max = 3
+byd_cells_max = 160
+byd_temps_max = 64
+byd_module_max = 8
+
+byd_no_of_col_7 = 7
+byd_no_of_col_8 = 8
+byd_no_of_col_12 = 12
+
+byd_webif_img = "/webif/static/img/"
+byd_path_empty = "x"
+byd_fname_volt = "bydvt"
+byd_fname_volt2 = "bydvbt"
+byd_fname_temp = "bydtt"
+byd_fname_ext = ".png"
+
+byd_ok = 1
+byd_error = 0
+
+byd_module_vmin = 0
+byd_module_vmax = 1
+byd_module_vava = 2
+byd_module_vdif = 3
+
+BUFFER_SIZE = 4096                                 # Groesse Empfangsbuffer
+
+byd_log_max_rows = 40                              # maximale Anzahl Eintraege (html/json)
+byd_log_directory = "byd_logs"
+byd_log_extension = "log"
+byd_log_special = "byd_special"
+byd_log_newline = "\n"
+byd_log_sep = "\t"
+
+# Log-Daten Indizes in der Liste
+byd_log_year = 0                                   # Jahr
+byd_log_month = 1                                  # Monat
+byd_log_day = 2                                    # Tag
+byd_log_hour = 3                                   # Stunde
+byd_log_minute = 4                                 # Minute
+byd_log_second = 5                                 # Sekunde
+byd_log_codex = 6                                  # Code des Log-Eintrages
+byd_log_data = 7                                   # Daten zum Log-Eintrag
+byd_log_raw = 8                                    # Roh-Daten des Log-Eintrages
+byd_log_str = 9                                    # 'byd_log_data' als String
+byd_log_str_sep = " | "
+byd_log_degree = "*C"
+
+MESSAGE_0    = "010300000066c5e0"
+MESSAGE_0_L  = 209
+MESSAGE_1    = "01030500001984cc"
+MESSAGE_1_L  = 55
+MESSAGE_2    = "010300100003040e"
+MESSAGE_2_L  = 11
+
+MESSAGE_3_1  = "0110055000020400018100f853"        # Start Messung Turm 1
+MESSAGE_3_2  = "01100550000204000281000853"        # Start Messung Turm 2
+MESSAGE_3_3  = "01100550000204000381005993"        # Start Messung Turm 3
+MESSAGE_3_L  = 8
+MESSAGE_4    = "010305510001d517"
+MESSAGE_4_L  = 7
+MESSAGE_5    = "01030558004104e5"
+MESSAGE_5_L  = 135
+MESSAGE_6    = "01030558004104e5"
+MESSAGE_6_L  = 135
+MESSAGE_7    = "01030558004104e5"
+MESSAGE_7_L  = 135
+MESSAGE_8    = "01030558004104e5"
+MESSAGE_8_L  = 135
+                                                   # to read the 5th module, the box must first be reconfigured (not tested)
+MESSAGE_9    = "01100100000306444542554700176f"    # switch to second turn for the last few cells
+MESSAGE_9_L  = 0                                   # UNBEKANNT !!
+MESSAGE_10_1 = "0110055000020400018100f853"        # start measuring remaining cells in tower 1 (like 3)
+MESSAGE_10_2 = "01100550000204000281000853"        # start measuring remaining cells in tower 2 (like 3)
+MESSAGE_10_3 = "01100550000204000381005993"        # start measuring remaining cells in tower 3 (like 3)
+MESSAGE_10_L = 8
+MESSAGE_11   = "010305510001d517"                  # (like 4)
+MESSAGE_11_L = 7
+MESSAGE_12   = "01030558004104e5"                  # (like 5)
+MESSAGE_12_L = 135
+MESSAGE_13   = "01030558004104e5"                  # (like 6)
+MESSAGE_13_L = 135
+MESSAGE_14   = "01030558004104e5"                  # (like 7)
+MESSAGE_14_L = 135
+MESSAGE_15   = "01030558004104e5"                  # (like 8)
+MESSAGE_15_L = 135
+
+EVT_MSG_0_0 = "011005a000020400008100A6D7"         # BMU
+EVT_MSG_0_1 = "011005a000020400018100f717"         # BMS tower 1
+EVT_MSG_0_2 = "011005a0000204000281000717"         # BMS tower 2
+EVT_MSG_0_3 = "011005a00002040003810056D7"         # BMS tower 3
+EVT_MSG_0_L  = 8
+EVT_MSG_1   = "010305A8004104D6"                   # request log-data
+EVT_MSG_1_L  = 135
+
+byd_errors = [
+  "High Temperature Charging (Cells)",             #  0
+  "Low Temperature Charging (Cells)",              #  1
+  "Over Current Discharging",                      #  2
+  "Over Current Charging",                         #  3
+  "Main circuit Failure",                          #  4
+  "Short Current Alarm",                           #  5
+  "Cells Imbalance",                               #  6
+  "Current Sensor Failure",                        #  7
+  "Battery Over Voltage",                          #  8
+  "Battery Under Voltage",                         #  9
+  "Cell Over Voltage",                             # 10
+  "Cell Under Voltage",                            # 11
+  "Voltage Sensor Failure",                        # 12
+  "Temperature Sensor Failure",                    # 13
+  "High Temperature Discharging (Cells)",          # 14
+  "Low Temperature Discharging (Cells)"            # 15
+]
+
+# Liste der Wechselrichter (entnommen aus Be_Connect)
+byd_inverters = [
+  "Fronius HV",                                    #  0
+  "Goodwe HV/Viessmann HV",                        #  1
+  "KOSTAL HV",                                     #  2
+  "SMA SBS3.7/5.0/6.0 HV",                         #  3
+  "Sungrow HV",                                    #  4
+  "KACO_HV",                                       #  5
+  "Ingeteam HV",                                   #  6
+  "SMA SBS2.5 HV",                                 #  7
+  "Solis HV",                                      #  8
+  "SMA STP 5.0-10.0 SE HV",                        #  9
+  "GE HV",                                         # 10
+  "Deye HV",                                       # 11
+  "KACO_NH",                                       # 12
+  "Solplanet",                                     # 13
+  "Western HV",                                    # 14
+  "SOSEN",                                         # 15
+  "Hoymiles HV",                                   # 16
+  "SAJ HV",                                        # 17
+  "Selectronic LV",                                # 18
+  "SMA LV",                                        # 19
+  "Victron LV",                                    # 20
+  "Studer LV",                                     # 21
+  "Schneider LV",                                  # 22
+  "Solis LV",                                      # 23
+  "Deye LV",                                       # 24
+  "Raion LV",                                      # 25
+  "Hoymiles LV",                                   # 26
+  "Goodwe LV/Viessmann LV",                        # 27
+  "SolarEdge LV",                                  # 28
+  "Sungrow LV Phocos LV",                          # 29
+  "Suntech LV"                                     # 30  (nicht im Hauptblock von Be_Connect)
+]
+
+# Status eines Turms (2 Byte, 16 Bit)
+byd_stat_tower = [
+    "Battery Over Voltage",                         # Bit 0
+    "Battery Under Voltage",                        # Bit 1
+    "Cells OverVoltage",                            # Bit 2
+    "Cells UnderVoltage",                           # Bit 3
+    "Cells Imbalance",                              # Bit 4
+    "Charging High Temperature(Cells)",             # Bit 5
+    "Charging Low Temperature(Cells)",              # Bit 6
+    "DisCharging High Temperature(Cells)",          # Bit 7
+    "DisCharging Low Temperature(Cells)",           # Bit 8
+    "Charging OverCurrent(Cells)",                  # Bit 9
+    "DisCharging OverCurrent(Cells)",               # Bit 10
+    "Charging OverCurrent(Hardware)",               # Bit 11
+    "Short Circuit",                                # Bit 12
+    "Inversly Connection",                          # Bit 13
+    "Interlock switch Abnormal",                    # Bit 14
+    "AirSwitch Abnormal"                            # Bit 15
+]
+
+byd_log_code = [
+    [  0,"Power ON"],                               # [  0]
+    [  1,"Power OFF"],                              # [  1]
+    [  2,"Events record"],                          # [  2]  Events appear, Events disappear
+    [  3,"Timing Record"],                          # [  3]
+    [  4,"Start Charging"],                         # [  4]
+    [  5,"Stop Charging"],                          # [  5]
+    [  6,"Start DisCharging"],                      # [  6]
+    [  7,"Stop DisCharging"],                       # [  7]
+    [  8,"SOC calibration rough"],                  # [  8]
+    [  9,"??"],                                     # [  9]
+    [ 10,"SOC calibration Stop"],                   # [ 10]
+    [ 11,"CAN Communication failed"],               # [ 11]
+    [ 12,"Serial Communication failed"],            # [ 12]
+    [ 13,"Receive PreCharge Command"],              # [ 13]
+    [ 14,"PreCharge Successful"],                   # [ 14]
+    [ 15,"PreCharge Failure"],                      # [ 15]
+    [ 16,"Start end SOC calibration"],              # [ 16]
+    [ 17,"Start Balancing"],                        # [ 17]
+    [ 18,"Stop Balancing"],                         # [ 18]
+    [ 19,"Address Registered"],                     # [ 19]
+    [ 20,"System Functional Safety Fault"],         # [ 20]
+    [ 21,"Events additional info"],                 # [ 21]
+    [ 22,"Start Firmware Update"],                  # [ 22]
+    [ 23,"Firmware Update finish"],                 # [ 23]
+    [ 24,"Firmware Update fails"],                  # [ 24]
+    [ 25,"SN Code was Changed"],                    # [ 25]
+    [ 26,"Current Calibration"],                    # [ 26]
+    [ 27,"Battery Voltage Calibration"],            # [ 27]
+    [ 28,"PackVoltage Calibration"],                # [ 28]
+    [ 29,"SOC/SOH Calibration"],                    # [ 29]
+    [ 30,"??"],                                     # [ 30]
+    [ 31,"??"],                                     # [ 31]
+    [ 32,"System status changed"],                  # [ 32]
+    [ 33,"Erase BMS Firmware"],                     # [ 33]
+    [ 34,"BMS update start"],                       # [ 34]
+    [ 35,"BMS update done"],                        # [ 35]
+    [ 36,"Functional Safety Info"],                 # [ 36]
+    [ 37,"No Defined"],                             # [ 37]
+    [ 38,"SOP Info"],                               # [ 38]
+    [ 39,"??"],                                     # [ 39]
+    [ 40,"BMS Firmware list"],                      # [ 40]
+    [ 41,"MCU list of BMS"],                        # [ 41]
+    
+    # BCU Hardware failt
+    # Firmware Update failure 
+    # Firmware Jumpinto other section 
+
+    [101,"Firmware Start to Update"],               # [101]  BMS: Start Firmware Update
+    [102,"Firmware Update Successful"],             # [102]  BMS: Firmware Update finish
+                                                    
+    [105,"Parameters table Update"],                # [105]
+    [106,"SN Code was Changed"],                    # [106]
+                                                    
+    [111,"DateTime Calibration"],                   # [111]
+    [112,"BMS disconnected with BMU"],              # [112]
+    [113,"MU F/W Reset"],                           # [113]
+    [114,"BMU Watchdog Reset"],                     # [114]
+    [115,"PreCharge Failed"],                       # [115]
+    [116,"Address registration failed"],            # [116]
+    [117,"Parameters table Load Failed"],           # [117]
+    [118,"System timing log"]                       # [118]
+    
+    # Parameters table updating done 
+]
+
+# System-Code (Log SOP Info (38), Quelle: Be_Connect 2.0.9, * = aus eigenen Log-Dateien)
+byd_log_status = [
+    "SYS_STANDBY",                                  # 0 *
+    "SYS_INACTIVE",                                 # 1 *
+    "SYS_BLACK_START",                              # 2
+    "SYS_ACTIVE",                                   # 3 *
+    "SYS_FAULT",                                    # 4
+    "SYS_UPDATING",                                 # 5
+    "SYS_SHUTDOWN",                                 # 6 *
+    "SYS_PRECHARGE",                                # 7 *
+    "SYS_BATT_CHECK",                               # 8 *
+    "SYS_ASSIGN_ADDR",                              # 9 *
+    "SYS_LOAD_PARAM",                               # 10 *
+    "SYS_INIT",                                     # 11 *
+    "SYS_UNKNOWN12"                                 # 12
+]
+
+byd_module_type = [
+    "HVL",
+    "HVM",
+    "HVS",
+    "Not defined"
+]
+
+# Warnungen fuer Events record (2) (16 Bit)
+byd_log_bmu_warnings = [
+    "??",                                           # 0
+    "??",                                           # 1
+    "Cells OverVoltage",                            # 2
+    "Cells UnderVoltage",                           # 3
+    "V-sensor failure",                             # 4
+    "??",                                           # 5
+    "??",                                           # 6
+    "Cell discharge Temp-Low",                      # 7
+    "??",                                           # 8
+    "Cell charge Temp-Low",                         # 9
+    "??",                                           # 10
+    "??",                                           # 11
+    "??",                                           # 12
+    "??",                                           # 13
+    "Cells imbalance",                              # 14
+    "??"                                            # 15
+]
+
+# Fehlermeldungen fuer Events record (2) (Enum)
+byd_log_bmu_errors = [
+    "Total Voltage too High",                       # 0
+    "Total Voltage too Low",                        # 1
+    "Cell Voltage too High",                        # 2
+    "Cell Voltage too Low",                         # 3
+    "Voltage Sensor Fault",                         # 4
+    "Temperature Sersor Fault",                     # 5
+    "Cell Discharging Temp. too High",              # 6
+    "Cell Discharging Temp. too Low",               # 7
+    "Cell Charging Temp. too High",                 # 8
+    "Cell Charging Temp. too Low",                  # 9
+    "Discharging Over Current",                     # 10
+    "Charging Over Current",                        # 11
+    "Major loop Fault",                             # 12
+    "Short Circuit warning",                        # 13
+    "Battery Imbalance",                            # 14
+    "Current Sensor Fault",                         # 15
+    "??",                                           # 16
+    "??",                                           # 17
+    "??",                                           # 18
+    "??",                                           # 19
+    "??",                                           # 20
+    "??",                                           # 21
+    "??",                                           # 22
+    ""                                              # 23 (Wert, wenn keine Meldung)
+]
+
+# Warnungen fuer BMS (16 Bit)
+byd_log_bms_warnings = [
+    "Battery Over Voltage",                         # 0
+    "Battery Under Voltage",                        # 1
+    "Cells OverVoltage",                            # 2 *
+    "Cells UnderVoltage",                           # 3 *
+    "Cells Imbalance",                              # 4 *
+    "Charging High Temperature(Cells)",             # 5
+    "Charging Low Temperature(Cells)",              # 6
+    "DisCharging High Temperature(Cells)",          # 7
+    "DisCharging Low Temperature(Cells)",           # 8
+    "Charging OverCurrent(Cells)",                  # 9
+    "DisCharging OverCurrent(Cells)",               # 10
+    "Charging OverCurrent(Hardware)",               # 11
+    "Short Circuit",                                # 12
+    "Inversly Connection",                          # 13
+    "Interlock switch Abnormal",                    # 14
+    "AirSwitch Abnormal"                            # 15
+]
+
+# Fehler fuer BMS (16 Bit)
+byd_log_bms_failures = [
+    "Cells Voltage Sensor Failure",                 # 0 *
+    "Temperature Sensor Failure",                   # 1
+    "BIC Communication Failure",                    # 2
+    "Pack Voltage Sensor Failure",                  # 3
+    "Current Sensor Failure",                       # 4
+    "Charging Mos Failure",                         # 5
+    "DisCharging Mos Failure",                      # 6
+    "PreCharging Mos Failure",                      # 7
+    "Main Relay Failure",                           # 8
+    "PreCharging Failed",                           # 9
+    "Heating Device Failure",                       # 10
+    "Radiator Failure",                             # 11
+    "BIC Balance Failure",                          # 12
+    "Cells Failure",                                # 13
+    "PCB Temperature Sensor Failure",               # 14
+    "Functional Safety Failure"                     # 15
+]
+
+# Switch Status fuer BMS (8 Bit)
+byd_log_bms_switch_status_on = [
+    "Charge Mos_Switch is on",                      # 0
+    "DisCharge Mos_Switch is on",                   # 1
+    "PreCharge Mos_Switch is on",                   # 2
+    "Relay is on",                                  # 3 *
+    "Air Switch is on",                             # 4 *
+    "PreCharge_2 Mos_Switch is on",                 # 5
+    "??",                                           # 6
+    "??"                                            # 7
+]
+
+byd_log_bms_switch_status_off = [
+    "Charge Mos_Switch is off",                     # 0
+    "DisCharge Mos_Switch is off",                  # 1
+    "PreCharge Mos_Switch is off",                  # 2
+    "Relay is off",                                 # 3 *  -> nur diesen Wert in einem Log gesehen
+    "Air Switch is off",                            # 4 *
+    "PreCharge_2 Mos_Switch is off",                # 5
+    "??",                                           # 6
+    "??"                                            # 7
+]
+
+# Power-Off fuer BMS (Enum 1 Byte)
+byd_log_bms_poweroff = [
+    ""                                                             # 0
+    "Press BMS LED button to Switch off",                          # 1
+    "BMU requires to switch off",                                  # 2 *
+    "BMU Power off And communication between BMU and BMS failed",  # 3 *
+    "Power off while communication failed(after 30 minutes)",      # 4
+    "Premium LV BMU requires to Power off",                        # 5
+    "Press BMS LED to Power off",                                  # 6
+    "Power off due to communication failed with BMU",              # 7
+    "BMS off due to battery UnderVoltage",                         # 8
+    "??",                                                          # 9
+]
+
+# -----------------------------------------------------------------------
+# Plugin-Code
+# -----------------------------------------------------------------------
+
+class byd_bat(SmartPlugin):
+
+    """
+    Main class of the Plugin. Does all plugin specific stuff and provides
+    the update functions for the items
+
+    HINT: Please have a look at the SmartPlugin class to see which
+    class properties and methods (class variables and class functions)
+    are already available!
+    """
+
+    PLUGIN_VERSION = '0.1.2'
+    ALLOW_MULTIINSTANCE = False
+    
+    def __init__(self,sh):
+        """
+        Initalizes the plugin.
+
+        If you need the sh object at all, use the method self.get_sh() to get it. There should be almost no need for
+        a reference to the sh object any more.
+
+        Plugins have to use the new way of getting parameter values:
+        use the SmartPlugin method get_parameter_value(parameter_name). Anywhere within the Plugin you can get
+        the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It
+        returns the value in the datatype that is defined in the metadata.
+        """
+
+        # Call init code of parent class (SmartPlugin)
+        super().__init__()
+        
+        # get the parameters for the plugin (as defined in metadata plugin.yaml):
+        
+        if self.get_parameter_value('ip') != '':
+          self.ip = self.get_parameter_value('ip')
+        else:
+          self.log_info("no ip defined => use default '" + byd_ip_default + "'")
+          self.ip = byd_ip_default
+        
+        if self.get_parameter_value('imgpath') != '':
+          self.bpath = self.get_parameter_value('imgpath')
+          if self.bpath is None:
+            self.log_info("path is None")
+            self.bpath = byd_path_empty
+        else:
+          self.log_info("no path defined")
+          self.bpath = byd_path_empty
+        
+        if self.get_parameter_value('diag_cycle') != '':
+          self.diag_cycle = self.get_parameter_value('diag_cycle')
+          if self.diag_cycle is None:
+            self.diag_cycle = byd_sample_diag
+        else:
+          self.log_info("no diag_cycle defined => use default '" + str(byd_sample_diag) + "s'")
+          self.diag_cycle = byd_sample_diag
+        if self.diag_cycle < byd_sample_basics:
+          self.diag_cycle = byd_sample_basics
+
+        if self.get_parameter_value('log_data') != '':
+          self.log_data = self.get_parameter_value('log_data')
+          if self.log_data is None:
+            self.log_data = False
+        else:
+          self.log_info("log_data not defined => log_data=false")
+          self.log_data = False
+        
+        if self.get_parameter_value('log_age') != '':
+          self.log_age = self.get_parameter_value('log_age')
+          if self.log_age is None:
+            self.log_age = 365
+        else:
+          self.log_info("no log_age defined => use default '" + str(365) + "s'")
+          self.log_age = 365
+        if self.log_age < 0:
+          self.log_age = 0
+
+        self.log_debug("BYD ip               = " + self.ip)
+        self.log_debug("BYD path             = " + self.bpath)
+        self.log_debug("BYD diagnostic cycle = " + f"{self.diag_cycle:.0f}" + "s")
+        self.log_debug("BYD log data         = " + str(self.log_data))
+        self.log_debug("BYD log age          = " + str(self.log_age) + " days")
+
+        # cycle time in seconds, only needed, if hardware/interface needs to be
+        # polled for value changes by adding a scheduler entry in the run method of this plugin
+        # (maybe you want to make it a plugin parameter?)
+        self._cycle = byd_sample_basics
+        
+        self.last_diag_secs = 9999                  # erzwingt beim ersten Aufruf das Abfragen der Detaildaten
+        self.last_log_secs = 9999                   # erzwingt beim ersten Aufruf das Abfragen der Log-Daten
+        
+        self.byd_root_found = False
+        
+        self.byd_towers_max = byd_towers_max
+        self.byd_module_vmin = byd_module_vmin
+        self.byd_module_vmax = byd_module_vmax
+        self.byd_module_vava = byd_module_vava
+        self.byd_module_vdif = byd_module_vdif
+
+        # State
+        self.byd_current = 0
+        self.byd_power = 0
+        self.byd_power_charge = 0
+        self.byd_power_discharge = 0
+        self.byd_soc = 0
+        self.byd_soh = 0
+        self.byd_temp_bat = 0
+        self.byd_temp_max = 0
+        self.byd_temp_min = 0
+        self.byd_volt_bat = 0
+        self.byd_volt_diff = 0
+        self.byd_volt_max = 0
+        self.byd_volt_min = 0
+        self.byd_volt_out = 0
+        self.byd_charge_total = 0
+        self.byd_discharge_total = 0
+        self.byd_eta = 0
+        
+        # System
+        self.byd_bms = ""
+        self.byd_bmu = ""
+        self.byd_bmu_a = ""
+        self.byd_bmu_b = ""
+        self.byd_batt_str = ""
+        self.byd_error_nr = 0
+        self.byd_error_str = ""
+        self.byd_application = ""
+        self.byd_inv_str = ""
+        self.byd_modules = 0
+        self.byd_bms_qty = 0
+        self.byd_capacity_total = 0
+        self.byd_param_t = ""
+        self.byd_serial = ""
+        
+        self.last_homedata = self.now_str()
+        self.last_diagdata = self.now_str()
+        
+        self.byd_diag_soc = []
+        self.byd_diag_soh = []
+        self.byd_diag_state = []
+        self.byd_diag_state_str = []
+        self.byd_diag_bat_voltag = []
+        self.byd_diag_v_out = []
+        self.byd_diag_current = []
+        self.byd_diag_volt_diff = []
+        self.byd_diag_volt_max = []
+        self.byd_diag_volt_max_c = []
+        self.byd_diag_volt_min = []
+        self.byd_diag_volt_min_c = []
+        self.byd_diag_temp_max = []
+        self.byd_diag_temp_max_c = []
+        self.byd_diag_temp_min = []
+        self.byd_diag_temp_min_c = []
+        self.byd_diag_charge_total = []
+        self.byd_diag_discharge_total = []
+        self.byd_diag_balance_active = []
+        self.byd_diag_balance_number = []
+        self.byd_diag_bms_log = []                            # Liste der Log-Eintraege pro Turm
+        self.byd_diag_bms_log_html = []                       # HTML-Tabelle der Log-Eintrage pro Turm
+        self.byd_diag_module = []                             # Liste der Daten zu den Modulen pro Turm
+        self.byd_volt_cell = []
+        self.byd_balance_cell = []
+        self.byd_temp_cell = []
+        self.byd_bmu_log = []
+        self.byd_bmu_log_html = ""
+        for x in range(0,byd_towers_max + 1):   # 0..3
+          self.byd_diag_soc.append(0)
+          self.byd_diag_soh.append(0)
+          self.byd_diag_state.append(0)
+          self.byd_diag_state_str.append(0)
+          self.byd_diag_bat_voltag.append(0)
+          self.byd_diag_v_out.append(0)
+          self.byd_diag_current.append(0)
+          self.byd_diag_volt_diff.append(0)
+          self.byd_diag_volt_max.append(0)
+          self.byd_diag_volt_max_c.append(0)
+          self.byd_diag_volt_min.append(0)
+          self.byd_diag_volt_min_c.append(0)
+          self.byd_diag_temp_max.append(0)
+          self.byd_diag_temp_max_c.append(0)
+          self.byd_diag_temp_min.append(0)
+          self.byd_diag_temp_min_c.append(0)
+          self.byd_diag_charge_total.append(0)
+          self.byd_diag_discharge_total.append(0)
+          self.byd_diag_balance_active.append(0)
+          self.byd_diag_balance_number.append(0)
+          self.byd_diag_bms_log.append([])
+          self.byd_diag_bms_log_html.append("")
+          self.byd_diag_module.append([])
+          a = []
+          for xx in range(0,byd_cells_max + 1):   # 0..160
+            a.append(0)
+          self.byd_volt_cell.append(a)
+          a = []
+          for xx in range(0,byd_cells_max + 1):   # 0..160
+            a.append(0)
+          self.byd_balance_cell.append(a)
+          a = []
+          for xx in range(0,byd_temps_max + 1):   # 0..64
+            a.append(0)
+          self.byd_temp_cell.append(a)
+          
+        self.plt_file_del()
+        
+        # Log-Verzeichnis erstellen
+        if self.log_data == True:
+          self.log_dir = self.create_logdirectory(self.get_sh().get_basedir(),byd_log_directory)
+          self.log_debug("log_dir=" + self.log_dir)
+          
+#        self.simulate_data()  # for internal tests only [TEST]
+
+        # Initialization code goes here
+
+        self.sh = sh
+        
+        self.init_webinterface()
+        
+        return
+
+    def run(self):
+        """
+        Run method for the plugin
+        """
+        self.scheduler_add(scheduler_name,self.poll_device,cycle=self._cycle)
+
+        self.alive = True
+        
+        return
+
+    def stop(self):
+        """
+        Stop method for the plugin
+        """
+        self.logger.debug("Stop method called")
+        self.scheduler_remove('poll_device')
+        self.alive = False
+
+    def parse_item(self, item):
+        """
+        Default plugin parse_item method. Is called when the plugin is initialized.
+        The plugin can, corresponding to its attribute keywords, decide what to do with
+        the item in future, like adding it to an internal array for future reference
+        :param item:    The item to process.
+        :return:        If the plugin needs to be informed of an items change you should return a call back function
+                        like the function update_item down below. An example when this is needed is the knx plugin
+                        where parse_item returns the update_item function when the attribute knx_send is found.
+                        This means that when the items value is about to be updated, the call back function is called
+                        with the item, caller, source and dest as arguments and in case of the knx plugin the value
+                        can be sent to the knx with a knx write function within the knx plugin.
+        """
+
+        if self.get_iattr_value(item.conf,'byd_root'):
+          self.byd_root = item
+          self.byd_root_found = True
+          self.log_debug("BYD root = " + "{0}".format(self.byd_root))
+
+        if self.has_iattr(item.conf,'byd_para'):
+            self._itemlist.append(item)
+            return self.update_item
+
+
+    def parse_logic(self, logic):
+        """
+        Default plugin parse_logic method
+        """
+        if 'xxx' in logic.conf:
+            # self.function(logic['name'])
+            pass
+
+    def update_item(self, item, caller=None, source=None, dest=None):
+        # Wird aufgerufen, wenn ein Item mit dem Attribut 'mmgarden' geaendert wird
+
+        self.log_debug("update_auto_item path=" + item.property.path + " name=" + item.property.name + " v=" + str(item()))
+        
+        if self.alive and caller != self.get_shortname():
+          # code to execute if the plugin is not stopped
+          # and only, if the item has not been changed by this plugin:
+            
+          s1 = item.property.path
+          if s1.find("enable_connection") != -1:
+            self.byd_root.info.connection(item())
+            if item() == True:
+              self.log_info("communication disabled => enabled !")
+            else:
+              self.log_info("communication enabled => disabled !")
+            
+        return
+
+    def poll_device(self):
+        # Wird alle 'self._cycle' aufgerufen
+        
+        if self.byd_root_found is False:
+          self.log_debug("BYD not root found - please define root item with structure 'byd_struct'")
+          return
+        
+        if self.byd_root.enable_connection() is False:
+          self.log_debug("communication disabled !")
+          return
+        
+        self.log_debug("BYD Start *********************")
+        
+        # Verbindung herstellen
+        client = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
+        try:
+          client.connect((self.ip,8080))
+        except:
+          self.log_info("client.connect failed (" + self.ip + ")")
+          self.byd_root.info.connection(False)
+          client.close()
+          return
+          
+        # 0.Befehl senden
+        res,data = self.send_msg(client,MESSAGE_0,byd_timeout_1s)
+        if res != byd_ok:
+          self.log_info("client.recv 0 failed")
+          self.byd_root.info.connection(False)
+          client.close()
+          return
+        if self.decode_0(data) == byd_error:
+          return
+        
+        # 1.Befehl senden
+        res,data = self.send_msg(client,MESSAGE_1,byd_timeout_1s)
+        if res != byd_ok:
+          self.log_info("client.recv 1 failed")
+          self.byd_root.info.connection(False)
+          client.close()
+          return
+        if self.decode_1(data) == byd_error:
+          return
+        
+        # 2.Befehl senden
+        res,data = self.send_msg(client,MESSAGE_2,byd_timeout_1s)
+        if res != byd_ok:
+          self.log_info("client.recv 1 failed")
+          self.byd_root.info.connection(False)
+          client.close()
+          return
+        if self.decode_2(data) == byd_error:
+          return
+        if self.byd_cells_n == 0:
+          # Batterietyp wird nicht unterstuetzt !
+          self.log_info("battery type " + self.byd_batt_str + " not supported !")
+          self.byd_root.info.connection(False)
+          client.close()
+          return
+        
+        # Speichere die Basisdaten
+        self.basisdata_save(self.byd_root)
+        
+        # Pruefe, ob die Diagnosedaten abgefragt werden sollen
+        self.last_diag_secs = self.last_diag_secs + self._cycle
+        if self.last_diag_secs >= self.diag_cycle:
+          self.last_diag_secs = 0
+          
+          # Durchlaufe alle Tuerme
+          for x in range(1,self.byd_bms_qty + 1):    # 1 ... self.byd_bms_qty
+            self.log_debug("Diagnose Turm " + str(x))
+            
+            # 3.Befehl senden
+            if x == 1:
+              m = MESSAGE_3_1
+            elif x == 2:
+              m = MESSAGE_3_2
+            elif x == 3:
+              m = MESSAGE_3_3
+            res,data = self.send_msg(client,m,byd_timeout_2s)
+            if res != byd_ok:
+              self.log_info("client.recv 3 failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            if self.decode_nop(data,x,MESSAGE_3_L) == byd_error:
+              return
+            time.sleep(2)
+            
+            # 4.Befehl senden
+            res,data = self.send_msg(client,MESSAGE_4,byd_timeout_10s)
+            if res != byd_ok:
+              self.log_info("client.recv 4 failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            if self.decode_nop(data,x,MESSAGE_4_L) == byd_error:
+              return
+            
+            # 5.Befehl senden
+            res,data = self.send_msg(client,MESSAGE_5,byd_timeout_1s)
+            if res != byd_ok:
+              self.log_info("client.recv 5 failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            if self.decode_5(data,x) == byd_error:
+              return
+            
+            # 6.Befehl senden
+            res,data = self.send_msg(client,MESSAGE_6,byd_timeout_1s)
+            if res != byd_ok:
+              self.log_info("client.recv 6 failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            if self.decode_6(data,x) == byd_error:
+              return
+            
+            # 7.Befehl senden
+            res,data = self.send_msg(client,MESSAGE_7,byd_timeout_1s)
+            if res != byd_ok:
+              self.log_info("client.recv 7 failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            if self.decode_7(data,x) == byd_error:
+              return
+            
+            # 8.Befehl senden
+            res,data = self.send_msg(client,MESSAGE_8,byd_timeout_1s)
+            if res != byd_ok:
+              self.log_info("client.recv 8 failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            if self.decode_8(data,x) == byd_error:
+              return
+            
+            if self.byd_cells_n > 128:
+              # Switch to second turn for the last module - 9.Befehl senden
+              res,data = self.send_msg(client,MESSAGE_9,byd_timeout_1s)
+              if res != byd_ok:
+                self.log_info("client.recv 9 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              self.decode_nop(data,x,MESSAGE_9_L)  # Laenge von MESSAGE_9 ist nicht bekannt - daher kein Abbruch hier
+              time.sleep(2)
+  
+              # 10.Befehl senden (wie Befehl 3)
+              if x == 1:
+                m = MESSAGE_10_1
+              elif x == 2:
+                m = MESSAGE_10_2
+              elif x == 3:
+                m = MESSAGE_10_3
+              res,data = self.send_msg(client,m,byd_timeout_2s)
+              if res != byd_ok:
+                self.log_info("client.recv 10 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              if self.decode_nop(data,x,MESSAGE_10_L) == byd_error:
+                return
+              time.sleep(2)
+            
+              # 11.Befehl senden (wie Befehl 4)
+              res,data = self.send_msg(client,MESSAGE_11,byd_timeout_10s)
+              if res != byd_ok:
+                self.log_info("client.recv 11 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              if self.decode_nop(data,x,MESSAGE_11_L) == byd_error:
+                return
+              
+              # 12.Befehl senden (wie Befehl 5)
+              res,data = self.send_msg(client,MESSAGE_12,byd_timeout_1s)
+              if res != byd_ok:
+                self.log_info("client.recv 12 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              if self.decode_12(data,x) == byd_error:
+                return
+              
+              # 13.Befehl senden (wie Befehl 6)
+              res,data = self.send_msg(client,MESSAGE_13,byd_timeout_1s)
+              if res != byd_ok:
+                self.log_info("client.recv 13 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              if self.decode_13(data,x) == byd_error:
+                return
+              
+              # 14.Befehl senden (wie Befehl 7)
+              res,data = self.send_msg(client,MESSAGE_14,byd_timeout_1s)
+              if res != byd_ok:
+                self.log_info("client.recv 14 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              if self.decode_14(data,x) == byd_error:
+                return
+              
+              # 15.Befehl senden (wie Befehl 8)
+              res,data = self.send_msg(client,MESSAGE_15,byd_timeout_1s)
+              if res != byd_ok:
+                self.log_info("client.recv 15 failed")
+                self.byd_root.info.connection(False)
+                client.close()
+                return
+              if self.decode_15(data,x) == byd_error:
+                return
+                
+            self.module_update(x)    # Bestimme gewisse Werte zu jedem Modul im Turm
+  
+          self.diagdata_save(self.byd_root)
+          self.byd_root.info.connection(True)
+
+          self.log_debug("BYD Diag Done +++++++++++++++++")
+          
+        # Pruefe, ob die Logdaten ausgelesen werden sollen
+        if self.log_data == True:
+          self.last_log_secs = self.last_log_secs + self._cycle
+          if self.last_log_secs >= byd_sample_log:
+            self.last_log_secs = 0
+            if self.read_log_data(client) == byd_error:
+              # Etwas ist schief gelaufen
+              self.log_info("read_log_data failed")
+              self.byd_root.info.connection(False)
+              client.close()
+              return
+            else:
+              self.byd_root.info.last_log(self.now_str())
+                  
+        client.close()
+
+        return
+
+# -----------------------------------------------------------------------
+# Decodieren der Daten vom BYD-System (ohne Log-Daten)
+# -----------------------------------------------------------------------
+        
+    def decode_0(self,data):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_0'.
+        
+        self.log_debug("decode_0: " + data.hex())
+        
+        if len(data) != MESSAGE_0_L:
+          self.log_info("MESSAGE_0 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_0_L) + ")")
+          return byd_error
+        
+        # Serienummer
+        self.byd_serial = ""
+        for x in range(3,22):  # 3..21
+          self.byd_serial = self.byd_serial + chr(data[x])                                     # Byte 3 .. 21
+        # self.byd_serial = "xxxxxxxxxxxxxxxxxxx"  # fuer Screenshots
+          
+        # Firmware-Versionen
+        self.byd_bmu_a = "V" + str(data[27]) + "." + str(data[28])                             # Byte 27+28 (Register 12)
+        self.byd_bmu_b = "V" + str(data[29]) + "." + str(data[30])                             # Byte 29+30 (Register 13)
+        self.byd_bms   = "V" + str(data[31]) + "." + str(data[32]) + "-" + chr(data[34] + 65)  # Byte 31+32+34
+        if data[33] == 0:                                                                      # Byte 33
+          self.byd_bmu = self.byd_bmu_a + "-A"
+        else:
+          self.byd_bmu = self.byd_bmu_b + "-B"
+
+        # Anzahl Tuerme und Anzahl Module pro Turm
+        self.byd_bms_qty = data[36] // 0x10                                                    # Byte 36 Bit 4-7 (Anzahl Tuerme)
+        if (self.byd_bms_qty == 0) or (self.byd_bms_qty > byd_towers_max):
+          self.byd_bms_qty = 1
+        self.byd_modules = data[36] % 0x10                                                     # Byte 36 Bit 0-3  (Anzahl Module)
+        self.byd_batt_type_snr = data[5]                                                       # Byte 5 (LVS Batterietyp Unterscheidung)
+        
+        # Application
+        if data[38] == 0:                                                                      # Byte 38
+          self.byd_application = "OffGrid"
+        elif data[38] == 1:
+          self.byd_application = "OnGrid"
+        elif data[38] == 2:
+          self.byd_application = "Backup"
+        else:
+          self.byd_application = "unknown"
+          
+        self.log_debug("Serial      : " + self.byd_serial)
+        self.log_debug("BMU A       : " + self.byd_bmu_a)
+        self.log_debug("BMU B       : " + self.byd_bmu_b)
+        self.log_debug("BMU         : " + self.byd_bmu)
+        self.log_debug("BMS         : " + self.byd_bms)
+        self.log_debug("BMS QTY     : " + str(self.byd_bms_qty))
+        self.log_debug("Modules     : " + str(self.byd_modules))
+        self.log_debug("Application : " + self.byd_application)
+        
+        return byd_ok
+
+    def decode_1(self,data):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_1'.
+
+        self.log_debug("decode_1: " + data.hex())
+        
+        if len(data) != MESSAGE_1_L:
+          self.log_info("MESSAGE_1 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_1_L) + ")")
+          return byd_error
+        
+        self.byd_soc = self.buf2int16SI(data,3)                       # Byte 3+4   (Register  0)
+        self.byd_volt_max = self.buf2int16SI(data,5) * 1.0 / 100.0    # Byte 5+6   (Register  1)
+        self.byd_volt_min = self.buf2int16SI(data,7) * 1.0 / 100.0    # Byte 7+8   (Register  2)
+        self.byd_soh = self.buf2int16SI(data,9)                       # Byte 9+10  (Register  3)
+        self.byd_current = self.buf2int16SI(data,11) * 1.0 / 10.0     # Byte 11+12 (Register  4)
+        self.byd_volt_bat = self.buf2int16US(data,13) * 1.0 / 100.0   # Byte 13+14 (Register  5)
+        self.byd_temp_max = self.buf2int16SI(data,15)                 # Byte 15+16 (Register  6)
+        self.byd_temp_min = self.buf2int16SI(data,17)                 # Byte 17+18 (Register  7)
+        self.byd_temp_bat = self.buf2int16SI(data,19)                 # Byte 19+20 (Register  8)
+        
+        self.byd_error_nr = self.buf2int16SI(data,29)                 # Byte 29+30 (Register 13)
+        self.byd_error_str = ""
+        for x in range(0,16):
+          if (((1 << x) & self.byd_error_nr) != 0):
+            if len(self.byd_error_str) > 0:
+              self.byd_error_str = self.byd_error_str + ";"
+            self.byd_error_str = self.byd_error_str + byd_errors[x]
+        if len(self.byd_error_str) == 0:
+          self.byd_error_str = "no error"
+
+        self.byd_param_t = str(data[31]) + "." + str(data[32])        # Byte 31+32 (Register 14)
+        
+        self.byd_volt_out = self.buf2int16US(data,35) * 1.0 / 100.0   # Byte 35+36 (Register 16)
+        self.byd_volt_diff = self.byd_volt_max - self.byd_volt_min
+        self.byd_power = self.byd_volt_out * self.byd_current
+        if self.byd_power >= 0:
+          self.byd_power_discharge = self.byd_power
+          self.byd_power_charge = 0
+        else:
+          self.byd_power_discharge = 0
+          self.byd_power_charge = -self.byd_power
+          
+        self.byd_charge_total = self.buf2int32US(data,37) / 10.0      # Byte 37-40 (Register 17+18) (in 100Wh in data)
+        self.byd_discharge_total = self.buf2int32US(data,41) / 10.0   # Byte 41-44 (Register 19+20) (in 100Wh in data)
+        self.byd_eta = (self.byd_discharge_total / self.byd_charge_total) * 100.0
+
+        self.log_debug("SOC             : " + f"{self.byd_soc:.1f}")
+        self.log_debug("SOH             : " + f"{self.byd_soh:.1f}")
+        self.log_debug("Volt Battery    : " + f"{self.byd_volt_bat:.1f}")
+        self.log_debug("Volt Out        : " + f"{self.byd_volt_out:.1f}")
+        self.log_debug("Volt max        : " + f"{self.byd_volt_max:.1f}")
+        self.log_debug("Volt min        : " + f"{self.byd_volt_min:.1f}")
+        self.log_debug("Volt diff       : " + f"{self.byd_volt_diff:.1f}")
+        self.log_debug("Current         : " + f"{self.byd_current:.1f}")
+        self.log_debug("Power           : " + f"{self.byd_power:.1f}")
+        self.log_debug("Temp Battery    : " + f"{self.byd_temp_bat:.1f}")
+        self.log_debug("Temp max        : " + f"{self.byd_temp_max:.1f}")
+        self.log_debug("Temp min        : " + f"{self.byd_temp_min:.1f}")
+        self.log_debug("Error           : " + f"{self.byd_error_nr:.0f}" + " " + self.byd_error_str)
+        self.log_debug("ParamT          : " + self.byd_param_t)
+        self.log_debug("Charge total    : " + f"{self.byd_charge_total:.1f}")
+        self.log_debug("Discharge total : " + f"{self.byd_discharge_total:.1f}")
+        self.log_debug("ETA             : " + f"{self.byd_eta:.1f}")
+        
+        return byd_ok
+        
+    def decode_2(self,data):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_2'.
+
+        self.log_debug("decode_2: " + data.hex())
+
+        if len(data) != MESSAGE_2_L:
+          self.log_info("MESSAGE_2 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_2_L) + ")")
+          return byd_error
+        
+        self.byd_batt_type = data[5]
+        if self.byd_batt_type == 0:
+          # HVL -> Lithium Iron Phosphate (LFP), 3-8 Module (12kWh-32kWh), unknown specification, so 0 cells and 0 temps
+          self.byd_batt_str = "HVL"
+          self.byd_capacity_module = 4.0
+          self.byd_volt_n = 0
+          self.byd_temp_n = 0
+          self.byd_cells_n = 0
+          self.byd_temps_n = 0
+        elif self.byd_batt_type == 1:
+          # HVM 16 Cells per module
+          self.byd_batt_str = "HVM"
+          self.byd_capacity_module = 2.76
+          self.byd_volt_n = 16
+          self.byd_temp_n = 8
+          self.byd_cells_n = self.byd_modules * self.byd_volt_n
+          self.byd_temps_n = self.byd_modules * self.byd_temp_n
+        elif self.byd_batt_type == 2:
+          # HVS 32 cells per module
+          self.byd_batt_str = "HVS"
+          self.byd_capacity_module = 2.56
+          self.byd_volt_n = 32
+          self.byd_temp_n = 12
+          self.byd_cells_n = self.byd_modules * self.byd_volt_n
+          self.byd_temps_n = self.byd_modules * self.byd_temp_n
+        else:
+          if (self.byd_batt_type_snr == 49) or (self.byd_batt_type_snr == 50):
+            self.byd_batt_str = "LVS"
+            self.byd_capacity_module = 4.0
+            self.byd_volt_n = 7
+            self.byd_temp_n = 0
+            self.byd_cells_n = self.byd_modules * self.byd_volt_n
+            self.byd_temps_n = 0
+          else:
+            self.byd_batt_str = "???"
+            self.byd_capacity_module = 0.0
+            self.byd_volt_n = 0
+            self.byd_temp_n = 0
+            self.byd_cells_n = 0
+            self.byd_temps_n = 0
+            
+        self.byd_capacity_total = self.byd_bms_qty * self.byd_modules * self.byd_capacity_module
+        
+        self.byd_inv_type = data[3]
+        self.byd_inv_str = self.get_inverter_name(self.byd_batt_str,self.byd_inv_type)
+        
+        self.log_debug("Inv Type  : " + self.byd_inv_str + " (" + str(self.byd_inv_type) + ")")
+        self.log_debug("Batt Type : " + self.byd_batt_str + " (" + str(self.byd_batt_type) + ")")
+        self.log_debug("Cells n   : " + f"{self.byd_cells_n:.0f}")
+        self.log_debug("Temps n   : " + f"{self.byd_temps_n:.0f}")
+        
+        if self.byd_cells_n > byd_cells_max:
+          self.byd_cells_n = byd_cells_max
+        if self.byd_temps_n > byd_temps_max:
+          self.byd_temps_n = byd_temps_max
+          
+        return byd_ok
+   
+    def decode_5(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_5'.
+
+        self.log_debug("decode_5 (" + str(x) + ") : " + data.hex())
+        
+        if len(data) != MESSAGE_5_L:
+          self.log_info("MESSAGE_5 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_5_L) + ")")
+          return byd_error
+        
+        self.byd_diag_volt_max[x] = self.buf2int16SI(data,5) / 1000.0          # Byte 5+6   (Index 1)
+        self.byd_diag_volt_min[x] = self.buf2int16SI(data,7) / 1000.0          # Byte 7+8   (Index 2)
+        self.byd_diag_volt_max_c[x] = data[9]                                  # Byte 9     (Index 3)
+        self.byd_diag_volt_min_c[x] = data[10]                                 # Byte 10
+        self.byd_diag_temp_max[x] = self.buf2int16SI(data,11)                  # Byte 11+12 (Index 4)
+        self.byd_diag_temp_min[x] = self.buf2int16SI(data,13)                  # Byte 13+14 (Index 5)
+        self.byd_diag_temp_max_c[x] = data[15]                                 # Byte 15    (Index 6)
+        self.byd_diag_temp_min_c[x] = data[16]                                 # Byte 16
+        
+        self.byd_diag_volt_diff[x] = (self.byd_diag_volt_max[x] - self.byd_diag_volt_min[x]) * 1000.0
+        
+        # Balancing-Flags. Es folgen 8x 16-bit-Worte (MSB,LSB!) = 16 Byte => 0..127 Bits
+        i = 0      # Zaehlt die Bits
+        nnn = 0    # Zaehlt die Anzahl der Zellen mit Balancing-Modus
+        for xx in range(17,33):  # 17..32  (16 Byte) Index 7..14
+          if (xx % 2) == 1:
+            a = data[xx+1]  # LSB, Bit 0-7
+          else:
+            a = data[xx-1]  # MSB, Bit 8-15
+#          self.log_debug("Balancing i=" + f"{i:.0f}" + " d=" + f"{a:.0f}")
+          for yy in range(0,8):  # 0..7
+            if (int(a) & 1) == 1:
+              self.byd_balance_cell[x][i] = 1
+              nnn = nnn + 1
+            else:
+              self.byd_balance_cell[x][i] = 0
+            a = a / 2
+            i = i + 1
+        self.byd_diag_balance_number[x] = nnn
+
+        self.byd_diag_charge_total[x] = self.buf2int32US(data,33) / 1000.0     # Byte 33-36 (Register 15+16) (in 1Wh in data)
+        self.byd_diag_discharge_total[x] = self.buf2int32US(data,37) / 1000.0  # Byte 37-41 (Register 17+18) (in 1Wh in data)
+        
+        self.byd_diag_bat_voltag[x] = self.buf2int16SI(data,45) * 1.0 / 10.0   # Byte 45+46 (Index 21)
+        
+        self.byd_diag_v_out[x] = self.buf2int16SI(data,51) * 1.0 / 10.0        # Byte 51+52 (Index 24)
+        self.byd_diag_soc[x] = self.buf2int16SI(data,53) * 1.0 / 10.0          # Byte 53+54 (Index 25)
+        self.byd_diag_soh[x] = self.buf2int16SI(data,55) * 1.0                 # Byte 55+56 (Index 26)
+        self.byd_diag_current[x] = self.buf2int16SI(data,57) * 1.0 / 10.0      # Byte 57+58 (Index 27)
+        self.byd_diag_state[x] = data[59] * 0x100 + data[60]                   # Byte 59+60 (Index 28)
+        
+        # starting with byte 101, ending with 131, Cell voltage 0-15
+        for xx in range(0,16):  # 0..15
+          self.byd_volt_cell[x][xx] = self.buf2int16SI(data,101 + (xx * 2)) / 1000.0
+
+        self.log_debug("SOC             : " + f"{self.byd_diag_soc[x]:.1f}")
+        self.log_debug("SOH             : " + f"{self.byd_diag_soh[x]:.1f}")
+        self.log_debug("Bat Voltag      : " + f"{self.byd_diag_bat_voltag[x]:.2f}")
+        self.log_debug("V-Out           : " + f"{self.byd_diag_v_out[x]:.2f}")
+        self.log_debug("Current         : " + f"{self.byd_diag_current[x]:.2f}")
+        self.log_debug("Volt max        : " + f"{self.byd_diag_volt_max[x]:.3f}" + " c=" + f"{self.byd_diag_volt_max_c[x]:.0f}")
+        self.log_debug("Volt min        : " + f"{self.byd_diag_volt_min[x]:.3f}" + " c=" + f"{self.byd_diag_volt_min_c[x]:.0f}")
+        self.log_debug("Volt diff       : " + f"{self.byd_diag_volt_diff[x]:.1f}")
+        self.log_debug("Temp max        : " + f"{self.byd_diag_temp_max[x]:.1f}" + " c=" + f"{self.byd_diag_temp_max_c[x]:.0f}")
+        self.log_debug("Temp min        : " + f"{self.byd_diag_temp_min[x]:.1f}" + " c=" + f"{self.byd_diag_temp_min_c[x]:.0f}")
+        self.log_debug("Charge total    : " + f"{self.byd_diag_charge_total[x]:.3f}")
+        self.log_debug("Discharge total : " + f"{self.byd_diag_discharge_total[x]:.3f}")
+        self.log_debug("Status          : " + "0x" + f"{self.byd_diag_state[x]:04x}")
+        self.log_debug("Balancing       : " + f"{nnn:.0f}")
+#        for xx in range(0,16):
+#          self.log_debug("Turm " + str(x) + " Volt " + str(xx) + " : " + str(self.byd_volt_cell[x][xx]))
+        
+        return byd_ok
+
+    def decode_6(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_6'.
+
+        self.log_debug("decode_6 (" + str(x) + ") : " + data.hex())
+        
+        if len(data) != MESSAGE_6_L:
+          self.log_info("MESSAGE_6 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_6_L) + ")")
+          return byd_error
+        
+        for xx in range(0,64):  # 0..63, Cell voltage 16-79
+          self.byd_volt_cell[x][16 + xx] = self.buf2int16SI(data,5 + (xx * 2)) / 1000.0
+          
+#        for xx in range(0,64):
+#          self.log_debug("Turm " + str(x) + " Volt " + str(16 + xx) + " : " + str(self.byd_volt_cell[x][16 + xx]))
+
+        return byd_ok
+
+    def decode_7(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_7'.
+
+        self.log_debug("decode_7 (" + str(x) + ") : " + data.hex())
+
+        if len(data) != MESSAGE_7_L:
+          self.log_info("MESSAGE_7 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_7_L) + ")")
+          return byd_error
+        
+        # starting with byte 5, ending 101, voltage for cell 81 to 128
+        for xx in range(0,48):  # 0..47, Cell voltage 80-127
+          self.byd_volt_cell[x][80 + xx] = self.buf2int16SI(data,5 + (xx * 2)) / 1000.0
+        
+        # starting with byte 103, ending 132, temp for cell 1 to 30
+        for xx in range(0,30):  # 0..29
+          self.byd_temp_cell[x][xx] = data[103 + xx]
+
+#        for xx in range(0,48):
+#          self.log_debug("Turm " + str(x) + " Volt " + str(80 + xx) + " : " + str(self.byd_volt_cell[x][80 + xx]))
+#        for xx in range(0,30):
+#          self.log_debug("Turm " + str(x) + " Temp " + str(xx) + " : " + str(self.byd_temp_cell[x][xx]))
+        
+        return byd_ok
+
+    def decode_8(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_8'.
+
+        self.log_debug("decode_8 (" + str(x) + ") : " + data.hex())
+
+        if len(data) != MESSAGE_8_L:
+          self.log_info("MESSAGE_8 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_8_L) + ")")
+          return byd_error
+        
+        for xx in range(0,34):  # 0..33
+          self.byd_temp_cell[x][30 + xx] = data[5 + xx]
+        
+#        for xx in range(0,34):
+#          self.log_debug("Turm " + str(x) + " Temp " + str(30 + xx) + " : " + str(self.byd_temp_cell[x][30 + xx]))
+
+        return byd_ok
+
+    def decode_12(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_12' fuer den Turm 'x'.
+
+        self.log_debug("decode_12 (" + str(x) + ") : " + data.hex())
+
+        if len(data) != MESSAGE_12_L:
+          self.log_info("MESSAGE_12 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_12_L) + ")")
+          return byd_error
+        
+        # Balancing-Flags. Es folgen 8x 16-bit-Worte (MSB,LSB!) = 16 Byte => 0..127 Bits
+        i = 127
+        nnn = self.byd_diag_balance_number[x]
+        for xx in range(17,33):  # 17..32
+          if (xx % 2) == 1:
+            a = data[xx+1]  # LSB, Bit 0-7
+          else:
+            a = data[xx-1]  # MSB, Bit 8-15
+#          self.log_debug("Balancing i=" + str(i) + " d=" + str(a))
+          for yy in range(0,8):  # 0..7
+            if i <= byd_cells_max:
+              if (int(a) & 1) == 1:
+                self.byd_balance_cell[x][i] = 1
+                nnn = nnn + 1
+              else:
+                self.byd_balance_cell[x][i] = 0
+            a = a / 2
+            i = i + 1
+        self.byd_diag_balance_number[x] = nnn
+        
+        # starting with byte 101, ending with 116, Cell voltage 129-144
+        for xx in range(0,16):  # 0..15, Cell voltage 128-143
+          self.byd_volt_cell[x][128 + xx] = self.buf2int16SI(data,101 + (xx * 2)) / 1000.0
+
+        return byd_ok
+
+    def decode_13(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_13'.
+
+        self.log_debug("decode_13 (" + str(x) + ") : " + data.hex())
+        
+        if len(data) != MESSAGE_13_L:
+          self.log_info("MESSAGE_13 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_13_L) + ")")
+          return byd_error
+        
+        # The first round measured up to 128 cells, request[12] then get another 16
+        # With 5 HVS Modules (max for HVS), only 16 cells are remaining
+
+        # starting with byte 5, ending with 21, Cell voltage 145-161
+        for xx in range(0,16):  # 0..15, Cell voltage 144-160
+          self.byd_volt_cell[x][144 + xx] = self.buf2int16SI(data,5 + (xx * 2)) / 1000.0
+
+        return byd_ok
+
+    def decode_14(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_14'.
+
+        self.log_debug("decode_14 (" + str(x) + ") : " + data.hex())
+
+        if len(data) != MESSAGE_14_L:
+          self.log_info("MESSAGE_14 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_14_L) + ")")
+          return byd_error
+        
+        return byd_ok
+
+    def decode_15(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'MESSAGE_15'.
+
+        self.log_debug("decode_15 (" + str(x) + ") : " + data.hex())
+
+        if len(data) != MESSAGE_15_L:
+          self.log_info("MESSAGE_15 answer length wrong ! (" + str(len(data)) + "/" + str(MESSAGE_15_L) + ")")
+          return byd_error
+        
+        return byd_ok
+        
+    def decode_nop(self,data,x,lenx):
+#        self.log_debug("decode_nop (" + str(x) + ") : " + data.hex())
+
+        if len(data) != lenx:
+          self.log_info("MESSAGE nop answer length wrong ! (" + str(len(data)) + "/" + str(lenx) + ")")
+          return byd_error
+        
+        return byd_ok
+        
+    def module_update(self,xx):
+        # Aktualisiert die Daten zu den Modulen im Turm 'xx'.
+
+        self.byd_diag_module[xx].clear()
+        
+        for i in range(0,self.byd_modules):  # 0.. byd_modules-1
+          f = True
+          vx = 0
+          for j in range(i * self.byd_volt_n,(i + 1) * self.byd_volt_n):  # Durchlaufe die Spannungswerte von Modul 'i'
+            vv = self.byd_volt_cell[xx][j]
+            if f == True:
+              vmin = vv
+              vmax = vv
+              f = False
+            if vv < vmin:
+              vmin = vv
+            if vv > vmax:
+              vmax = vv
+            vx = vx + vv
+          vava = vx / self.byd_volt_n
+          vdif = (vmax - vmin) * 1000.0
+          ll = []
+          ll.append(vmin)
+          ll.append(vmax)
+          ll.append(vava)
+          ll.append(vdif)
+          self.byd_diag_module[xx].append(ll)
+          
+        if self.byd_modules < byd_module_max:
+          for i in range(self.byd_modules,byd_module_max):
+            ll = []
+            ll.append(0)
+            ll.append(0)
+            ll.append(0)
+            ll.append(0)
+            self.byd_diag_module[xx].append(ll)
+            
+#        for i in range(0,self.byd_modules):  # 0.. byd_modules-1
+#          vmin = self.byd_diag_module[xx][i][byd_module_vmin]
+#          vmax = self.byd_diag_module[xx][i][byd_module_vmax]
+#          vava = self.byd_diag_module[xx][i][byd_module_vava]
+#          vdif = self.byd_diag_module[xx][i][byd_module_vdif]
+#          self.log_debug("M" + str(i+1) + ": vmin=" + f"{vmin:.3f}" + ": vmax=" + f"{vmax:.3f}" + ": vava=" + f"{vava:.3f}" + ": vdif=" + f"{vdif:.3f}")
+        
+        return
+        
+# -----------------------------------------------------------------------
+# Decodieren der Log-Daten vom BYD-System
+# -----------------------------------------------------------------------
+
+    def read_log_data(self,client):
+        # Einlesen/aktualisieren der BMS-Log-Eintraege von allen Tuermen
+        
+        # Log-Daten aus dem BMU (0) und jedem Turm (1-3) auslesen
+        for x in range(0,self.byd_bms_qty + 1):    # 0 ... self.byd_bms_qty
+          if x == 0:
+            self.log_debug("read_log_data BMU")
+          else:
+            self.log_debug("read_log_data BMS tower " + str(x))
+          
+          # Trigger zum Auslesen senden
+          bmu = False
+          if x == 0:
+            m = EVT_MSG_0_0
+            bmu = True
+          if x == 1:
+            m = EVT_MSG_0_1
+          elif x == 2:
+            m = EVT_MSG_0_2
+          elif x == 3:
+            m = EVT_MSG_0_3
+          res,data = self.send_msg(client,m,byd_timeout_1s)
+          if res == byd_error:
+            self.log_info("read_log_data message " + m + " failed")
+            return byd_error
+          if self.decode_log_0(data,x) == byd_error:
+            return
+          time.sleep(2)
+          
+          # Register 0x05A1 auslesen -> Log-Daten verfuerbar ?
+          res,r = self.read_reg(client,0x05A1,0x01)
+          if res == byd_error:
+            self.log_info("read_log_data read_reg 0x05A1 failed")
+            return byd_error
+          if (r % 0x100) == 0:
+            self.log_debug("read_log_data no data (" + str(x) + ")")
+            continue
+            
+          # 1.Paket auslesen
+          res,data1 = self.send_msg(client,EVT_MSG_1,byd_timeout_1s)
+          if res == byd_error:
+            self.log_info("read_log_data message " + m + " failed")
+            return byd_error
+            
+          # 2.Paket auslesen
+          res,data2 = self.send_msg(client,EVT_MSG_1,byd_timeout_1s)
+          if res == byd_error:
+            self.log_info("read_log_data message " + m + " failed")
+            return byd_error
+            
+          # 3.Paket auslesen
+          res,data3 = self.send_msg(client,EVT_MSG_1,byd_timeout_1s)
+          if res == byd_error:
+            self.log_info("read_log_data message " + m + " failed")
+            return byd_error
+            
+          # 4.Paket auslesen
+          res,data4 = self.send_msg(client,EVT_MSG_1,byd_timeout_1s)
+          if res == byd_error:
+            self.log_info("read_log_data message " + m + " failed")
+            return byd_error
+            
+          # 5.Paket auslesen
+          res,data5 = self.send_msg(client,EVT_MSG_1,byd_timeout_1s)
+          if res == byd_error:
+            self.log_info("read_log_data message " + m + " failed")
+            return byd_error
+            
+          # Daten extrahieren und speichern
+          if self.decode_log_1(bmu,data1,data2,data3,data4,data5,x) == byd_error:
+            return
+            
+        if self.byd_bms_qty == 1:
+          self.byd_root.visu.tower2_log.log_html("")
+          self.byd_root.visu.tower2_log.log_jsonlist([])
+          self.byd_root.visu.tower3_log.log_html("")
+          self.byd_root.visu.tower3_log.log_jsonlist([])
+        elif self.byd_bms_qty == 2:
+          self.byd_root.visu.tower3_log.log_html("")
+          self.byd_root.visu.tower3_log.log_jsonlist([])
+        
+        return byd_ok
+        
+    def decode_log_0(self,data,x):
+        # Decodieren der Nachricht auf Befehl 'EVT_MSG_0_1'.
+
+        self.log_debug("decode_log_0 (" + str(x) + ") : " + data.hex())
+
+        if len(data) != EVT_MSG_0_L:
+          self.log_info("EVT_MSG_0_x answer length wrong ! (" + str(len(data)) + "/" + str(EVT_MSG_0_L) + ")")
+          return byd_error
+        
+        return byd_ok
+
+    def decode_log_1(self,bmu,d1,d2,d3,d4,d5,xx):
+        # Decodieren Log-Daten.
+
+        self.log_debug("decode_log_1 (" + str(xx) + ")")
+        self.log_debug("1) " + d1.hex())
+        self.log_debug("2) " + d2.hex())
+        self.log_debug("3) " + d3.hex())
+        self.log_debug("4) " + d4.hex())
+        self.log_debug("5) " + d5.hex())
+
+        if (len(d1) != EVT_MSG_1_L) or (len(d2) != EVT_MSG_1_L) or (len(d3) != EVT_MSG_1_L) or (len(d4) != EVT_MSG_1_L) or (len(d5) != EVT_MSG_1_L):
+          self.log_info("EVT_MSG_1 answer length wrong ! (" + str(len(data)) + "/" + str(EVT_MSG_1_L) + ")")
+          return byd_error
+        
+        # Erzeuge eine Liste mit allen Datenbytes - 20 Log-Eintraege befinden sich in dieser Liste
+        d = []
+        for x in range(1,6):  # 1..5
+          for y in range(5,133):  # 5..132
+            if x == 1:
+              d.append(d1[y])
+            elif x == 2:
+              d.append(d2[y])
+            elif x == 3:
+              d.append(d3[y])
+            elif x == 4:
+              d.append(d4[y])
+            elif x == 5:
+              d.append(d5[y])
+
+        self.log_debug("=> " + bytearray(d).hex())
+        
+        # https://smarthomeng.github.io/smarthome/plugins/database/README.html
+        
+        # Log-Eintraege extrahieren
+        for x in range(0,20):  # 0..19
+          # Alle Bytes fuer diesen Log-Eintrag
+          raw = []
+          for y in range(0,30):  # 0..29
+            raw.append(d[(x*30)+y])
+          # Extrahiere Code, Datum und Uhrzeit
+          code = d[(x*30)+0]
+          year = d[(x*30)+1]                 # im Log ist Datum/Zeit = UTC
+          month = d[(x*30)+2]
+          day = d[(x*30)+3]
+          hour = d[(x*30)+4]
+          minute = d[(x*30)+5]
+          second = d[(x*30)+6]
+          # Extrahiere die Daten zu diesem Log-Eintrag
+          data = []
+          for y in range(7,30):  # 7..29
+            data.append(d[(x*30)+y])
+          # Erzeuge nun den Listeneintrag
+          ld = []
+          ld.append(year)
+          ld.append(month)
+          ld.append(day)
+          ld.append(hour)
+          ld.append(minute)
+          ld.append(second)
+          ld.append(code)
+          ld.append(data)
+          ld.append(bytearray(raw).hex())
+          ld.append(self.logdata2str(bmu,ld,xx))
+          
+          self.log_update_list(bmu,ld,xx)
+        
+        self.log_create_html_json(bmu,xx)
+        self.logging_update(bmu,xx)
+        
+#        self.log_debug_list(bmu,xx)
+        
+        return byd_ok
+        
+    def log_update_list(self,bmu,ldx,x):
+        # Fuegt den Log-Datensatz 'ldx' in die Log-Liste ein. Der neuste Eintrag steht vorne (Index 0).
+        ldd = datetime(2000+ldx[byd_log_year],ldx[byd_log_month],ldx[byd_log_day],ldx[byd_log_hour],ldx[byd_log_minute],ldx[byd_log_second],0)
+        if bmu == True:
+          ld = self.byd_bmu_log
+        else:
+          ld = self.byd_diag_bms_log[x]
+        if len(ld) == 0:
+          ld.append(ldx)
+          return
+        for i in range(len(ld)):
+          dd = ld[i]
+          dt = datetime(2000+dd[byd_log_year],dd[byd_log_month],dd[byd_log_day],dd[byd_log_hour],dd[byd_log_minute],dd[byd_log_second],0)
+          if (ldd == dt) and (ldx[byd_log_codex] == dd[byd_log_codex]) and (set(dd[byd_log_data]) == set(ldx[byd_log_data])):
+            # Eintrag ist schon vorhanden
+#            self.log_debug("i=" + str(i) + " -> schon vorhanden " + ldd.strftime("%d.%m.%Y, %H:%M:%S") + " - " + dt.strftime("%d.%m.%Y, %H:%M:%S"))
+            return
+          elif dt < ldd:
+            # Index 'i' zeigt auf das Element vor dem wir das neue Element einfuegen.
+            ld.insert(i,ldx)
+#            self.log_debug("i=" + str(i) + " -> insert " + ldd.strftime("%d.%m.%Y, %H:%M:%S") + " - " + dt.strftime("%d.%m.%Y, %H:%M:%S"))
+            return
+        # Das Element 'ldx' ist aelter als alle bisherigen Elemente.
+        ld.append(ldx)
+        return
+          
+    def logcode2str(self,code):
+        # Gibt den Text zum Logcode 'code' zurueck.
+        for i in range(len(byd_log_code)):
+          if code == byd_log_code[i][0]:
+            return byd_log_code[i][1]
+        return "??"
+        
+    def logdata2str(self,bmu,ld,xx):
+        # Erzeugt aus 'data' in 'ld' einen lesbaren Text (aehnlich Be_Connect).
+        
+        data = ld[byd_log_data]
+        s1 = ""
+        unknown = False
+        if ld[byd_log_codex] == 0:                                                                 # Power ON (0)
+          if bmu == True:
+            if data[0] == 0:
+              s1 = s1 + "Bootloader" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+            if data[1] == 0:
+              s1 = s1 + "Running section: A" + byd_log_str_sep
+            elif data[1] == 1:
+              s1 = s1 + "Running section: B" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+            s2 = f"{data[2]:d}" + "." + f"{data[3]:d}"
+            s1 = s1 + "Current Version:V" + s2 + byd_log_str_sep
+          else:
+            if data[0] == 0:
+              s1 = s1 + "Bootloader" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+            if data[2] == 0:
+              s1 = s1 + "Running section: A" + byd_log_str_sep
+            elif data[2] == 1:
+              s1 = s1 + "Running section: B" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+            s2 = f"{data[3]:d}" + "." + f"{data[4]:d}"
+            s1 = s1 + "FW version:V" + s2 + byd_log_str_sep
+          
+        elif ld[byd_log_codex] == 1:                                                                 # Power OFF (1)
+          if bmu == True:
+            if data[0] == 0:
+              s1 = s1 + "??" + byd_log_str_sep
+            elif data[0] == 1:
+              s1 = s1 + "Switch off by pressing LED button." + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+          else:
+            if data[1] < len(byd_log_bms_poweroff):
+              s1 = s1 + byd_log_bms_poweroff[data[1]] + byd_log_str_sep
+            else:
+              s1 = s1 + byd_log_bms_poweroff[len(byd_log_bms_poweroff)-1] + byd_log_str_sep
+            if data[2] == 0:
+              s1 = s1 + "Running section: A" + byd_log_str_sep
+            elif data[2] == 1:
+              s1 = s1 + "Running section: B" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+            s2 = f"{data[3]:d}" + "." + f"{data[4]:d}"
+            s1 = s1 + "FW version:V" + s2 + byd_log_str_sep
+          
+        elif ld[byd_log_codex] == 2:                                                               # Events record (2)
+          if bmu == True:
+            if data[0] == 0:
+              s1 = s1 + "disappear" + byd_log_str_sep
+            else:
+              s1 = s1 + "appear" + byd_log_str_sep
+            if data[1] < len(byd_log_bmu_errors):
+              s2 = byd_log_bmu_errors[data[1]]
+              if len(s2) > 0:
+                s1 = s1 + s2 + byd_log_str_sep
+            else:
+              s1 = s1 + byd_log_bmu_errors[len(byd_log_bmu_errors)-1] + byd_log_str_sep
+            x = int(data[2] * 0x100 + data[3])
+            if x == 0:
+              s1 = s1 + "No warning" + byd_log_str_sep
+            else:
+              s2 = ""
+              for i in range(0,16):  # 0..15
+                if (int(x) % 2) == 1:
+                  if len(s2) > 0:
+                    s2 = s2 + ";"
+                  s2 = s2 + byd_log_bmu_warnings[i]
+                x = int(x / 2)
+              s1 = s1 + s2 + byd_log_str_sep
+            x = self.buf2int16US(data,4)
+            s1 = s1 + "Cell_Max._V:" + f"{x:d}" + "mV" + byd_log_str_sep
+            x = self.buf2int16US(data,6)
+            s1 = s1 + "Cell_Min._V:" + f"{x:d}" + "mV" + byd_log_str_sep
+            x = data[8]
+            s1 = s1 + "Battery_Temp_Max:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = data[9]
+            s1 = s1 + "Battery_Temp_Min:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = self.buf2int16US(data,10)  / 10.0
+            s1 = s1 + "Battery Total Voltage:" + f"{x:.1f}" + "V" + byd_log_str_sep
+            x = data[12]
+            s1 = s1 + "SOC:" + f"{x:d}" + "%" + byd_log_str_sep
+            x = data[13]
+            s1 = s1 + "SOH:" + f"{x:d}" + "%" + byd_log_str_sep
+          else:
+            s1 = self.logdatabms2str(ld,False) 
+
+        elif ld[byd_log_codex] == 3:                                                               # Timing Record (3)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False)
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 4:                                                               # Start Charging(4)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 5:                                                               # Stop Charging(5)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 6:                                                               # Start DisCharging (6)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 7:                                                               # Stop DisCharging (7)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 8:                                                               # SOC calibration rough (8)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 9:                                                               # SOC calibration fine (8)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 10:                                                              # SOC calibration Stop (10)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 13:                                                              # Receive PreCharge Command (13)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 14:                                                              # PreCharge Successful (14)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 16:                                                              # Start end SOC calibration (16)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 17:                                                              # Start Balancing (17)
+          if bmu == False:
+            s2 = ""
+            nn = 0
+            ci = 0
+            for i in range(0,20):  # 0..19
+              x = int(data[i])
+              for ii in range(0,8):  # 0..7
+                if (int(x) % 2) == 1:
+                  if len(s2) > 0:
+                    s2 = s2 + ";"
+                  s2 = s2 + str(ci)
+                  nn = nn + 1
+                x = int(x / 2)
+                ci = ci + 1
+            if len(s2) > 0:
+              s1 = s1 + "Balancing Cells:#=" + str(nn) + "[" + s2 + "]" + byd_log_str_sep
+            x = self.buf2int16USx(data,21)
+            s1 = s1 + "Cell_Min_V:" + f"{x:d}" + "mV" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 18:                                                              # Stop Balancing (18)
+          if bmu == False:
+            x = self.buf2int16USx(data,21)
+            s1 = s1 + "Cell_Min_V:" + f"{x:d}" + "mV" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 19:                                                              # Address Registered (19)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 20:                                                              # System Functional Safety Fault (20)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,False) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 21:                                                              # Events additional info (21)
+          if bmu == False:
+            s1 = self.logdatabms2str(ld,True) 
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+        
+        elif ld[byd_log_codex] == 32:                                                              # System status changed (32)
+          if bmu == True:
+            if data[1] < len(byd_log_status):
+              s1 = s1 + byd_log_status[data[1]] + " => "
+            else:
+              s1 = s1 + byd_log_status[len(byd_log_status)-1] + " => "
+            if data[0] < len(byd_log_status):
+              s1 = s1 + byd_log_status[data[0]]
+            else:
+              s1 = s1 + byd_log_status[len(byd_log_status)-1]
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+
+        elif ld[byd_log_codex] == 34:                                                              # BMS update start (34)
+          if bmu == True:
+            s1 = s1 + "FW Version:V" + f"{data[1]:d}" + "." + f"{data[2]:d}" + byd_log_str_sep
+            s1 = s1 + "MCU Type:" + f"{data[4]:d}" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+
+        elif ld[byd_log_codex] == 35:                                                              # BMS update start (35)
+          if bmu == True:
+            s1 = s1 + "FW Version:V" + f"{data[1]:d}" + "." + f"{data[2]:d}" + byd_log_str_sep
+            s1 = s1 + "MCU Type:" + f"{data[4]:d}" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+
+        elif ld[byd_log_codex] == 36:                                                              # Functional Safety Info (36)
+          if bmu == True:
+            x = data[0] * 0x01000000 + data[1] * 0x00010000 + data[2] * 0x00000100 + data[3]
+            s1 = s1 + "Running Time:" + f"{x:d}" + "s" + byd_log_str_sep
+            x = data[4]
+            s1 = s1 + "BMU detected Cells Qty:" + f"{x:d}" + byd_log_str_sep
+            x = data[5]
+            s1 = s1 + "BMU detected Temp. Qty:" + f"{x:d}" + byd_log_str_sep
+            x = self.buf2int16US(data,6)
+            s1 = s1 + "BMU detected Cell_V_Max:" + f"{x:d}" + "mV" + byd_log_str_sep
+            x = self.buf2int16US(data,8)
+            s1 = s1 + "BMU detected Cell_V_Min:" + f"{x:d}" + "mV" + byd_log_str_sep
+            x = data[10]
+            s1 = s1 + "BMU detected Temp_Max:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = data[11]
+            s1 = s1 + "BMU detected Temp_Min:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = self.buf2int16US(data,12)  / 10.0
+            s1 = s1 + "BMU detected Current:" + f"{x:.1f}" + "A" + byd_log_str_sep
+            x = self.buf2int16US(data,14)  / 10.0
+            s1 = s1 + "BMU detected Output_V:" + f"{x:.1f}" + "V" + byd_log_str_sep
+            x = self.buf2int16US(data,16)  / 10.0
+            s1 = s1 + "BMU detected All Cells Accum_V:" + f"{x:.1f}" + "V" + byd_log_str_sep
+            x = data[18]
+            s1 = s1 + "BMS Address:" + f"{x:d}" + byd_log_str_sep
+            if data[19] < len(byd_module_type):
+              s1 = s1 + "Module type:" + byd_module_type[data[19]] + byd_log_str_sep
+            else:
+              s1 = s1 + "Module type:" + byd_module_type[len(byd_module_type)-1] + byd_log_str_sep
+            x = data[20]
+            s1 = s1 + "Module Number:" + f"{x:d}" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+          
+        elif ld[byd_log_codex] == 37:                                                              # No Defined (37)
+          if bmu == True:
+            s1 = s1 + "not defined - " + bytearray(ld[byd_log_data]).hex()
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+          
+        elif ld[byd_log_codex] == 38:                                                              # SOP Info
+          if bmu == True:
+            x = self.buf2int16US(data,0)  / 10.0
+            s1 = s1 + "Charge Max. Current:" + f"{x:.1f}" + "A" + byd_log_str_sep
+            x = self.buf2int16US(data,2)  / 10.0
+            s1 = s1 + "Discharge Max. Current:" + f"{x:.1f}" + "A" + byd_log_str_sep
+            x = self.buf2int16US(data,4)  / 10.0
+            s1 = s1 + "Charge Max. Voltage:" + f"{x:.1f}" + "V" + byd_log_str_sep
+            x = self.buf2int16US(data,6)  / 10.0
+            s1 = s1 + "Discharge Min. Voltage:" + f"{x:.1f}" + "V" + byd_log_str_sep
+            if data[8] < len(byd_log_status):
+              s1 = s1 + byd_log_status[data[8]] + byd_log_str_sep
+            else:
+              s1 = s1 + byd_log_status[len(byd_log_status)-1] + byd_log_str_sep
+            x = data[9]
+            s1 = s1 + "Battery Temperature:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            s1 = s1 + self.get_inverter_name(self.byd_batt_str,data[10])  + byd_log_str_sep
+            x = data[11]
+            s1 = s1 + "BMS Qty:" + f"{x:d}" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+
+        elif ld[byd_log_codex] == 40:                                                              # BMS Firmware list (40)
+          if bmu == True:
+            x = data[0]
+            s1 = s1 + "Firmware Num:" + f"{x:d}" + byd_log_str_sep
+            s1 = s1 + "FW Version:V" + f"{data[1]:d}" + "." + f"{data[2]:d}" + byd_log_str_sep
+            x = data[3]
+            s1 = s1 + "Firmware Num:" + f"{x:d}" + byd_log_str_sep
+            s1 = s1 + "FW Version:V" + f"{data[4]:d}" + "." + f"{data[5]:d}" + byd_log_str_sep
+            x = data[6]
+            if x != 0xFF:
+              s1 = s1 + "Firmware Num:" + f"{x:d}" + byd_log_str_sep
+              s1 = s1 + "FW Version:V" + f"{data[7]:d}" + "." + f"{data[8]:d}" + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+          
+        elif ld[byd_log_codex] == 41:                                                              # No Defined (41)
+          if bmu == True:
+            s1 = s1 + "not defined - " + bytearray(ld[byd_log_data]).hex()
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+
+        elif ld[byd_log_codex] == 101:                                                             # Firmware Start to Update (101)
+          if bmu == True:
+            x = data[0]
+            if x == 0:
+              s1 = s1 + "BMS A Updating" + byd_log_str_sep
+            else:
+              s1 = s1 + "BMS B Updating" + byd_log_str_sep
+            s1 = s1 + "Version:V" + f"{data[1]:d}" + "." + f"{data[2]:d}" + byd_log_str_sep
+          else:
+            x = data[0]
+            if x == 0:
+              s1 = s1 + "Target Area:A" + byd_log_str_sep
+            else:
+              s1 = s1 + "Target Area:B" + byd_log_str_sep
+            s1 = s1 + "Before Update:V" + f"{data[2]:d}" + "." + f"{data[1]:d}" + byd_log_str_sep
+            s1 = s1 + "After Update:V" + f"{data[4]:d}" + "." + f"{data[3]:d}" + byd_log_str_sep
+
+        elif ld[byd_log_codex] == 102:                                                             # Firmware Update Successful (102)
+          if bmu == True:
+            x = data[0]
+            if x == 0:
+              s1 = s1 + "BMS A Update Finish" + byd_log_str_sep
+            else:
+              s1 = s1 + "BMS B Update Finish" + byd_log_str_sep
+            s1 = s1 + "Version:V" + f"{data[1]:d}" + "." + f"{data[2]:d}" + byd_log_str_sep
+          else:
+            x = data[0]
+            if x == 0:
+              s1 = s1 + "Target Area:A" + byd_log_str_sep
+            else:
+              s1 = s1 + "Target Area:B" + byd_log_str_sep
+            s1 = s1 + "Before Update:V" + f"{data[2]:d}" + "." + f"{data[1]:d}" + byd_log_str_sep
+            s1 = s1 + "After Update:V" + f"{data[4]:d}" + "." + f"{data[3]:d}" + byd_log_str_sep
+
+        elif ld[byd_log_codex] == 105:                                                             # Parameters table Update (105)
+          if bmu == True:
+            if (data[0] == 0) or (data[0] == 1) or (data[0] == 2):
+              s1 = s1 + "BMU Parameters table Update" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+            s2 = f"{data[1]:d}" + "." + f"{data[2]:d}"
+            s1 = s1 + "Parameters table:V" + s2 + byd_log_str_sep
+          else:
+            x = self.buf2int16USx(data,0)
+            y = self.buf2int16USx(data,2)
+            s1 = s1 + "Threshold table version:V" + f"{x:d}" + "." + f"{y:d}" + byd_log_str_sep
+          
+        elif ld[byd_log_codex] == 106:                                                             # SN Code was Changed (106)
+          if bmu == True:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+          else:
+            s1 = s1 + "serial number was changed" + byd_log_str_sep
+          
+        elif ld[byd_log_codex] == 111:                                                             # DateTime Calibration (111)
+          if bmu == True:
+            if data[0] == 0:
+              s1 = s1 + "Calibrated by Upper computer" + byd_log_str_sep
+            elif data[0] == 1:
+              s1 = s1 + "Calibrated by Inverter" + byd_log_str_sep
+            elif data[0] == 2:
+              s1 = s1 + "Calibrated by Internet" + byd_log_str_sep
+            else:
+              s1 = s1 + "??" + byd_log_str_sep
+          else:
+            dtl = self.log_datetime_2_local(ld[byd_log_year],ld[byd_log_month],ld[byd_log_day],ld[byd_log_hour],ld[byd_log_minute],ld[byd_log_second],0)
+            dtc = self.log_datetime_2_local(data[0],data[1],data[2],data[3],data[4],data[5],0)
+            dtx = dtl - dtc
+            x = dtx.total_seconds()
+#            self.log_debug("x=" + f"{x:.3f}" + " seconds=" + f"{dtx.seconds:.3f}" + " us=" + f"{dtx.microseconds:.1f}" + " - " + dtl.strftime("%d.%m.%Y %H:%M:%S") + " - " + dtc.strftime("%d.%m.%Y %H:%M:%S"))
+            s1 = s1 + "New Time:" + dtc.strftime("%d.%m.%Y %H:%M:%S") + " Delta:" + f"{x:.1f}" + "s" + byd_log_str_sep
+
+        elif ld[byd_log_codex] == 118:                                                             # System timing log (118)
+          if bmu == True:
+            if data[0] < len(byd_log_status):
+              s1 = s1 + "System Status:" + byd_log_status[data[0]] + byd_log_str_sep
+            else:
+              s1 = s1 + "System Status:" + byd_log_status[len(byd_log_status)-1] + byd_log_str_sep
+            x = data[1]
+            s1 = s1 + "Environment_Temp_Min:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = data[2]
+            s1 = s1 + "Environment_Temp_Max:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = data[3]
+            s1 = s1 + "SOC:" + f"{x:.0f}" + "%" + byd_log_str_sep
+            x = data[4]
+            s1 = s1 + "SOH:" + f"{x:.0f}" + "%" + byd_log_str_sep
+            x = self.buf2int16US(data,6)  / 10.0
+            s1 = s1 + "Battery Total Voltage:" + f"{x:.1f}" + "V" + byd_log_str_sep
+            x = self.buf2int16US(data,8)
+            s1 = s1 + "Cell_HV:" + f"{x:d}" + "mV" + byd_log_str_sep
+            x = self.buf2int16US(data,10)
+            s1 = s1 + "Cell_LV:" + f"{x:d}" + "mV" + byd_log_str_sep
+            x = data[5]
+            s1 = s1 + "Battery Current_Temp:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = data[13]
+            s1 = s1 + "Battery_Temp_Max:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+            x = data[15]
+            s1 = s1 + "Battery_Temp_Min:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+          else:
+            s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+            unknown = True
+          
+        else:
+          s1 = "not implemented yet (" + bytearray(ld[byd_log_data]).hex() + ")"
+          unknown = True
+          
+        if unknown == True:
+          # Wir haben einen Log-Eintrag gefunden, den wir noch nicht decodieren
+          s1x = "logdata2str "
+          if bmu == True:
+            s1x = s1x + "BMU "
+          else:
+            s1x = s1x + "BMS Tower " + str(xx)
+          s1x = s1x + " : code " + str(ld[byd_log_codex]) + " (" + self.logcode2str(ld[byd_log_codex]) + ") not implemented yet"
+          s1x = s1x + " (" + f"{ld[byd_log_day]:02d}" + "." + f"{ld[byd_log_month]:02d}" + "." + f"{2000+ld[byd_log_year]:4d}"
+          s1x = s1x + " " + f"{ld[byd_log_hour]:2d}" + ":" + f"{ld[byd_log_minute]:02d}" + ":" + f"{ld[byd_log_second]:02d}" + ")"
+          s2 = "Raw Data: " + ld[byd_log_raw]
+          s3 = "Data: " + bytearray(ld[byd_log_data]).hex()
+          self.logging_special(s1x,s2,s3)
+    
+        return s1
+        
+    def logdatabms2str(self,ld,cnr):
+        # Erzeugt den String fuer den Standard-BMS-Log-Eintrag.
+        # ld = Log-Eintrag
+        # cnr = True  -> Byte 17-21 Zellennummern
+        #       False -> Byte 17-22 normale Bedeutung
+        co = ld[byd_log_codex]
+        data = ld[byd_log_data]
+        s1 = ""
+        # Warnungen (3x16Bit)
+        y1 = int(data[1] * 0x100 + data[0])
+        y2 = int(data[3] * 0x100 + data[2])
+        y3 = int(data[5] * 0x100 + data[4])
+        y = y1 | y2 | y3                      # Bitweise Oder
+        if y > 0:
+          s2 = "Warning:"
+          for i in range(0,16):  # 0..15
+            if (int(y) % 2) == 1:
+              if len(s2) > 0:
+                s2 = s2 + ";"
+              s2 = s2 + byd_log_bms_warnings[i]
+            y = int(y / 2)
+          s1 = s1 + s2 + byd_log_str_sep
+        else:
+          s1 = s1 + "No Warning" + byd_log_str_sep
+        # Fehler
+        y = int(data[7] * 0x100 + data[6])
+        if y > 0:
+          s2 = "Fault:"
+          for i in range(0,16):  # 0..15
+            if (int(y) % 2) == 1:
+              if len(s2) > 0:
+                s2 = s2 + ";"
+              s2 = s2 + byd_log_bms_failures[i]
+            y = int(y / 2)
+          s1 = s1 + s2 + byd_log_str_sep
+        # Status
+        y = int(data[8])
+        if y > 0:
+          s2 = ""
+          for i in range(0,8):  # 0..7
+            if (int(y) % 2) == 1:
+              if len(s2) > 0:
+                s2 = s2 + ";"
+              s2 = s2 + byd_log_bms_switch_status_on[i]
+            else:
+              if i == 3:
+                if len(s2) > 0:
+                  s2 = s2 + ";"
+                s2 = s2 + byd_log_bms_switch_status_off[i]
+            y = int(y / 2)
+          s1 = s1 + s2 + byd_log_str_sep
+        x = data[9]
+        if co == 9:
+          # Battery Idling
+          s1 = s1 + "Battery Idling:" + f"{x:d}" + "%" + byd_log_str_sep
+        elif co == 20:
+          # BMU serial port
+          s1 = s1 + "BMU serial port:V" + f"{x:d}" + byd_log_str_sep
+        else:
+          # SOC
+          s1 = s1 + "SOC:" + f"{x:d}" + "%" + byd_log_str_sep
+        x = data[10]
+        if co == 9:
+          # SOC
+          s1 = s1 + "Target SOC:" + f"{x:d}" + "%" + byd_log_str_sep
+        elif co == 20:
+          # BMS serial port
+          s1 = s1 + "BMS serial port:V" + f"{x:d}" + byd_log_str_sep
+        else:
+          # SOH
+          s1 = s1 + "SOH:" + f"{x:d}" + "%" + byd_log_str_sep
+        # Spannung Batterie
+        x = self.buf2int16USx(data,11)  / 10.0
+        s1 = s1 + "Bat_V:" + f"{x:.1f}" + "V" + byd_log_str_sep
+        # Spannung Ausgang
+        x = self.buf2int16USx(data,13)  / 10.0
+        s1 = s1 + "Output_V:" + f"{x:.1f}" + "V" + byd_log_str_sep
+        # Strom
+        x = self.buf2int16SIx(data,15)  / 10.0
+        s1 = s1 + "Current:" + f"{x:.1f}" + "A" + byd_log_str_sep
+        # Zellenspannung max
+        if cnr == False:
+          x = self.buf2int16USx(data,17)
+          s1 = s1 + "Cell_Max_V:" + f"{x:d}" + "mV" + byd_log_str_sep
+        else:
+          x = data[17]
+          s1 = s1 + "Cell_Max_V:No" + f"{x:d}" + byd_log_str_sep
+        # Zellenspannung min
+        if cnr == False:
+          x = self.buf2int16USx(data,19)
+          s1 = s1 + "Cell_Min_V:" + f"{x:d}" + "mV" + byd_log_str_sep
+        else:
+          x = data[18]
+          s1 = s1 + "Cell_Min_V:No" + f"{x:d}" + byd_log_str_sep
+        # Temperatur max
+        if cnr == False:
+          x = data[21]
+          s1 = s1 + "Cell_Max_T:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+        else:
+          x = data[19]
+          s1 = s1 + "Cell_Max_T:No" + f"{x:d}" + byd_log_str_sep
+        # Temperatur min
+        if cnr == False:
+          x = data[22]
+          s1 = s1 + "Cell_Min_T:" + f"{x:d}" + byd_log_degree + byd_log_str_sep
+        else:
+          x = data[20]
+          s1 = s1 + "Cell_Min_T:No" + f"{x:d}" + byd_log_str_sep
+        
+        return s1
+        
+    def log_create_html_json(self,bmu,x):
+        # Erstellt fuer eine Einheit die HTML-Tabelle fuer die Anzeige in smartVISU.
+        
+        # Beispiel einer JSON-Message fuer '' in smartVISU:
+        # myNewMessage = {"id":6498501,"title":"Geben Sie 4 g Chlor hinzu","message":"Ich empfehle Ihnen, Chlor zuzusetzen, um eine gute Desinfektion Ihres Wassers zu gew\u00e4hrleisten.","created_at":"2022-01-23T14:30:57+0000","updated_at":"2022-01-23T14:30:57+0000","status":"waiting","deadline":"2 022-01-29T00:00:00+0000"}
+
+        if bmu == True:
+          ld = self.byd_bmu_log
+        else:
+          ld = self.byd_diag_bms_log[x]
+        line_string = ""
+        json_list = []
+        for i in range(len(ld)):  # 0..len(ld)-1
+          dd = ld[i]
+          dt = self.log_datetime_2_local(dd[byd_log_year],dd[byd_log_month],dd[byd_log_day],dd[byd_log_hour],dd[byd_log_minute],dd[byd_log_second],0)
+          # HTML
+          line_string = line_string + ''
+          line_string = line_string + '' + dt.strftime("%d.%m.%Y") + ''
+          line_string = line_string + '' + dt.strftime("%H:%M:%S") + ''
+          line_string = line_string + '' + self.logcode2str(dd[byd_log_codex]) + ' (' + f"{dd[byd_log_codex]:d}" + ')' + ''
+          line_string = line_string + '' + dd[byd_log_str] + ''
+          line_string = line_string + ''
+          # JSON
+          jd = {"id":str(i),"title":self.logcode2str(dd[byd_log_codex]) + " (" + f"{dd[byd_log_codex]:d}" + ")",
+                "content":dd[byd_log_str],"level":"info","date":dt.strftime("%d.%m.%Y %H:%M:%S")}
+          json_list.append(jd)
+          if i == byd_log_max_rows:
+            break
+        html_string = '' + line_string + '
' + + if bmu == True: + old = self.byd_root.visu.bmu_log.log_html() + if html_string != old: + self.byd_root.visu.bmu_log.log_html(html_string) + self.byd_bmu_log_html = html_string + old = self.byd_root.visu.bmu_log.log_jsonlist() + if json_list != old: + self.byd_root.visu.bmu_log.log_jsonlist(json_list) + else: + self.byd_diag_bms_log_html[x] = html_string + if x == 1: + old = self.byd_root.visu.tower1_log.log_html() + if html_string != old: + self.byd_root.visu.tower1_log.log_html(html_string) + old = self.byd_root.visu.tower1_log.log_jsonlist() + if json_list != old: + self.byd_root.visu.tower1_log.log_jsonlist(json_list) + elif x == 2: + old = self.byd_root.visu.tower2_log.log_html() + if html_string != old: + self.byd_root.visu.tower2_log.log_html(html_string) + old = self.byd_root.visu.tower2_log.log_jsonlist() + if json_list != old: + self.byd_root.visu.tower2_log.log_jsonlist(json_list) + elif x == 3: + old = self.byd_root.visu.tower3_log.log_html() + if html_string != old: + self.byd_root.visu.tower3_log.log_html(html_string) + old = self.byd_root.visu.tower3_log.log_jsonlist() + if json_list != old: + self.byd_root.visu.tower3_log.log_jsonlist(json_list) + return + + def log_debug_list(self,bmu,x): + # Ausgabe der aktuellen Log-Daten im Debug-Modus. + if bmu == True: + ld = self.byd_bmu_log + else: + ld = self.byd_diag_bms_log[x] + for i in range(len(ld)): + dd = ld[i] + s1 = "+ " + f"{i:2d}" + s1 = s1 + " " + f"{dd[byd_log_year]:2d}" + "." + f"{dd[byd_log_month]:02d}" + "." + f"{dd[byd_log_day]:02d}" + s1 = s1 + " " + f"{dd[byd_log_hour]:2d}" + ":" + f"{dd[byd_log_minute]:02d}" + ":" + f"{dd[byd_log_second]:02d}" + s1 = s1 + " d=" + bytearray(dd[byd_log_data]).hex() + s1 = s1 + " c=" + f"{dd[byd_log_codex]:3d}" + " - " + self.logcode2str(dd[byd_log_codex]) + self.log_debug(s1) + + def log_datetime_2_local(self,y,m,d,h,mi,s,ms): + dt = datetime(2000+y,m,d,h,mi,s,ms) + dtx = dt.replace(tzinfo=ZoneInfo('UTC')) # make aware + dtxx = dtx.astimezone(ZoneInfo('localtime')) # convert + return dtxx + +# ----------------------------------------------------------------------- +# Speichern der Daten in den Items +# ----------------------------------------------------------------------- + + def basisdata_save(self,device): + # Speichert die Basisdaten in der sh-Struktur. + + self.log_debug("basisdata_save") + + device.state.current(self.byd_current) + device.state.power(self.byd_power) + device.state.power_charge(self.byd_power_charge) + device.state.power_discharge(self.byd_power_discharge) + device.state.soc(self.byd_soc) + device.state.soh(self.byd_soh) + device.state.tempbatt(self.byd_temp_bat) + device.state.tempmax(self.byd_temp_max) + device.state.tempmin(self.byd_temp_min) + device.state.voltbatt(self.byd_volt_bat) + device.state.voltdiff(self.byd_volt_diff) + device.state.voltmax(self.byd_volt_max) + device.state.voltmin(self.byd_volt_min) + device.state.voltout(self.byd_volt_out) + device.state.charge_total(self.byd_charge_total) + device.state.discharge_total(self.byd_discharge_total) + device.state.eta(self.byd_eta) + + device.system.bms(self.byd_bms) + device.system.bmu(self.byd_bmu) + device.system.bmubanka(self.byd_bmu_a) + device.system.bmubankb(self.byd_bmu_b) + device.system.batttype(self.byd_batt_str) + device.system.errornum(self.byd_error_nr) + device.system.errorstr(self.byd_error_str) + device.system.grid(self.byd_application) + device.system.invtype(self.byd_inv_str) + device.system.modules(self.byd_modules) + device.system.bmsqty(self.byd_bms_qty) + device.system.capacity_total(self.byd_capacity_total) + device.system.paramt(self.byd_param_t) + device.system.serial(self.byd_serial) + + self.last_homedata = self.now_str() # Speichert Zeitpunkt als String + device.info.last_state(self.last_homedata) + + return + + def diagdata_save(self,device): + # Speichert die Diagnosedaten in der sh-Struktur und erzeugt die Heatmaps. + + self.log_debug("diagdata_save") + + self.diagdata_save_one(device.diagnosis.tower1,1) + if self.byd_bms_qty > 1: + self.diagdata_save_one(device.diagnosis.tower2,2) + if self.byd_bms_qty > 2: + self.diagdata_save_one(device.diagnosis.tower3,3) + + self.last_diagdata = self.now_str() # Speichert Zeitpunkt als String + device.info.last_diag(self.last_diagdata) + + return + + def diagdata_save_one(self,device,x): + # Speichert alle Daten fuer einen Turm und erzeugt die Heatmaps. + + device.soc(self.byd_diag_soc[x]) + device.soh(self.byd_diag_soh[x]) + device.bat_voltag(self.byd_diag_bat_voltag[x]) + device.v_out(self.byd_diag_v_out[x]) + device.current(self.byd_diag_current[x]) + device.volt_diff(self.byd_diag_volt_diff[x]) + device.volt_max.volt(self.byd_diag_volt_max[x]) + device.volt_max.cell(self.byd_diag_volt_max_c[x]) + device.volt_min.volt(self.byd_diag_volt_min[x]) + device.volt_min.cell(self.byd_diag_volt_min_c[x]) + device.temp_max.temp(self.byd_diag_temp_max[x]) + device.temp_max.cell(self.byd_diag_temp_max_c[x]) + device.temp_min.temp(self.byd_diag_temp_min[x]) + device.temp_min.cell(self.byd_diag_temp_min_c[x]) + device.charge_total(self.byd_diag_charge_total[x]) + device.discharge_total(self.byd_diag_discharge_total[x]) + + self.byd_diag_balance_active[x] = False + if self.byd_diag_balance_number[x] > 0: + self.byd_diag_balance_active[x] = True + + device.balancing.active(self.byd_diag_balance_active[x]) + device.balancing.number(self.byd_diag_balance_number[x]) + + self.save_module_data(x,device.modules) + + # Status des Turms = Hex-Wert 'byd_diag_state' (2 Byte) + device.state.raw(self.byd_diag_state[x]) + if self.byd_diag_state[x] == 0: + s = "normal" + else: + # Fuege alle Bit-Texte in einem String zusammen + s = "" + xx = int() + xx = self.byd_diag_state[x] + for yy in range(0,16): # 0..15 + if (int(xx) & 1) == 1: + if len(s) != 0: + s = s + "," + s = s + byd_stat_tower[yy] # Enum mit den Bit-Texten + xx = xx / 2 + self.byd_diag_state_str[x] = s + device.state.str(s) +# self.log_debug("Status: " + s) + + self.diag_plot(x) + +# self.log_debug("Turm " + str(x)) +# for xx in range(0,self.byd_cells_n): +# self.log_debug("Volt " + str(xx+1) + " : " + str(self.byd_volt_cell[x][xx])) +# for xx in range(0,self.byd_temps_n): +# self.log_debug("Temp " + str(xx+1) + " : " + str(self.byd_temp_cell[x][xx])) + + return + + def save_module_data(self,xx,m): + # Speichert die Daten fuer die Module im Turm 'x' in 'm' ab. + + m.m1.v_min(self.byd_diag_module[xx][0][byd_module_vmin]) + m.m1.v_max(self.byd_diag_module[xx][0][byd_module_vmax]) + m.m1.v_av(self.byd_diag_module[xx][0][byd_module_vava]) + m.m1.v_diff(self.byd_diag_module[xx][0][byd_module_vdif]) + + m.m2.v_min(self.byd_diag_module[xx][1][byd_module_vmin]) + m.m2.v_max(self.byd_diag_module[xx][1][byd_module_vmax]) + m.m2.v_av(self.byd_diag_module[xx][1][byd_module_vava]) + m.m2.v_diff(self.byd_diag_module[xx][1][byd_module_vdif]) + + m.m3.v_min(self.byd_diag_module[xx][2][byd_module_vmin]) + m.m3.v_max(self.byd_diag_module[xx][2][byd_module_vmax]) + m.m3.v_av(self.byd_diag_module[xx][2][byd_module_vava]) + m.m3.v_diff(self.byd_diag_module[xx][2][byd_module_vdif]) + + m.m4.v_min(self.byd_diag_module[xx][3][byd_module_vmin]) + m.m4.v_max(self.byd_diag_module[xx][3][byd_module_vmax]) + m.m4.v_av(self.byd_diag_module[xx][3][byd_module_vava]) + m.m4.v_diff(self.byd_diag_module[xx][3][byd_module_vdif]) + + m.m5.v_min(self.byd_diag_module[xx][4][byd_module_vmin]) + m.m5.v_max(self.byd_diag_module[xx][4][byd_module_vmax]) + m.m5.v_av(self.byd_diag_module[xx][4][byd_module_vava]) + m.m5.v_diff(self.byd_diag_module[xx][4][byd_module_vdif]) + + m.m6.v_min(self.byd_diag_module[xx][5][byd_module_vmin]) + m.m6.v_max(self.byd_diag_module[xx][5][byd_module_vmax]) + m.m6.v_av(self.byd_diag_module[xx][5][byd_module_vava]) + m.m6.v_diff(self.byd_diag_module[xx][5][byd_module_vdif]) + + m.m7.v_min(self.byd_diag_module[xx][6][byd_module_vmin]) + m.m7.v_max(self.byd_diag_module[xx][6][byd_module_vmax]) + m.m7.v_av(self.byd_diag_module[xx][6][byd_module_vava]) + m.m7.v_diff(self.byd_diag_module[xx][6][byd_module_vdif]) + + m.m8.v_min(self.byd_diag_module[xx][7][byd_module_vmin]) + m.m8.v_max(self.byd_diag_module[xx][7][byd_module_vmax]) + m.m8.v_av(self.byd_diag_module[xx][7][byd_module_vava]) + m.m8.v_diff(self.byd_diag_module[xx][7][byd_module_vdif]) + + return + +# ----------------------------------------------------------------------- +# Generieren der Bilder +# ----------------------------------------------------------------------- + + def diag_plot(self,x): + # Erstellt die Plots fuer Turm 'x'. + + # Saeulen-Grafik ---------------------------------------------------------------- + +# # Simulationsdaten fuer Pruefung des Plots !!!!!!!!!!!!!!! +# # - Max. Anzahl Module: HVS=2-5, HVM=3-8, HVL=3-8, LVS=1-8 +# old_n = self.byd_volt_n +# old_m = self.byd_modules +# old_x = self.byd_cells_n +# old_v = self.byd_volt_cell +# old_b = self.byd_balance_cell +# twr = 1 +# self.byd_volt_n = 8 +# self.byd_modules = 7 +# self.byd_cells_n = self.byd_volt_n * self.byd_modules +# for xx in range(0,self.byd_cells_n): +# self.byd_volt_cell[twr][xx] = round(random.uniform(3.08,3.27),2) +# self.byd_balance_cell[twr][xx] = random.randint(0,1) + + # Daten zusammenstellen + xx = np.arange(self.byd_modules) # X-Achse -> alle Module + yy = [] + for ii in range(0,self.byd_volt_n): # alle Zellen eines Moduls + zz = [] + for jj in range(0,self.byd_modules): + v = self.byd_volt_cell[x][(jj*self.byd_volt_n)+ii] + zz.append(v) + yy.append(zz) + # Min/Max bestimmen + yminl = [] + ymaxl = [] + f = True + for jj in range(0,self.byd_modules): + f1 = True + for ii in range(0,self.byd_volt_n): + v = self.byd_volt_cell[x][(jj*self.byd_volt_n)+ii] + if f == True: + ymin = v + ymax = v + f = False + else: + if v < ymin: + ymin = v + elif v > ymax: + ymax = v + if f1 == True: + ymin1 = v + ymax1 = v + ymini = ii + ymaxi = ii + f1 = False + else: + if v < ymin1: + ymin1 = v + ymini = ii + elif v > ymax1: + ymax1 = v + ymaxi = ii + yminl.append(ymini) + ymaxl.append(ymaxi) + # Balancing-Daten extrahieren + ba = [] + balance_n = 0 + for ii in range(0,self.byd_volt_n): # alle Zellen eines Moduls + zz = [] + for jj in range(0,self.byd_modules): + b = self.byd_balance_cell[x][(jj*self.byd_volt_n)+ii] * ymin * 0.999 + if b > 0: + balance_n = balance_n + 1 + zz.append(b) + ba.append(zz) + nn = [] + # Modulnamen fuer X-Achse + for jj in range(0,self.byd_modules): + nn.append("M"+str(jj+1)) + # Daten fuer Titel zusammensetzen + delta = (ymax - ymin) * 1000.0 + title_data = " (SOC=" + f"{self.byd_diag_soc[x]:.1f}" + "% min=" + f"{ymin:.3f}" + "V max=" + f"{ymax:.3f}" + "V delta=" + f"{delta:.0f}" + "mV)" + + # Berechne bestimmte Parameter fuer die optimale Darstellung + width = 1.0 / (self.byd_volt_n + 1) + ddd = width + y1 = self.round_decimal(ymax,Decimal('0.05')) + if y1 < ymax: + y1 = y1 + 0.05 + y0 = self.round_decimal(ymin,Decimal('0.05')) + if (y1 - y0) < 0.1: + yyy = (y0 + y1) / 2 + y0 = yyy - 0.05 + y1 = yyy + 0.05 + + fig,ax = plt.subplots(figsize=(10,4)) # Erzeugt ein Bitmap von 1000x400 Pixel + + x1 = -((self.byd_volt_n / 2) * ddd) + for ii in range(0,self.byd_volt_n): # alle Zellen eines Moduls + if (ii % 2) == 0: + col = '#ff0000' # 'rot' + else: + col = '#ff8c00' # 'orange' + b = plt.bar(xx+x1,yy[ii],width,color=col,zorder=3) + for jj in range(0,self.byd_modules): + if ii == yminl[jj]: + b[jj].set_color('#05b4ff') # 'blau' + elif ii == ymaxl[jj]: + b[jj].set_color('#c505ff') # 'violett' + if balance_n > 0: + plt.bar(xx+x1,ba[ii],width,color='#1cfc03',zorder=4) # 'giftgruen' + x1 = x1 + ddd + + plt.ylim(y0,y1) + plt.xticks(xx,nn) + plt.ylabel("Volt [V]") + plt.grid(axis='y',color='#999999',linestyle='dashed',zorder=0) + ax.tick_params(axis='x',colors='white') + ax.tick_params(axis='y',colors='white') + ax.yaxis.label.set_color('white') + ax.spines['bottom'].set_color('white') + ax.spines['top'].set_color('white') + ax.spines['right'].set_color('white') + ax.spines['left'].set_color('white') + ax.set_title("Turm " + str(x) + " - Spannungen [V]" + " (" + self.now_str() + ")" + title_data,size=10,color='white') + if balance_n > 0: + n_col = 3 + custom_lines = [Line2D([0], [0],color='#05b4ff',lw=4), + Line2D([0], [0],color='#c505ff',lw=4), + Line2D([0], [0],color='#1cfc03',lw=4)] + legend_text = ['Min','Max','Balancing'] + else: + n_col = 2 + custom_lines = [Line2D([0], [0],color='#05b4ff',lw=4), + Line2D([0], [0],color='#c505ff',lw=4)] + legend_text = ['Min','Max'] + ax.legend(custom_lines,legend_text,fancybox=True,framealpha=0.0,labelcolor='white',fontsize=9,ncol=n_col) + + fig.tight_layout() + if len(self.bpath) != byd_path_empty: + fig.savefig(self.bpath + byd_fname_volt2 + str(x) + byd_fname_ext,format='png',transparent=True) + self.log_debug("save " + self.bpath + byd_fname_volt2 + str(x) + byd_fname_ext) + fig.savefig(self.get_plugin_dir() + byd_webif_img + byd_fname_volt2 + str(x) + byd_fname_ext,format='png',transparent=True) + self.log_debug("save " + self.get_plugin_dir() + byd_webif_img + byd_fname_volt2 + str(x) + byd_fname_ext) + plt.close('all') + +# # Simulationsdaten fuer Pruefung des Plots !!!!!!!!!!!!!!! +# self.byd_volt_n = old_n +# self.byd_modules = old_m +# self.byd_cells_n = old_x +# self.byd_volt_cell = old_v +# self.byd_balance_cell = old_b + + # Heatmap der Spannungen -------------------------------------------------------- + if self.byd_volt_n == byd_no_of_col_7: + no_of_col = byd_no_of_col_7 + else: + no_of_col = byd_no_of_col_8 + i = int() + j = int() + i = 0 + j = 1 + rows = self.byd_cells_n // no_of_col # Anzahl Zeilen bestimmen + d = [] + rt = [] + for r in range(0,rows): # 0..rows-1 + c = [] + for cc in range(0,no_of_col): # 0..no_of_col-1 + c.append(self.byd_volt_cell[x][i]) + i = i + 1 + d.append(c) + rt.append("M" + str(j)) + if ((r + 1) % (self.byd_volt_n // no_of_col)) == 0: + j = j + 1 + dd = np.array(d) + + fig,ax = plt.subplots(figsize=(10,4)) # Erzeugt ein Bitmap von 1000x400 Pixel + + im = ax.imshow(dd) # Befehl fuer Heatmap + cbar = ax.figure.colorbar(im,ax=ax,shrink=0.5) + cbar.ax.yaxis.set_tick_params(color='white') + cbar.outline.set_edgecolor('white') + plt.setp(plt.getp(cbar.ax.axes,'yticklabels'),color='white') + + ax.set_aspect(0.25) + ax.get_xaxis().set_visible(False) + ax.set_yticks(np.arange(len(rt)),labels=rt) + + ax.spines[:].set_visible(False) + ax.set_xticks(np.arange(dd.shape[1] + 1) - 0.5,minor=True) + ax.set_yticks(np.arange(dd.shape[0] + 1) - 0.5,minor=True) + ax.tick_params(which='minor',bottom=False,left=False) + ax.tick_params(axis='y',colors='white',labelsize=10) + + textcolors = ("white","black") + threshold = im.norm(dd.max()) / 2.0 + kw = dict(horizontalalignment="center",verticalalignment="center",size=9) + valfmt = matplotlib.ticker.StrMethodFormatter("{x:.3f}") + + # Loop over data dimensions and create text annotations including colored frame around balancing cells. + k = 0 + for i in range(0,rows): # 0..rows-1 (Zeilen) + for j in range(0,no_of_col): # 0..no_of_col-1 (Spalten) + kw.update(color=textcolors[int(im.norm(dd[i,j]) > threshold)]) + text = ax.text(j,i,valfmt(dd[i,j],None),**kw) + if self.byd_balance_cell[x][k] > 0: + ax.add_patch(patches.Rectangle((-0.5+j,-0.5+i),1,1,edgecolor='red',fill=False,lw=2)) + k = k + 1 + + ax.set_title("Turm " + str(x) + " - Spannungen [V]" + " (" + self.now_str() + ")" + title_data,size=10,color='white') + + fig.tight_layout() + if len(self.bpath) != byd_path_empty: + fig.savefig(self.bpath + byd_fname_volt + str(x) + byd_fname_ext,format='png',transparent=True) +# self.log_debug("save " + self.bpath + byd_fname_temp + str(x) + byd_fname_ext) + fig.savefig(self.get_plugin_dir() + byd_webif_img + byd_fname_volt + str(x) + byd_fname_ext,format='png',transparent=True) +# self.log_debug("save " + self.get_plugin_dir() + byd_webif_img + byd_fname_temp + str(x) + byd_fname_ext) + plt.close('all') + + # Heatmap der Temperaturen ------------------------------------------------------ + if self.byd_temps_n == 0: + return + if self.byd_temp_n == byd_no_of_col_8: + no_of_col = byd_no_of_col_8 + else: + no_of_col = byd_no_of_col_12 + rows = self.byd_temps_n // no_of_col + i = 0 + j = 1 + d = [] + rt = [] + for r in range(0,rows): + c = [] + for cc in range(0,no_of_col): + c.append(self.byd_temp_cell[x][i]) + i = i + 1 + d.append(c) + rt.append("M" + str(j)) + if ((r + 1) % (self.byd_temp_n // no_of_col)) == 0: + j = j + 1 + dd = np.array(d) + cmap = matplotlib.colors.LinearSegmentedColormap.from_list('',['#f5f242','#ffaf38','#fc270f']) + norm = matplotlib.colors.TwoSlopeNorm(vcenter=dd.min() + (dd.max() - dd.min()) / 2,vmin=dd.min(),vmax=dd.max()) + + fig,ax = plt.subplots(figsize=(10,2.5)) # Erzeugt ein Bitmap von 1000x250 Pixel + + im = ax.imshow(dd,cmap=cmap,norm=norm) + cbar = ax.figure.colorbar(im,ax=ax,shrink=0.5) + cbar.ax.yaxis.set_tick_params(color='white') + cbar.outline.set_edgecolor('white') + plt.setp(plt.getp(cbar.ax.axes,'yticklabels'),color='white') + + ax.set_aspect(0.28) + ax.get_xaxis().set_visible(False) + ax.set_yticks(np.arange(len(rt)),labels=rt) + + ax.spines[:].set_visible(False) + ax.set_xticks(np.arange(dd.shape[1] + 1) - .5,minor=True) + ax.set_yticks(np.arange(dd.shape[0] + 1) - .5,minor=True) + ax.tick_params(which='minor',bottom=False,left=False) + ax.tick_params(axis='y',colors='white',labelsize=10) + + textcolors = ("black","white") + threshold = im.norm(dd.max()) / 2. + kw = dict(horizontalalignment="center",verticalalignment="center",size=9) + valfmt = matplotlib.ticker.StrMethodFormatter("{x:.0f}") + + # Loop over data dimensions and create text annotations. + for i in range(0,rows): + for j in range(0,no_of_col): + kw.update(color=textcolors[int(im.norm(dd[i,j]) > threshold)]) + text = ax.text(j,i,valfmt(dd[i,j], None),**kw) + + ax.set_title("Turm " + str(x) + " - Temperaturen [°C]" + " (" + self.now_str() + ")",size=10,color='white') + + fig.tight_layout() + if len(self.bpath) != byd_path_empty: + fig.savefig(self.bpath + byd_fname_temp + str(x) + byd_fname_ext,format='png',transparent=True) +# self.log_debug("save " + self.bpath + byd_fname_temp + str(x) + byd_fname_ext) + fig.savefig(self.get_plugin_dir() + byd_webif_img + byd_fname_temp + str(x) + byd_fname_ext, + format='png',transparent=True) +# self.log_debug("save " + self.get_plugin_dir() + byd_webif_img + byd_fname_temp + str(x) + byd_fname_ext) + plt.close('all') + + return + + def plt_file_del_single(self,fn,dummy): + # Loescht eine vorhandene Datei 'fn' und erstellt eine leere Datei. + if os.path.exists(fn) == True: + os.remove(fn) + if dummy == True: + self.create_dummy_png(fn) + return + + def plt_file_del(self): + # Loescht alle Plot-Dateien + + # Spannungs-Plots + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_volt + str(1) + byd_fname_ext,True) + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_volt + str(2) + byd_fname_ext,True) + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_volt + str(3) + byd_fname_ext,True) + + if len(self.bpath) != byd_path_empty: + self.plt_file_del_single(self.bpath + byd_fname_volt + str(1) + byd_fname_ext,False) + self.plt_file_del_single(self.bpath + byd_fname_volt + str(2) + byd_fname_ext,False) + self.plt_file_del_single(self.bpath + byd_fname_volt + str(3) + byd_fname_ext,False) + + # Spannungs-Plots + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_volt2 + str(1) + byd_fname_ext,True) + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_volt2 + str(2) + byd_fname_ext,True) + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_volt2 + str(3) + byd_fname_ext,True) + + if len(self.bpath) != byd_path_empty: + self.plt_file_del_single(self.bpath + byd_fname_volt2 + str(1) + byd_fname_ext,False) + self.plt_file_del_single(self.bpath + byd_fname_volt2 + str(2) + byd_fname_ext,False) + self.plt_file_del_single(self.bpath + byd_fname_volt2 + str(3) + byd_fname_ext,False) + + # Temperatur-Plots + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_temp + str(1) + byd_fname_ext,True) + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_temp + str(2) + byd_fname_ext,True) + self.plt_file_del_single(self.get_plugin_dir() + byd_webif_img + byd_fname_temp + str(3) + byd_fname_ext,True) + + if len(self.bpath) != byd_path_empty: + self.plt_file_del_single(self.bpath + byd_fname_temp + str(1) + byd_fname_ext,False) + self.plt_file_del_single(self.bpath + byd_fname_temp + str(2) + byd_fname_ext,False) + self.plt_file_del_single(self.bpath + byd_fname_temp + str(3) + byd_fname_ext,False) + + return + + def create_dummy_png(self,fn): + fig,ax = plt.subplots(figsize=(10,0.1)) # Erzeugt ein Bitmap von 1000x10 Pixel + fig.savefig(fn,format='png',transparent=True) + plt.close('all') + return + +# ----------------------------------------------------------------------- +# Routinen fuer das Logging der Daten +# ----------------------------------------------------------------------- + + def create_logdirectory(self,base,log_directory): + # Erstellt das Verzeichnis 'log_directory' im Log-Verzeichnis von smarthomeNG. + if log_directory[0] != "/": + if base[-1] != "/": + base += "/" + log_directory = base + "var/log/" + log_directory + if not os.path.exists(log_directory): + os.makedirs(log_directory) + return log_directory + + def logging_update(self,bmu,x): + # Aktualisiert die aktuelle Logdatei. + # Fuer jeden Tag wird eine neue Log-Datei erstellt. + + # Dateiname erstellen und zugehoerige Log-Liste holen + tn = self.now() + fn = f"{tn.year-2000:2d}" + f"{tn.month:02d}" + f"{tn.day:02d}" + "_BYD_" + if bmu == True: + fn = fn + "BMU" + ld = self.byd_bmu_log + else: + fn = fn + "BMS_Tower_" + str(x) + ld = self.byd_diag_bms_log[x] + fn = self.log_dir + "/" + fn + "." + byd_log_extension +# self.log_debug("logging_update fn=" + fn) + + # Wir suchen den aeltesten Eintrag in der Liste 'ld' vom heutigen Tag + mii = -1 + for mi in range(len(ld)-1,-1,-1): # len(ld)-1 .. 0 + dd = ld[mi] + if (2000+dd[byd_log_year] == tn.year) and (dd[byd_log_month] == tn.month) and (dd[byd_log_day] == tn.day): + mii = mi + break + if mii == -1: + # In der aktuellen Liste gibt es noch keinen Eintrag vom heutigen Datum - wir warten ! + return + + if not os.path.exists(fn): + # Datei existiert noch nicht - erstellen mit Headerzeile + self.log_debug("logging_update file not exist yet => create (" + fn + ")") + f = open(fn,"wt",encoding='utf-8') + s1 = "Date/Time (local)" + byd_log_sep + "Code" + byd_log_sep + "Code Description" + byd_log_sep + "Data" + byd_log_sep + "Data Raw" + byd_log_newline + sx = [] + sx.append(s1) + f.writelines(sx) + f.close() + if bmu == True: + self.logging_del_old_files() + + # Datei oeffnen und alle Zeilen einlesen + fl = [] + with open(fn,"rt",encoding='utf-8') as f: + fl = f.readlines() + f.close() + + # Wir suchen diesen aeltesten Eintrag 'mii' in der Liste 'ld' in der Logdatei. + wl = [] + wl.append(fl[0]) # Titelzeile direkt uebernehmen + s1 = self.logging_create_str(ld[mii]) + if len(fl) > 1: + for fi in range(1,len(fl)): # 1..len(fl)-1 - ohne Titelzeile + # Durchlaufe alle Zeilen, beginnend mit der aeltesten Zeile (oben in der Datei) + if fl[fi] == s1: + # Wir haben den Eintrag im Log gefunden - hier brechen wir ab + break + else: + wl.append(fl[fi]) + + # Nun fuegen wir alle Eintraege aus 'ld' an die Datei hinzu + nn = 0 + for mi in range(mii,-1,-1): # mii .. 0 + # Durchlaufe die Eintrage im Speicher, beginnend mit der aeltesten Zeile (am Ende der Liste) + s1 = self.logging_create_str(ld[mi]) + wl.append(s1) + nn = nn + 1 + + if len(fl)-1 == nn: + # Anzahl Zeilen in der aktuellen Log unveraendert. + self.log_debug("logging_update rows not changed ! " + str(len(fl)-1) + "/" + str(nn)) + return + + # Schreibe die Log-Datei mit den neuen Daten + f = open(fn,"wt",encoding='utf-8') + f.writelines(wl) + f.close() + + def logging_create_str(self,dd): + dt = self.log_datetime_2_local(dd[byd_log_year],dd[byd_log_month],dd[byd_log_day],dd[byd_log_hour],dd[byd_log_minute],dd[byd_log_second],0) + s1 = dt.strftime("%d.%m.%Y %H:%M:%S") + byd_log_sep + f"{dd[byd_log_codex]:d}" + byd_log_sep + self.logcode2str(dd[byd_log_codex]) + byd_log_sep + s1 = s1 + dd[byd_log_str] + byd_log_sep + bytearray(dd[byd_log_data]).hex() + byd_log_newline + return s1 + + def logging_del_old_files(self): + # Alte Log-Dateien loeschen. + if self.log_age == 0: + return + tn = self.now() + files = os.listdir(self.log_dir) + ts = tn.replace(hour=0,minute=1,second=0) + tx = ts - timedelta(days=self.log_age) + tx = tx.replace(tzinfo=None) +# self.log_debug("ts=" + ts.strftime("%d.%m.%Y %H:%M:%S") + " tx=" + tx.strftime("%d.%m.%Y %H:%M:%S")) + for i in range(0,len(files)): + yy = int(files[i][0:2]) + mm = int(files[i][2:4]) + da = int(files[i][4:6]) + dt = datetime(2000+yy,mm,da,0,1,0,0) # Datum dieser Datei als 'datetime' + dx = tx - dt +# self.log_debug("i:" + str(i) + " -> " + files[i] + " / " + " y=" + str(yy) + " m=" + str(mm) + " d=" + str(da) + " - " + dt.strftime("%d.%m.%Y %H:%M:%S") + " dx=" + str(dx.total_seconds()) + " dx=" + str(dx.days)) + if dx.days > 0: + # Positiv = Datei zu alt, wird geloescht ! + os.remove(self.log_dir + "/" + files[i]) + self.log_info("Log file " + files[i] + " too old -> deleted") + + def logging_special(self,s1,s2,s3): + # Schreibt die Texte in die Spezial-Log-Datei. + fn = self.log_dir + "/" + byd_log_special + "." + byd_log_extension + if not os.path.exists(fn): + f = open(fn,"wt",encoding='utf-8') + sx = [] + sx.append("BYD_BAT Plugin - special notes" + byd_log_newline) + sx.append(byd_log_newline) + f.writelines(sx) + f.close() + + # Datei oeffnen und alle Zeilen einlesen. + fl = [] + with open(fn,"rt",encoding='utf-8') as f: + fl = f.readlines() + f.close() + + # Pruefe, ob dieser neue Eintrag schon im Log vorhanden ist. + s1 = s1 + byd_log_newline + s2 = s2 + byd_log_newline + s3 = s3 + byd_log_newline + if len(fl) >= 6: + for i in range(2,len(fl),5): + if (i+3) > len(fl)-1: + break + s1x = fl[i+1] + s2x = fl[i+2] + s3x = fl[i+3] + if (s1 == s1x) and (s2 == s2x) and (s3 == s3x): + # Eintrag ist schon vorhanden + self.log_debug("logging_special record exists ! " + s1) + return + + # Neuer Eintrag - an die Datei anfuegen. + s = [] + tn = self.now() + s.append(tn.strftime("%d.%m.%Y %H:%M:%S") + byd_log_newline) + s.append(s1) + s.append(s2) + s.append(s3) + s.append(byd_log_newline) + f = open(fn,"at",encoding='utf-8') + f.writelines(s) + f.close() + return + + def log_debug(self,s1): + self.logger.debug(s1) + + def log_info(self,s1): + self.logger.warning(s1) + +# ----------------------------------------------------------------------- +# Kommunikations-Routinen +# ----------------------------------------------------------------------- + + def read_reg(self,client,reg,xx): + # Liest ein Register (MODBUS/RTU) ein. + msg = "0103" + f"{reg:04x}" + "00" + f"{xx:02x}" +# self.log_debug("read_reg msg=" + msg) + msgb = bytes.fromhex(msg) + crc = self.modbus_crc(msgb) + ba = crc.to_bytes(2,byteorder='little') + msg = msg + f"{ba[0]:02x}" + f"{ba[1]:02x}" +# self.log_debug("read_reg msg=" + msg) + + client.send(bytes.fromhex(msg)) + client.settimeout(byd_timeout_1s) + + try: + data = client.recv(BUFFER_SIZE) + except: + self.log_info("read_reg 0x" + f"{reg:04x}" + " failed !") + return byd_error,0 + + v = data[3] * 0x100 + data[4] +# self.log_debug("read_reg : v=" + f"{v:04x}" + " - " + data.hex()) + return byd_ok,v + + def send_msg(self,client,msg,tout): + # Sendet die Nachricht 'msg' und holt die Antwort. + # Eingabe : client = Client fuer Senden/Empfangen + # msg = Nachricht zum Senden (String) + # tout = Timeout Warten auf Antwort [s] + # Ausgabe : () = result (byd_ok,byd_error) und data + client.send(bytes.fromhex(msg)) + client.settimeout(tout) + try: + data = client.recv(BUFFER_SIZE) + except: + return byd_error,0 + d = [] + for n in range(len(data)-2): # ohne CRC + d.append(data[n]) + crc = self.modbus_crc(d) + crcx = data[len(data)-1] * 0x100 + data[len(data)-2] + if crc != crcx: + self.log_info("send_msg recv crc not ok (" + f"{crc:04x}" + "/" + f"{crcx:04x}" + ")") + return byd_error,0 +# self.log_debug("send_msg crc=" + f"{crc:04x}" + " / " + f"{crcx:04x}" + " len=" + str(len(data))) + return byd_ok,data + + def modbus_crc(self,msg:str) -> int: + # Bestimmt den CRC-Wert der Nachricht 'msg'. + crc = 0xFFFF + for n in range(len(msg)): + crc ^= msg[n] + for i in range(8): + if crc & 1: + crc >>= 1 + crc ^= 0xA001 + else: + crc >>= 1 + return crc + +# ----------------------------------------------------------------------- +# Hilfsroutinen +# ----------------------------------------------------------------------- + + def buf2int16SI(self,byteArray,pos): # signed + try: + result = byteArray[pos] * 256 + byteArray[pos + 1] + except: + return 0 + if (result > 32768): + result -= 65536 + return result + + def buf2int16US(self,byteArray,pos): # unsigned + try: + result = byteArray[pos] * 256 + byteArray[pos + 1] + except: + return 0 + return result + + def buf2int16SIx(self,byteArray,pos): # signed + try: + result = byteArray[pos+1] * 256 + byteArray[pos] + except: + return 0 + if (result > 32768): + result -= 65536 + return result + + def buf2int16USx(self,byteArray,pos): # unsigned + try: + result = byteArray[pos+1] * 256 + byteArray[pos] + except: + return 0 + return result + + def buf2int32SI(self,byteArray,pos): # signed +# self.log_debug("buf2int32US 0=" + f"{byteArray[pos]:02x}" + " 1=" + f"{byteArray[pos+1]:02x}" + " 2=" + f"{byteArray[pos+2]:02x}" + " 3=" + f"{byteArray[pos+3]:02x}") + try: + result = byteArray[pos+2] * 0x01000000 + byteArray[pos+3] * 0x00010000 + byteArray[pos] * 0x00000100 + byteArray[pos+1] + except: + return 0 + if (result > 0x7FFFFFFF): + result -= 0x100000000 +# self.log_debug("buf2int32US r=" + str(result)) + return result + + def buf2int32US(self,byteArray,pos): # unsigned +# self.log_debug("buf2int32US 0=" + f"{byteArray[pos]:02x}" + " 1=" + f"{byteArray[pos+1]:02x}" + " 2=" + f"{byteArray[pos+2]:02x}" + " 3=" + f"{byteArray[pos+3]:02x}") + try: + result = byteArray[pos+2] * 0x01000000 + byteArray[pos+3] * 0x00010000 + byteArray[pos] * 0x00000100 + byteArray[pos+1] + except: + return 0 +# self.log_debug("buf2int32US r=" + str(result)) + return result + + def now_str(self): + return self.now().strftime("%d.%m.%Y, %H:%M:%S") + + def get_inverter_name(self,batt,type): + # Bestimmt den Namen des Wechselrichters. + # Das Mapping wurde Be_Connect (Hauptseite Setup) entnommen. + if batt == "LVS": # LVS + if type == 0: + return byd_inverters[0] # Fronius HV + elif (type == 1) or (type == 2): + return byd_inverters[1] # Goodwe HV/Viessmann HV + elif type == 3: + return byd_inverters[2] # KOSTAL HV + elif type == 4: + return byd_inverters[18] # Selectronic LV + elif type == 5: + return byd_inverters[3] # SMA SBS3.7/5.0/6.0 HV + elif type == 6: + return byd_inverters[19] # SMA LV + elif type == 7: + return byd_inverters[20] # Victron LV + elif type == 8: + return byd_inverters[30] # Suntech LV + elif type == 9: + return byd_inverters[4] # Sungrow HV + elif type == 10: + return byd_inverters[5] # KACO_HV + elif type == 11: + return byd_inverters[21] # Studer LV + elif type == 12: + return byd_inverters[28] # SolarEdge LV + elif type == 13: + return byd_inverters[6] # Ingeteam HV + elif batt == "HVL": # HVL + if type == 0: + return byd_inverters[1] + elif type == 1: + return byd_inverters[3] + elif type == 2: + return byd_inverters[8] + elif type == 3: + return byd_inverters[10] + elif type == 4: + return byd_inverters[17] + else: # HVM, HVS + if (type >= 0) and (type <= 16): + return byd_inverters[type] + return "unknown" + + def round_decimal(self,decimal_number,base,rounding=ROUND_DOWN): + """ + Round decimal number to the nearest base + : param decimal_number: decimal number to round to the nearest base + : type decimal_number: Decimal + : param base: rounding base, e.g. 5, Decimal('0.05') + : type base: int or Decimal + : param rounding: Decimal rounding type + : rtype: Decimal + """ +# return base * Decimal(decimal_number / base).quantize(1,rounding=rounding) + return float(base * (Decimal(decimal_number) / base).quantize(1,rounding=rounding)) + +# ----------------------------------------------------------------------- +# Webinterface +# ----------------------------------------------------------------------- + + # webinterface init method + def init_webinterface(self): + + """" + Initialize the web interface for this plugin + + This method is only needed if the plugin is implementing a web interface + """ + try: + self.mod_http = Modules.get_instance().get_module( + 'http') # try/except to handle running in a core version that does not support modules + except: + self.mod_http = None + if self.mod_http is None: + self.logger.error("Not initializing the web interface") + return False + + import sys + if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): + self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") + return False + + # set application configuration for cherrypy + webif_dir = self.path_join(self.get_plugin_dir(), 'webif') + config = { + '/': { + 'tools.staticdir.root': webif_dir, + }, + '/static': { + 'tools.staticdir.on': True, + 'tools.staticdir.dir': 'static' + } + } + + # Register the web interface as a cherrypy app + self.mod_http.register_webif(WebInterface(webif_dir,self), + self.get_shortname(), + config, + self.get_classname(), + self.get_instance_name(), + description='') + + return True + +# ----------------------------------------------------------------------- +# Simulations-Routinen +# ----------------------------------------------------------------------- + + def simulate_data(self): + # For internal tests only + +# simul = 1 # HVM + simul = 2 # HVS +# simul = 3 # LVS + + twr = 3 + + if simul == 1: + self.byd_batt_str = "HVM" + self.byd_modules = 7 + self.byd_capacity_module = 2.76 + self.byd_volt_n = 16 + self.byd_temp_n = 8 + elif simul == 2: + self.byd_batt_str = "HVS" + self.byd_modules = 5 + self.byd_capacity_module = 2.56 + self.byd_volt_n = 32 + self.byd_temp_n = 12 + elif simul == 3: + self.byd_batt_str = "LVS" + self.byd_modules = 3 + self.byd_capacity_module = 4.0 + self.byd_volt_n = 7 + self.byd_temp_n = 0 + + self.byd_cells_n = self.byd_modules * self.byd_volt_n + self.byd_temps_n = self.byd_modules * self.byd_temp_n + + for xx in range(0,self.byd_cells_n): + self.byd_volt_cell[twr][xx] = round(random.uniform(2.1,2.9),2) + self.byd_balance_cell[twr][xx] = random.randint(0,1) +# self.log_info("xx=" + str(xx) + " v=" + str(self.byd_volt_cell[1][xx])) + for xx in range(0,self.byd_temps_n): + self.byd_temp_cell[twr][xx] = round(random.uniform(20.0,28.0),2) +# self.log_info("xx=" + str(xx) + " v=" + str(self.byd_temp_cell[1][xx])) + + self.diag_plot(twr) + +# ----------------------------------------------------------------------- +# ENDE +# ----------------------------------------------------------------------- + \ No newline at end of file diff --git a/byd_bat/assets/base.png b/byd_bat/assets/base.png new file mode 100644 index 000000000..34c486f81 Binary files /dev/null and b/byd_bat/assets/base.png differ diff --git a/byd_bat/assets/diag.png b/byd_bat/assets/diag.png new file mode 100644 index 000000000..8a4eeec69 Binary files /dev/null and b/byd_bat/assets/diag.png differ diff --git a/byd_bat/assets/home.png b/byd_bat/assets/home.png new file mode 100644 index 000000000..aa1cb5cc7 Binary files /dev/null and b/byd_bat/assets/home.png differ diff --git a/byd_bat/assets/logdata.png b/byd_bat/assets/logdata.png new file mode 100644 index 000000000..e0f8363e6 Binary files /dev/null and b/byd_bat/assets/logdata.png differ diff --git a/byd_bat/assets/temp.png b/byd_bat/assets/temp.png new file mode 100644 index 000000000..0c27a8280 Binary files /dev/null and b/byd_bat/assets/temp.png differ diff --git a/byd_bat/assets/volt.png b/byd_bat/assets/volt.png new file mode 100644 index 000000000..c9a7bd440 Binary files /dev/null and b/byd_bat/assets/volt.png differ diff --git a/byd_bat/locale.yaml b/byd_bat/locale.yaml new file mode 100644 index 000000000..e8789cec3 --- /dev/null +++ b/byd_bat/locale.yaml @@ -0,0 +1,62 @@ +# translations for the web interface +plugin_translations: + # Translations for the plugin specially for the web interface + 'BYD Home': {'de': '=', 'en': 'BYD Home'} + 'BYD Diagnose': {'de': '=', 'en': 'BYD Diagnostics'} + 'BYD Spannungen': {'de': '=', 'en': 'BYD Voltages'} + 'BYD Temperaturen': {'de': '=', 'en': 'BYD Temperatures'} + 'BYD Log-Daten': {'de': '=', 'en': 'BYD Log data'} + + 'Verbindung': {'de': '=', 'en': 'Connection'} + + 'Laden': {'de': '=', 'en': 'Loading'} + 'Gesamtkapazität': {'de': '=', 'en': 'Total capacity'} + 'Batterieladung': {'de': '=', 'en': 'Battery charge'} + 'Ladeleistung': {'de': '=', 'en': 'Charging power'} + 'Entladeleistung': {'de': '=', 'en': 'Discharge power'} + 'Bilder-Pfad': {'de': '=', 'en': 'Image path'} + 'Basisdaten': {'de': '=', 'en': 'Basic values'} + 'Diagnosedaten': {'de': '=', 'en': 'Diagnostic values'} + + 'Leistung': {'de': '=', 'en': 'Power'} + 'Spannung Ausgang': {'de': '=', 'en': 'Voltage output'} + 'Strom Ausgang': {'de': '=', 'en': 'Current output'} + 'Spannung Batterie': {'de': '=', 'en': 'Voltage battery'} + 'Spannung Batteriezellen max': {'de': '=', 'en': 'Voltage battery cell max'} + 'Spannung Batteriezellen min': {'de': '=', 'en': 'Voltage battery cell min'} + 'Spannung Batteriezellen Differenz': {'de': '=', 'en': 'Voltage battery cell delta'} + 'Temperatur Batterie': {'de': '=', 'en': 'Temperature battery'} + 'Temperatur Batterie max': {'de': '=', 'en': 'Temperature battery max'} + 'Temperatur Batterie min': {'de': '=', 'en': 'Temperature battery min'} + + 'Wechselrichter': {'de': '=', 'en': 'Inverter'} + 'Batterietyp': {'de': '=', 'en': 'Battery type'} + 'Seriennummer': {'de': '=', 'en': 'Serial number'} + 'Türme': {'de': '=', 'en': 'Towers'} + 'Module pro Turm': {'de': '=', 'en': 'Modules per tower'} + 'Parameter': {'de': '=', 'en': 'Parameter'} + 'Fehler': {'de': '=', 'en': 'Error'} + + 'Turm': {'de': '=', 'en': 'Tower'} + 'Batteriespannung': {'de': '=', 'en': 'Batteryvoltage'} + 'Spannung Out': {'de': '=', 'en': 'Voltage Out'} + 'Strom': {'de': '=', 'en': 'Current'} + 'Spannung max (Zelle)': {'de': '=', 'en': 'Voltage max (cell)'} + 'Spannung min (Zelle)': {'de': '=', 'en': 'Voltage min (cell)'} + 'Spannung Differenz': {'de': '=', 'en': 'Voltage difference'} + 'Temperatur max (Zelle)': {'de': '=', 'en': 'Temperature max (cell)'} + 'Temperatur min (Zelle)': {'de': '=', 'en': 'Temperature min (cell)'} + 'Balancing Anzahl Zellen': {'de': '=', 'en': 'Balancing number of cells'} + 'Status': {'de': '=', 'en': 'State'} + 'Laden total': {'de': '=', 'en': 'Charge total'} + 'Entladen total': {'de': '=', 'en': 'Discharge total'} + 'Wirkungsgrad': {'de': '=', 'en': 'Efficiency'} + + 'Spannung minimal': {'de': '=', 'en': 'Voltage min'} + 'Spannung maximal': {'de': '=', 'en': 'Voltage max'} + 'Spannung Durchschnitt': {'de': '=', 'en': 'Voltage average'} + + # Alternative format for translations of longer texts: + 'Hier kommt der Inhalt des Webinterfaces hin.': + de: '=' + en: 'Here goes the content of the web interface.' diff --git a/byd_bat/plugin.yaml b/byd_bat/plugin.yaml new file mode 100644 index 000000000..157198948 --- /dev/null +++ b/byd_bat/plugin.yaml @@ -0,0 +1,1091 @@ +# Metadata for the plugin +plugin: + # Global plugin attributes + type: interface # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Plugin fuer die Anzeige von Daten von BYD Batterien' + en: 'Plugin to display data from BYD batteries' + maintainer: Matthias Manhart + tester: Matthias Manhart + state: develop # change to ready when done with development + keywords: byd battery +# documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1886748-support-thread-f%C3%BCr-das-byd-batterie-plugin + + version: 0.1.2 # Plugin version (must match the version specified in __init__.py) + sh_minversion: 1.9 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + py_minversion: 3.9 # minimum Python version to use for this plugin +# py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) + multi_instance: false # plugin supports multi instance + restartable: true + classname: byd_bat # class containing the plugin + +parameters: + + ip: + type: ip + default: '192.168.16.254' + description: + de: "IP-Adresse der BYD Batterie (Master)" + en: "IP address of the BYD battery (master)" + + imgpath: + type: str + default: '' + description: + de: "Pfad fuer Heatmap-Bilder (z.Bsp. fuer smartvisu)" + en: "Path for heatmap images (e.g. for smartvisu)" + + diag_cycle: + type: num + default: 300 + description: + de: "Abfrage-Zyklus fuer die Diagnosedaten (>=60s)" + en: "Query cycle for diagnostic data (>=60s)" + + log_data: + type: bool + default: false + description: + de: "Abfrage der Log-Daten" + en: "get the log data" + + log_age: + type: num + default: 365 + description: + de: "Aufbewahrungsdauer der Log-Dateien in Tagen (0=kein Löschen)" + en: "Retention period of the log files in days (0=no deletion)" + +item_attributes: + + byd_root: # only used internally - do not use for own items ! + type: bool + mandatory: true + description: + de: 'Root-Flag fuer das Plugin' + en: 'Root-Flag for plugin' + + byd_para: + type: bool + default: off + description: + de: 'Interner Parameter' + en: 'Internal parameter' + +item_structs: + + byd_struct: + + byd_root: true # Must stay here - used by the plugin internally - do not remove ! + + info: + + connection: # Shows connection status of plugin to battery + type: bool + initial_value: false + enforce_updates: true + visu_acl: ro + + last_state: # Date/time of last state update + type: str + visu_acl: ro + cache: true + + last_diag: # Date/time of last dignostics update + type: str + visu_acl: ro + cache: true + + last_log: # Date/time of last log data update + type: str + visu_acl: ro + cache: true + + enable_connection: # true -> communication enabled, false -> communication disabled (other software can access BYD unit) + type: bool + initial_value: true + enforce_updates: true + visu_acl: rw + byd_para: true + + state: # State of the BYD system (1 or more towers) + + current: # [A] Charge / Discharge Current + type: num + visu_acl: ro + database: init + + power: # [W] Power (+ discharge / - charge battery) + type: num + visu_acl: ro + database: init + + power_charge: # [W] Power charging + type: num + visu_acl: ro + database: init + + power_discharge: # [W] Power discharging + type: num + visu_acl: ro + database: init + + soc: # [%] SOC (State of Charge) + type: num + visu_acl: ro + database: init + + soh: # [%] SOH (State of Health) + type: num + visu_acl: ro + database: init + + tempbatt: # [°C] Battery Temperature + type: num + visu_acl: ro + database: init + + tempmax: # [°C] Max Cell Temp + type: num + visu_acl: ro + database: init + + tempmin: # [°C] Min Cell Temp + type: num + visu_acl: ro + database: init + + voltbatt: # [V] Battery Voltage + type: num + visu_acl: ro + database: init + + voltdiff: # [V] Max - Min Cell Voltage + type: num + visu_acl: ro + database: init + + voltmax: # [V] Max Cell Voltage + type: num + visu_acl: ro + database: init + + voltmin: # [V] Min Cell Voltage + type: num + visu_acl: ro + database: init + + voltout: # [V] Output Voltage + type: num + visu_acl: ro + database: init + + charge_total: # [kWh] Total charge + type: num + visu_acl: ro + database: init + + discharge_total: # [kWh] Total discharge + type: num + visu_acl: ro + database: init + + eta: # [%] ETA (Efficiency) + type: num + visu_acl: ro + database: init + + system: # Informations about the BYD system + + bms: # Firmware BMS Battery Management System + type: str + cache: true + visu_acl: ro + + bmu: # Firmware BMU Battery Management Unit + type: str + cache: true + visu_acl: ro + + bmubanka: # Firmware BMU-BankA + type: str + cache: true + visu_acl: ro + + bmubankb: # Firmware BMU-BankB + type: str + cache: true + visu_acl: ro + + batttype: # Battery Type + type: str + cache: true + visu_acl: ro + + errornum: # Error (numeric) + type: num + cache: true + visu_acl: ro + + errorstr: # Error (string) + type: str + cache: true + visu_acl: ro + + grid: # Parameter Table + type: str + cache: true + visu_acl: ro + + invtype: # Inverter Type + type: str + cache: true + visu_acl: ro + + modules: # Modules (count per tower) + type: num + cache: true + visu_acl: ro + + bmsqty: # Towers (count) + type: num + cache: true + visu_acl: ro + + capacity_total: # Total capacitiy (all modules) [kWh] + type: num + cache: true + visu_acl: ro + + paramt: # Firmware BMU (?) + type: str + cache: true + visu_acl: ro + + serial: # Serial number (master) + type: str + cache: true + visu_acl: ro + + diagnosis: + + tower1: + + soc: # [%] SOC + type: num + visu_acl: ro + database: init + + soh: # [%] SOH + type: num + visu_acl: ro + database: init + + bat_voltag: # Battery Voltage [V] + type: num + visu_acl: ro + database: init + + v_out: # Voltage Out [V] + type: num + visu_acl: ro + database: init + + current: # Current [A] (+ discharge / - charge battery) + type: num + visu_acl: ro + database: init + + volt_diff: # Voltage difference (Max-Min) [mV] + type: num + visu_acl: ro + database: init + + volt_max: + + volt: # max voltage [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of max voltage + type: num + visu_acl: ro + database: init + + volt_min: + + volt: # min voltage [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of min voltage + type: num + visu_acl: ro + database: init + + temp_max: + + temp: # max temperature [°C] + type: num + visu_acl: ro + database: init + + cell: # cell number of max temperature + type: num + visu_acl: ro + database: init + + temp_min: + + temp: # min temperature [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of min temperature + type: num + visu_acl: ro + database: init + + charge_total: # [kWh] Total charge + type: num + visu_acl: ro + database: init + + discharge_total: # [kWh] Total discharge + type: num + visu_acl: ro + database: init + + balancing: # Data about the balancing of this tower + + active: # true -> 1 or more cells balancing, false -> no cell balancing + type: bool + visu_acl: ro + database: init + + number: # number of cells currently balancing + type: num + visu_acl: ro + database: init + + state: # State of the tower + + raw: # State of the tower (16 Bit, see byd_stat_tower in __init__.py) + type: num + visu_acl: ro + database: init + + str: # State as text (english) + type: str + visu_acl: ro + + modules: # Data for each module of the tower + m1: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m2: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m3: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m4: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m5: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m6: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m7: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m8: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + + tower2: + + soc: # [%] SOC + type: num + visu_acl: ro + database: init + + soh: # [%] SOH + type: num + visu_acl: ro + database: init + + bat_voltag: # Battery Voltage [V] + type: num + visu_acl: ro + database: init + + v_out: # Voltage Out [V] + type: num + visu_acl: ro + database: init + + current: # Current [A] (+ discharge / - charge battery) + type: num + visu_acl: ro + database: init + + volt_diff: # Voltage difference (Max-Min) [mV] + type: num + visu_acl: ro + database: init + + volt_max: + + volt: # max voltage [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of max voltage + type: num + visu_acl: ro + database: init + + volt_min: + + volt: # min voltage [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of min voltage + type: num + visu_acl: ro + database: init + + temp_max: + + temp: # max temperature [°C] + type: num + visu_acl: ro + database: init + + cell: # cell number of max temperature + type: num + visu_acl: ro + database: init + + temp_min: + + temp: # min temperature [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of min temperature + type: num + visu_acl: ro + database: init + + charge_total: # [kWh] Total charge + type: num + visu_acl: ro + database: init + + discharge_total: # [kWh] Total discharge + type: num + visu_acl: ro + database: init + + balancing: # Data about the balancing of this tower + + active: # true -> 1 or more cells balancing, false -> no cell balancing + type: bool + visu_acl: ro + database: init + + number: # number of cells currently balancing + type: num + visu_acl: ro + database: init + + state: # State of the tower + + raw: # State of the tower (16 Bit, see byd_stat_tower in __init__.py) + type: num + visu_acl: ro + database: init + + str: # State as text (english) + type: str + visu_acl: ro + + modules: # Data for each module of the tower + m1: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m2: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m3: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m4: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m5: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m6: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m7: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m8: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + + tower3: + + soc: # [%] SOC + type: num + visu_acl: ro + database: init + + soh: # [%] SOH + type: num + visu_acl: ro + database: init + + bat_voltag: # Battery Voltage [V] + type: num + visu_acl: ro + database: init + + v_out: # Voltage Out [V] + type: num + visu_acl: ro + database: init + + current: # Current [A] (+ discharge / - charge battery) + type: num + visu_acl: ro + database: init + + volt_diff: # Voltage difference (Max-Min) [mV] + type: num + visu_acl: ro + database: init + + volt_max: + + volt: # max voltage [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of max voltage + type: num + visu_acl: ro + database: init + + volt_min: + + volt: # min voltage [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of min voltage + type: num + visu_acl: ro + database: init + + temp_max: + + temp: # max temperature [°C] + type: num + visu_acl: ro + database: init + + cell: # cell number of max temperature + type: num + visu_acl: ro + database: init + + temp_min: + + temp: # min temperature [V] + type: num + visu_acl: ro + database: init + + cell: # cell number of min temperature + type: num + visu_acl: ro + database: init + + charge_total: # [kWh] Total charge + type: num + visu_acl: ro + database: init + + discharge_total: # [kWh] Total discharge + type: num + visu_acl: ro + database: init + + balancing: # Data about the balancing of this tower + + active: # true -> 1 or more cells balancing, false -> no cell balancing + type: bool + visu_acl: ro + database: init + + number: # number of cells currently balancing + type: num + visu_acl: ro + database: init + + state: # State of the tower + + raw: # State of the tower (16 Bit, see byd_stat_tower in __init__.py) + type: num + visu_acl: ro + database: init + + str: # State as text (english) + type: str + visu_acl: ro + + modules: # Data for each module of the tower + m1: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m2: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m3: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m4: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m5: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m6: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m7: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + m8: + v_min: # min voltage [V] + type: num + visu_acl: ro + cache: true + v_max: # max voltage [V] + type: num + visu_acl: ro + cache: true + v_av: # average voltage [V] + type: num + visu_acl: ro + cache: true + v_diff: # voltage difference (min-max) [mV] + type: num + visu_acl: ro + cache: true + + visu: # Items for the visualisation (e.g. smartVISU) + + bmu_log: # Logd data from BMU + + log_html: # HTML table with Log-Data (smartVISU: basic.print) + + type: str + visu_acl: ro + cache: true + + log_jsonlist: # Json list with Log-Data (smartVISU: status.activelist) + + type: list + initial_value: [] + visu_acl: ro + cache: true + + tower1_log: # Log data frm BMS Tower 1 + + log_html: # HTML table with Log-Data (smartVISU: basic.print) + + type: str + visu_acl: ro + cache: true + + log_jsonlist: # Json list with Log-Data (smartVISU: status.activelist) + + type: list + initial_value: [] + visu_acl: ro + cache: true + + tower2_log: # Log data frm BMS Tower 2 + + log_html: # HTML table with Log-Data (smartVISU: basic.print) + + type: str + visu_acl: ro + cache: true + + log_jsonlist: # Json list with Log-Data (smartVISU: status.activelist) + + type: list + initial_value: [] + visu_acl: ro + cache: true + + tower3_log: # Log data frm BMS Tower 3 + + log_html: # HTML table with Log-Data (smartVISU: basic.print) + + type: str + visu_acl: ro + cache: true + + log_jsonlist: # Json list with Log-Data (smartVISU: status.activelist) + + type: list + initial_value: [] + visu_acl: ro + cache: true + +logic_parameters: NONE + +plugin_functions: NONE diff --git a/byd_bat/requirements.txt b/byd_bat/requirements.txt new file mode 100644 index 000000000..11c0a0a75 --- /dev/null +++ b/byd_bat/requirements.txt @@ -0,0 +1 @@ +matplotlib>=3.8.0 diff --git a/byd_bat/user_doc.rst b/byd_bat/user_doc.rst new file mode 100644 index 000000000..c7aec3220 --- /dev/null +++ b/byd_bat/user_doc.rst @@ -0,0 +1,117 @@ +.. index:: Plugins; byd_bat +.. index:: byd_bat + +======= +byd_bat +======= + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Mit Hilfe dieses Plugins können diverse Daten aus einem BYD Energiespeicher ausgelesen werden. Die Parameter entsprechen den Daten, die in der Software "Be_Connect_Plus" von BYD angezeigt werden. + +Es werden 1-3 Türme und die Batteriesysteme HVS, HVM und LVS unterstützt. + +Das Plugin benötigt nur ein Item mit der folgenden Deklaration: + +byd: + struct: byd_bat.byd_struct + +Alle verfügbaren Daten werden im Struct 'byd_struct' bereitgestellt. Diverse Parameter besitzen bereits die Eigenschaft 'database: init', so dass die Daten für die Visualisierung (z.Bsp. in smartVISU) bereitgestellt werden. + +Die Grunddaten des Systems werden alle 60 Sekunden aktualisiert. Die Diagnosedaten werden beim Start des Plugins und gemäss dem Parameter 'diag_cycle' abgerufen. + +Der BYD Energiespeicher akzeptiert nur 1 Verbindung gleichzeitig. Mit dem Item 'byd.enable_connection' kann die Verbindung pausiert werden. Im Web Interface ist dieses Item ebenfalls vorhanden. + +Die Log-Daten werden alle 300 Sekunden abgerufen, wenn der Parameter 'log_data' auf 'true' gesetzt ist. Die Logdaten werden in den Items 'visu/...' als HTML-Tabellen oder JSON-Daten bereitgestellt. Diese Items können in smartVISU wie folgt dargestellt werden: + +HTML-Tabelle: + + {{ basic.print('','byd.visu.bmu_log.log_html','html') }} + {{ basic.print('','byd.visu.tower1_log.log_html','html') }} + +JSON-Daten: + + {{ status.activelist('','byd.visu.bmu_log.log_jsonlist','title','date','content','level') }} + {{ status.activelist('','byd.visu.tower1_log.log_jsonlist','title','date','content','level') }} + +Zusätzlich werden die Log-Daten tageweise in Logdateien im Verzeichnis 'var/log/byd_logs' gespeichert. Der Parameter 'log_age' definiert, wie viele Tage die Logs gespeichert bleiben sollen. + +Das Plugin generiert aus den Spannungs- und Temperaturwerten für jeden Turm Plots als Bitmap-Dateien (in den folgenden Dateinamen ist X = Nummer des Turms [1-3]): + + * bydvtX.png : Spannungen Heatmap + * bydvbtX.png : Spannungen Balkendiagramm + * bydttX.png : Temperaturen Heatmap + +Diese Plots werden im Web Interface angezeigt. Zusätzlich können diese Bilder auch in ein weiteres Verzeichnis kopiert werden (z.Bsp. für smartVISU, siehe Parameter 'imgpath'). + +Die Änderungen im Plugin sind am Anfang der Datei '__init__.py' im Abschnitt 'History' dokumentiert. + +Anforderungen +============= + +Der BYD Energiespeicher muss mit dem LAN verbunden sind. Die IP-Adresse des BYD wird über DHCP zugewiesen und muss ermittelt werden. Diese IP-Adresse muss in der Plugin-Konfiguration gespeichert werden. In Systemen mit mehr als 1 Turm wird nur der Master mit dem LAN verbunden. + +Notwendige Software +------------------- + +* matplotlib + +Unterstützte Geräte +------------------- + +Folgende BYD-Typen werden unterstützt: + +* HVS (noch nicht getestet) +* HVM (getestet mit HVM 19.3kWh und 2 Türmen) +* LVS (noch nicht getestet) + +Bitte Debug-Daten (level: DEBUG) von noch nicht getesteten BYD Energiespeichern an Plugin-Autor senden. Beim Start von SmartHomeNG werden die Diagnosedaten sofort ermittelt. + +Konfiguration +============= + +Detaillierte Information sind :doc:`/plugins_doc/config/byd_bat` zu entnehmen. + + +Web Interface +============= + +Ein Web Interface ist implementiert und zeigt die eingelesenen Daten an. + +Beispiele +========= + +Oben rechts werden die wichtigsten Daten zum BYD Energiespeicher angezeigt. Mit dem Schalter "Verbindung" kann die Kommunikation mit dem Energiespeicher pausiert werden, um beispielsweise mit einer anderen Software auf das System zugreifen zu können. + +.. image:: assets/base.png + :class: screenshot + +Im Tab "BYD Home" sind die Grunddaten des Energiespeichers dargestellt: + +.. image:: assets/home.png + :class: screenshot + +Im Tab "BYD Diagnose" werden Diagnosedaten angezeigt: + +.. image:: assets/diag.png + :class: screenshot + +Im Tab "BYD Spannungen" werden die Spannungen der Module als Plot angezeigt: + +.. image:: assets/volt.png + :class: screenshot + +Im Tab "BYD Temperaturen" werden die Temperaturen der Module als Heatmap angezeigt: + +.. image:: assets/temp.png + :class: screenshot + +Im Tab "BYD Log-Daten" werden die Log-Daten angezeigt: + +.. image:: assets/logdata.png + :class: screenshot diff --git a/byd_bat/webif/__init__.py b/byd_bat/webif/__init__.py new file mode 100644 index 000000000..8e1319e9d --- /dev/null +++ b/byd_bat/webif/__init__.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2023 Matthias Manhart smarthome@beathis.ch +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This file implements the web interface for the byd_bat plugin. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + +class WebInterface(SmartPluginWebIf): + + def __init__(self,webif_dir,plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self,reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + pagelength = self.plugin.get_parameter_value('webif_pagelength') + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=0) + + @cherrypy.expose + def get_data_html(self,dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + + # get the new data + data = {} + + data['batttype'] = self.plugin.byd_batt_str + data['bydip'] = self.plugin.ip + data['soc'] = f'{self.plugin.byd_soc:.1f}' + " %" + data['capacity_total'] = f'{self.plugin.byd_capacity_total:.2f}' + " kWh" + data['power_charge'] = f'{self.plugin.byd_power_charge:.1f}' + " W" + data['power_discharge'] = f'{self.plugin.byd_power_discharge:.1f}' + " W" + data['last_homedata'] = self.plugin.last_homedata + data['last_diagdata'] = self.plugin.last_diagdata + data['imppath'] = self.plugin.bpath + +# self.logger.warning("c=" + str(self.plugin.byd_root.enable_connection())) + data['connection'] = self.plugin.byd_root.enable_connection() + + for xx in range(1,self.plugin.byd_towers_max+1): + tx = 't' + str(xx) + '_' + if xx <= self.plugin.byd_bms_qty: + # Turm ist vorhanden + data[tx + 'log_html'] = self.plugin.byd_diag_bms_log_html[xx] + else: + # Turm nicht vorhanden + data[tx + 'log_html'] = "" + + # 1.Register "BYD Home" + t = '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '
SOC:' + f'{self.plugin.byd_soc:.1f}' + ' %' + '
SOH:' + f'{self.plugin.byd_soh:.1f}' + ' %' + '
' + self.translate("Leistung") + ':' + f'{self.plugin.byd_power:.1f}' + ' W' + '
' + self.translate("Ladeleistung") + ':' + f'{self.plugin.byd_power_charge:.1f}' + ' W' + '
' + self.translate("Entladeleistung") + ':' + f'{self.plugin.byd_power_discharge:.1f}' + ' W' + '
' + self.translate("Spannung Ausgang") + ':' + f'{self.plugin.byd_volt_out:.1f}' + ' V' + '
' + self.translate("Strom Ausgang") + ':' + f'{self.plugin.byd_current:.1f}' + ' A' + '
' + self.translate("Spannung Batterie") + ':' + f'{self.plugin.byd_volt_bat:.1f}' + ' V' + '
' + self.translate("Spannung Batteriezellen max") + ':' + f'{self.plugin.byd_volt_max:.3f}' + ' V' + '
' + self.translate("Spannung Batteriezellen min") + ':' + f'{self.plugin.byd_volt_min:.3f}' + ' V' + '
' + self.translate("Spannung Batteriezellen Differenz") + ':' + f'{self.plugin.byd_volt_diff * 1000:.1f}' + ' mV' + '
' + self.translate("Temperatur Batterie") + ':' + f'{self.plugin.byd_temp_bat:.1f}' + ' °C' + '
' + self.translate("Temperatur Batterie max") + ':' + f'{self.plugin.byd_temp_max:.1f}' + ' °C' + '
' + self.translate("Temperatur Batterie min") + ':' + f'{self.plugin.byd_temp_min:.1f}' + ' °C' + '
' + self.translate("Laden total") + ':' + f'{self.plugin.byd_charge_total:.1f}' + ' kWh' + '
' + self.translate("Entladen total") + ':' + f'{self.plugin.byd_discharge_total:.1f}' + ' kWh' + '
' + self.translate("Wirkungsgrad") + ':' + f'{self.plugin.byd_eta:.1f}' + ' %' + '
' + data['r1_table'] = t + + t = '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '' + '' + '' + t = t + '
' + self.translate("Wechselrichter") + ':' + self.plugin.byd_inv_str + '
' + self.translate("Batterietyp") + ':' + self.plugin.byd_batt_str + '
' + self.translate("Seriennummer") + ':' + self.plugin.byd_serial + '
' + self.translate("Türme") + ':' + str(self.plugin.byd_bms_qty) + '
' + self.translate("Module pro Turm") + ':' + str(self.plugin.byd_modules) + '
' + self.translate("Parameter") + ':' + self.plugin.byd_application + '
' + self.translate("Fehler") + ':' + self.plugin.byd_error_str + " (" + str(self.plugin.byd_error_nr) + ")" + '
' + 'BMS' + ':' + self.plugin.byd_bmu + '
' + 'BMU' + ':' + self.plugin.byd_bms + '
' + 'BMU A' + ':' + self.plugin.byd_bmu_a + '
' + 'BMU B' + ':' + self.plugin.byd_bmu_b + '
' + 'P/T' + ':' + self.plugin.byd_param_t + '
' + data['r2_table'] = t + + # 2.Register "BYD Diagnose" + ds = [] + for xx in range(1,self.plugin.byd_towers_max+1): + ts = [] + if xx <= self.plugin.byd_bms_qty: + # Turm ist vorhanden + ts.append(['SOC',f'{self.plugin.byd_diag_soc[xx]:.1f}' + " %"]) + ts.append(['SOH',f'{self.plugin.byd_diag_soh[xx]:.1f}' + " %"]) + ts.append([self.translate("Batteriespannung"),f'{self.plugin.byd_diag_bat_voltag[xx]:.1f}' + " V"]) + ts.append([self.translate("Spannung Out"),f'{self.plugin.byd_diag_v_out[xx]:.1f}' + " V"]) + ts.append([self.translate("Strom"),f'{self.plugin.byd_diag_current[xx]:.1f}' + " A"]) + ts.append([self.translate("Spannung max (Zelle)"),f'{self.plugin.byd_diag_volt_max[xx]:.3f}' + " V (" + str(self.plugin.byd_diag_volt_max_c[xx]) + ")"]) + ts.append([self.translate("Spannung min (Zelle)"),f'{self.plugin.byd_diag_volt_min[xx]:.3f}' + " V (" + str(self.plugin.byd_diag_volt_min_c[xx]) + ")"]) + ts.append([self.translate("Spannung Differenz"),f'{self.plugin.byd_diag_volt_diff[xx]:.0f}' + " mV"]) + ts.append([self.translate("Temperatur max (Zelle)"),f'{self.plugin.byd_diag_temp_max[xx]:.1f}' + " V (" + str(self.plugin.byd_diag_temp_max_c[xx]) + ")"]) + ts.append([self.translate("Temperatur min (Zelle)"),f'{self.plugin.byd_diag_temp_min[xx]:.1f}' + " V (" + str(self.plugin.byd_diag_temp_min_c[xx]) + ")"]) + ts.append([self.translate("Laden total"),f'{self.plugin.byd_diag_charge_total[xx]:.3f}' + " kWh"]) + ts.append([self.translate("Entladen total"),f'{self.plugin.byd_diag_discharge_total[xx]:.3f}' + " kWh"]) + ts.append([self.translate("Balancing Anzahl Zellen"),f'{self.plugin.byd_diag_balance_number[xx]:.0f}']) + else: + for ii in range(0,13): + ts.append(['','']) + ds.append(ts) + + t = '' + t = t + '' + t = t + '' + if self.plugin.byd_bms_qty > 1: + t = t + '' + if self.plugin.byd_bms_qty > 2: + t = t + '' + t = t + '' + t = t + '' + for ii in range(0,13): + t = t + self.table_diag_row(ds[0][ii][0],ds[0][ii][1],ds[1][ii][1],ds[2][ii][1]) + t = t + '
' + self.translate("Turm") + ' 1' + self.translate("Turm") + ' 2' + self.translate("Turm") + ' 3
' + data['r3_table'] = t + + t = '

' + self.translate("Turm") + ' 1 BMS

' + t = t + self.table_diag_details(1) + t = t + '

' + self.translate("Status") + ': ' + self.plugin.byd_diag_state_str[1] + ' (0x' + f'{self.plugin.byd_diag_state[1]:04x}' + ')

' + if self.plugin.byd_bms_qty > 1: + t = t + '

' + t = t + '

' + self.translate("Turm") + ' 2 BMS

' + t = t + self.table_diag_details[2] + t = t + '

' + self.translate("Status") + ': ' + self.plugin.byd_diag_state_str[2] + ' (0x' + f'{self.plugin.byd_diag_state[2]:04x}' + ')

' + if self.plugin.byd_bms_qty > 2: + t = t + '

' + t = t + '

' + self.translate("Turm") + ' 3 BMS

' + t = t + self.table_diag_details[3] + t = t + '

' + self.translate("Status") + ': ' + self.plugin.byd_diag_state_str[3] + ' (0x' + f'{self.plugin.byd_diag_state[3]:04x}' + ')

' + data['r4_table'] = t + + # 5.Register "BYD Logdaten" + t = '

BMU

' + t = t + self.plugin.byd_bmu_log_html + t = t + '

' + t = t + '

' + self.translate("Turm") + ' 1 BMS

' + t = t + self.plugin.byd_diag_bms_log_html[1] + if self.plugin.byd_bms_qty > 1: + t = t + '

' + t = t + '

' + self.translate("Turm") + ' 2 BMS

' + t = t + self.plugin.byd_diag_bms_log_html[2] + if self.plugin.byd_bms_qty > 2: + t = t + '

' + t = t + '

' + self.translate("Turm") + ' 3 BMS

' + t = t + self.plugin.byd_diag_bms_log_html[3] + data['r5_table'] = t + +# self.logger.warning("done done") + + # return it as json to the web page + try: + return json.dumps(data) + except Exception as e: + self.logger.error("get_data_html exception: {}".format(e)) + + def table_diag_row(self,title,s1,s2,s3): + s = '' + s = s + '' + title + ':' + s = s + '' + s1 + '' + if self.plugin.byd_bms_qty > 1: + s = s + '' + s2 + '' + if self.plugin.byd_bms_qty > 2: + s = s + '' + s3 + '' + s = s + '' + return s + + def table_diag_details(self,xx): + t = '' + t = t + '''' + for i in range(0,self.plugin.byd_modules): + t = t + '' + t = t + '' + t = t + self.table_diag_details_row(self.translate("Spannung minimal") + ' [V]',xx,self.plugin.byd_module_vmin,3) + t = t + self.table_diag_details_row(self.translate("Spannung maximal") + ' [V]',xx,self.plugin.byd_module_vmax,3) + t = t + self.table_diag_details_row(self.translate("Spannung Durchschnitt") + ' [V]',xx,self.plugin.byd_module_vava,3) + t = t + self.table_diag_details_row(self.translate("Spannung Differenz") + ' [mV]',xx,self.plugin.byd_module_vdif,0) + t = t + '
' + '' + '' + 'M' + str(i+1) + '
' + return t + + def table_diag_details_row(self,txt,xx,vi,nn): + t = '' + t = t + '' + txt + '' + for i in range(0,self.plugin.byd_modules): + if nn == 1: + t = t + '' + f'{self.plugin.byd_diag_module[xx][i][vi]:.1f}' + '' + elif nn == 2: + t = t + '' + f'{self.plugin.byd_diag_module[xx][i][vi]:.2f}' + '' + elif nn == 3: + t = t + '' + f'{self.plugin.byd_diag_module[xx][i][vi]:.3f}' + '' + else: + t = t + '' + f'{self.plugin.byd_diag_module[xx][i][vi]:.0f}' + '' + t = t + '' + return t + + @cherrypy.expose + def byd_connection_true(self): + self.logger.warning("byd_connection_true") + self.plugin.byd_root.enable_connection(True) + return + + @cherrypy.expose + def byd_connection_false(self): + self.logger.warning("byd_connection_false") + self.plugin.byd_root.enable_connection(False) + return + + \ No newline at end of file diff --git a/byd_bat/webif/static/img/plugin_logo.png b/byd_bat/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..5d27349ab Binary files /dev/null and b/byd_bat/webif/static/img/plugin_logo.png differ diff --git a/byd_bat/webif/templates/index.html b/byd_bat/webif/templates/index.html new file mode 100644 index 000000000..cc25237ce --- /dev/null +++ b/byd_bat/webif/templates/index.html @@ -0,0 +1,367 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 5000 %} + + + + + +{% set buttons = true %} + + +{% set autorefresh_buttons = false %} + + +{% set reload_button = false %} + + +{% set close_button = true %} + + +{% set row_count = true %} + + +{% set initial_update = true %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Batterietyp') }}:{{ _('Laden') }} ...BYD IP:
{{ _('Batterieladung') }}:{{ _('Gesamtkapazität') }}:
{{ _('Ladeleistung') }}:{{ _('Entladeleistung') }}:
{{ _('Basisdaten') }}:{{ _('Diagnosedaten') }}:
+{% endblock headtable %} + + +{% block buttons %} +{% if 1==1 %} +
+  {{ _('Verbindung') }}  +
+{% endif %} +{% endblock %} + + +{% set tabcount = 5 %} + + +{% if item_count==0 %} + {% set start_tab = 1 %} +{% endif %} + + +{% set tab1title = ""+_('BYD Home')+"" %} +{% block bodytab1 %} +
+
+
+

+
{{ _('Laden') }} ...
+
+
+

+
+
+
+
+{% endblock bodytab1 %} + + +{% set tab2title = ""+_('BYD Diagnose')+"" %} +{% block bodytab2 %} +
+
+
+

+
{{ _('Laden') }} ...
+
+
+

+
+
+
+
+{% endblock bodytab2 %} + + +{% set tab3title = ""+_('BYD Spannungen')+"" %} +{% block bodytab3 %} + + + + + + + + + + + + + + + + + + + +
Turm 1 nicht vorhanden
Turm 1 nicht vorhanden
Turm 2 nicht vorhanden
Turm 2 nicht vorhanden
Turm 3 nicht vorhanden
Turm 3 nicht vorhanden
+{% endblock bodytab3 %} + + + +{% set tab4title = ""+_('BYD Temperaturen')+"" %} +{% block bodytab4 %} +

+ + + + + + + + + + +
Turm 1 nicht vorhanden
Turm 2 nicht vorhanden
Turm 3 nicht vorhanden
+{% endblock bodytab4 %} + + +{% set tab5title = ""+_('BYD Log-Daten')+"" %} +{% block bodytab5 %} +

+
{{ _('Laden') }} ...
+ +{% endblock bodytab5 %} diff --git a/casambi/user_doc.rst b/casambi/user_doc.rst index 973f7a6fc..5da42829a 100755 --- a/casambi/user_doc.rst +++ b/casambi/user_doc.rst @@ -31,7 +31,8 @@ Das Casambi Konzept sieht vor, ein Mobilgerät (z.B. Handy oder Tablett) als Har enzusetzen. Anforderungen -============ +============= + Das Casambi Plugin benötigt einen validen Casambi API key, der hier beantragt werden kann: ``support@casambi.com`` diff --git a/comfoair/__init__.py b/comfoair/__init__.py index 1bfcf970f..87184bb96 100755 --- a/comfoair/__init__.py +++ b/comfoair/__init__.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +# !/usr/bin/env python ######################################################################### # Copyright 2013 Stefan Kals ######################################################################### @@ -18,11 +18,9 @@ # along with this plugin. If not, see . ######################################################################### -import logging import socket import time import serial -import re import threading from . import commands @@ -32,33 +30,29 @@ class ComfoAir(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = '1.3.0' + PLUGIN_VERSION = '1.3.1' - def __init__(self, smarthome, host=None, port=0, serialport=None, kwltype='comfoair350'): - self.logger = logging.getLogger('ComfoAir') + def __init__(self, sh, **kwargs): self.connected = False - self._sh = smarthome self._params = {} self._init_cmds = [] self._cyclic_cmds = {} self._lock = threading.Lock() - self._host = host - self._port = int(port) - self._serialport = serialport + self._host = self.get_parameter_value('host') + self._port = self.get_parameter_value('port') + self._serialport = self.get_parameter_value('serialport') + self._kwltype = self.get_parameter_value('kwltype') self._connection_attempts = 0 self._connection_errorlog = 60 self._initread = False - # automatically (re)connect - smarthome.connections.monitor(self) - # Load controlset and commandset - if kwltype in commands.controlset and kwltype in commands.commandset: - self._controlset = commands.controlset[kwltype] - self._commandset = commands.commandset[kwltype] - self.log_info('Loaded commands for KWL type \'{}\''.format(kwltype)) + if self._kwltype in commands.controlset and self._kwltype in commands.commandset: + self._controlset = commands.controlset[self._kwltype] + self._commandset = commands.commandset[self._kwltype] + self.log_info('Loaded commands for KWL type \'{}\''.format(self._kwltype)) else: - self.log_err('Commands for KWL type \'{}\' could not be found!'.format(kwltype)) + self.log_err('Commands for KWL type \'{}\' could not be found!'.format(self._kwltype)) return None # Remember packet config @@ -69,13 +63,13 @@ def __init__(self, smarthome, host=None, port=0, serialport=None, kwltype='comfo self._reponsecommandinc = self._controlset['ResponseCommandIncrement'] self._commandlength = 2 self._checksumlength = 1 - + def connect(self): - if self._serialport is not None: + if self._serialport: self.connect_serial() else: self.connect_tcp() - + def connect_tcp(self): self._lock.acquire() try: @@ -94,12 +88,12 @@ def connect_tcp(self): self.log_info('connected to {}:{}'.format(self._host, self._port)) self._connection_attempts = 0 self._lock.release() - + def connect_serial(self): self._lock.acquire() try: self._serialconnection = serial.Serial( - self._serialport, 9600, serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, timeout=2) + self._serialport, 9600, serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE, timeout=2) except Exception as e: self._connection_attempts -= 1 if self._connection_attempts <= 0: @@ -118,8 +112,8 @@ def disconnect(self): self.disconnect_serial() else: self.disconnect_tcp() - - def disconnect_tcp(self): + + def disconnect_tcp(self): self.connected = False try: self._sock.shutdown(socket.SHUT_RDWR) @@ -130,54 +124,54 @@ def disconnect_tcp(self): except: pass - def disconnect_serial(self): + def disconnect_serial(self): self.connected = False try: self._serialconnection.close() self._serialconnection = None except: pass - + def send_bytes(self, packet): if self._serialport is not None: self.send_bytes_serial(packet) else: self.send_bytes_tcp(packet) - + def send_bytes_tcp(self, packet): self._sock.sendall(packet) def send_bytes_serial(self, packet): self._serialconnection.write(packet) - + def read_bytes(self, length): if self._serialport is not None: return self.read_bytes_serial(length) else: return self.read_bytes_tcp(length) - + def read_bytes_tcp(self, length): return self._sock.recv(length) def read_bytes_serial(self, length): return self._serialconnection.read(length) - + def parse_item(self, item): # Process the read config if self.has_iattr(item.conf, 'comfoair_read'): commandname = self.get_iattr_value(item.conf, 'comfoair_read') - if (commandname == None or commandname not in self._commandset): + if (commandname is None or commandname not in self._commandset): self.log_err('Item {} contains invalid read command \'{}\'!'.format(item, commandname)) return None - + # Remember the read config to later update this item if the configured response comes in self.log_info('Item {} reads by using command \'{}\'.'.format(item, commandname)) commandconf = self._commandset[commandname] commandcode = commandconf['Command'] - if not commandcode in self._params: + if commandcode not in self._params: self._params[commandcode] = {'commandname': [commandname], 'items': [item]} - elif not item in self._params[commandcode]['items']: + elif item not in self._params[commandcode]['items']: self._params[commandcode]['commandname'].append(commandname) self._params[commandcode]['items'].append(item) @@ -185,7 +179,7 @@ def parse_item(self, item): if (self.has_iattr(item.conf, 'comfoair_init') and self.get_iattr_value(item.conf, 'comfoair_init') == 'true'): self.log_info('Item {} is initialized on startup.'.format(item)) # Only add the item to the initial commands if it is not cyclic. Cyclic commands get called on init because this is the first cycle... - if not commandcode in self._init_cmds and not self.has_iattr(item.conf, 'comfoair_read_cycle'): + if commandcode not in self._init_cmds and not self.has_iattr(item.conf, 'comfoair_read_cycle'): self._init_cmds.append(commandcode) # Allow items to be cyclically updated @@ -193,7 +187,7 @@ def parse_item(self, item): cycle = int(self.get_iattr_value(item.conf, 'comfoair_read_cycle')) self.log_info('Item {} should read cyclic every {} seconds.'.format(item, cycle)) - if not commandcode in self._cyclic_cmds: + if commandcode not in self._cyclic_cmds: self._cyclic_cmds[commandcode] = {'cycle': cycle, 'nexttime': 0} else: # If another item requested this command already with a longer cycle, use the shorter cycle now @@ -203,12 +197,12 @@ def parse_item(self, item): # Process the send config if self.has_iattr(item.conf, 'comfoair_send'): commandname = self.get_iattr_value(item.conf, 'comfoair_send') - if commandname == None: + if commandname is None: return None elif commandname not in self._commandset: self.log_err('Item {} contains invalid write command \'{}\'!'.format(item, commandname)) return None - + self.log_info('Item {} writes by using command \'{}\''.format(item, commandname)) return self.update_item else: @@ -221,7 +215,7 @@ def update_item(self, item, caller=None, source=None, dest=None): if caller != 'ComfoAir' and self.has_iattr(item.conf, 'comfoair_send'): commandname = self.get_iattr_value(item.conf, 'comfoair_send') - if type(item) != int: + if type(item) is not int: value = int(item()) else: value = item() @@ -238,18 +232,18 @@ def update_item(self, item, caller=None, source=None, dest=None): aw = float(readafterwrite) time.sleep(aw) self.send_command(readcommandname) - - # If commands should be triggered after this write + + # If commands should be triggered after this write if self.has_iattr(item.conf, 'comfoair_trigger'): trigger = self.get_iattr_value(item.conf, 'comfoair_trigger') - if trigger == None: + if trigger is None: self.log_err('Item {} contains invalid trigger command list \'{}\'!'.format(item, trigger)) else: - tdelay = 5 # default delay + tdelay = 5 # default delay if self.has_iattr(item.conf, 'comfoair_trigger_afterwrite'): tdelay = float(self.get_iattr_value(item.conf, 'comfoair_trigger_afterwrite')) - if type(trigger) != list: - trigger = [trigger] + if type(trigger) is not list: + trigger = [trigger] for triggername in trigger: triggername = triggername.strip() if triggername is not None and readafterwrite is not None: @@ -268,32 +262,32 @@ def handle_cyclic_cmds(self): self.log_debug('Triggering cyclic read command: {}'.format(commandname)) self.send_command(commandname) entry['nexttime'] = currenttime + entry['cycle'] - + def send_command(self, commandname, value=None): try: - #self.log_debug('Got a new send job: Command {} with value {}'.format(commandname, value)) - + # self.log_debug('Got a new send job: Command {} with value {}'.format(commandname, value)) + # Get command config commandconf = self._commandset[commandname] commandcode = int(commandconf['Command']) commandcodebytecount = commandconf['CommandBytes'] commandtype = commandconf['Type'] commandvaluebytes = commandconf['ValueBytes'] - #self.log_debug('Command config: {}'.format(commandconf)) - + # self.log_debug('Command config: {}'.format(commandconf)) + # Transform value for write commands - #self.log_debug('Got value: {}'.format(value)) + # self.log_debug('Got value: {}'.format(value)) if 'ValueTransform' in commandconf and value is not None and value != '' and commandtype == 'Write': commandtransform = commandconf['ValueTransform'] value = self.value_transform(value, commandtype, commandtransform) - #self.log_debug('Transformed value using method {} to {}'.format(commandtransform, value)) - + # self.log_debug('Transformed value using method {} to {}'.format(commandtransform, value)) + # Build value byte array valuebytes = bytearray() if value is not None and commandvaluebytes > 0: valuebytes = self.int2bytes(value, commandvaluebytes) - #self.log_debug('Created value bytes: {}'.format(valuebytes)) - + # self.log_debug('Created value bytes: {}'.format(valuebytes)) + # Calculate the checksum commandbytes = self.int2bytes(commandcode, commandcodebytecount) payload = bytearray() @@ -301,7 +295,7 @@ def send_command(self, commandname, value=None): if len(valuebytes) > 0: payload.extend(valuebytes) checksum = self.calc_checksum(payload) - + # Build packet packet = bytearray() packet.extend(self.int2bytes(self._controlset['PacketStart'], 2)) @@ -311,22 +305,22 @@ def send_command(self, commandname, value=None): packet.extend(self.int2bytes(checksum, 1)) packet.extend(self.int2bytes(self._controlset['PacketEnd'], 2)) self.log_debug('Preparing command {} with value {} (transformed to value byte \'{}\') to be sent.'.format(commandname, value, self.bytes2hexstring(valuebytes))) - + # Use a lock to allow only one sender at a time self._lock.acquire() if not self.connected: raise Exception("No connection to ComfoAir.") - + try: self.send_bytes(packet) self.log_debug('Successfully sent packet: {}'.format(self.bytes2hexstring(packet))) except Exception as e: raise Exception('Exception while sending: {}'.format(e)) - + if commandtype == 'Read': packet = bytearray() - + # Try to receive a packet start, a command and a data length byte firstpartlen = len(self._packetstart) + self._commandlength + 1 while self.alive and len(packet) < firstpartlen: @@ -335,9 +329,9 @@ def send_command(self, commandname, value=None): self.log_debug('Trying to receive {} bytes for the first part of the response.'.format(bytestoreceive)) chunk = self.read_bytes(bytestoreceive) self.log_debug('Received {} bytes chunk of response part 1: {}'.format(len(chunk), self.bytes2hexstring(chunk))) - if len(chunk) == 0: + if len(chunk) == 0: raise Exception('Received 0 bytes chunk - ignoring packet!') - + # Cut away old ACK (but only if the telegram wasn't started already) if len(packet) == 0: chunk = self.remove_ack_begin(chunk) @@ -346,11 +340,11 @@ def send_command(self, commandname, value=None): raise Exception("error receiving first part of packet: timeout") except Exception as e: raise Exception("error receiving first part of packet: {}".format(e)) - + datalen = packet[firstpartlen - 1] - #self.log_info('Got a data length of: {}'.format(datalen)) + # self.log_info('Got a data length of: {}'.format(datalen)) packetlen = firstpartlen + datalen + self._checksumlength + len(self._packetend) - + # Try to receive the second part of the packet while self.alive and len(packet) < packetlen or packet[-2:] != self._packetend: try: @@ -358,42 +352,42 @@ def send_command(self, commandname, value=None): if len(packet) >= packetlen and packet[-2:] != self._packetend: packetlen = len(packet) + 1 self.log_debug('Extended packet length because of encoded characters.'.format(self.bytes2hexstring(chunk))) - + # Receive next chunk bytestoreceive = packetlen - len(packet) self.log_debug('Trying to receive {} bytes for the second part of the response.'.format(bytestoreceive)) chunk = self.read_bytes(bytestoreceive) self.log_debug('Received {} bytes chunk of response part 2: {}'.format(len(chunk), self.bytes2hexstring(chunk))) packet.extend(chunk) - if len(chunk) == 0: + if len(chunk) == 0: raise Exception('Received 0 bytes chunk - ignoring packet!') except socket.timeout: raise Exception("error receiving second part of packet: timeout") except Exception as e: raise Exception("error receiving second part of packet: {}".format(e)) - + # Send ACK self.send_bytes(self._acknowledge) - + # Parse response self.parse_response(packet) - + except Exception as e: self.disconnect() self.log_err("send_command failed: {}".format(e)) - finally: + finally: # At the end, release the lock self._lock.release() def parse_response(self, response): - #resph = self.bytes2int(response) + # resph = self.bytes2int(response) self.log_debug('Successfully received response: {}'.format(self.bytes2hexstring(response))) # A telegram looks like this: start sequence (2 bytes), command (2 bytes), data length (1 byte), data, checksum (1 byte), end sequence (2 bytes, already cut away) commandcodebytes = response[2:4] - commandcodebytes[1] -= self._reponsecommandinc # The request command of this response is -1 (for comfoair 350) - commandcodebytes.append(0) # Add a data length byte of 0 (always true for read commands) + commandcodebytes[1] -= self._reponsecommandinc # The request command of this response is -1 (for comfoair 350) + commandcodebytes.append(0) # Add a data length byte of 0 (always true for read commands) commandcode = self.bytes2int(commandcodebytes) # Remove begin and checksum to get the data @@ -405,8 +399,8 @@ def parse_response(self, response): # Validate checksum packetpart = bytearray() - packetpart.extend(response[2:5]) # Command and data length - packetpart.extend(databytes) # Decoded data bytes + packetpart.extend(response[2:5]) # Command and data length + packetpart.extend(databytes) # Decoded data bytes checksum = self.calc_checksum(packetpart) receivedchecksum = response[len(response) - len(self._packetend) - 1] if (receivedchecksum != checksum): @@ -437,7 +431,7 @@ def parse_response(self, response): # Extract value valuebytes = databytes[index:index + commandvaluebytes] rawvalue = self.bytes2int(valuebytes) - + # Tranform value value = self.value_transform(rawvalue, commandtype, commandtransform) self.log_debug('Matched command {} and read transformed value {} (raw value was {}) from byte position {} and byte length {}.'.format(commandname, value, rawvalue, commandresppos, commandvaluebytes)) @@ -448,20 +442,23 @@ def parse_response(self, response): self.log_err('Telegram did not contain enough data bytes for the configured command {} to extract a value!'.format(commandname)) def run(self): + # automatically (re)connect + self._sh.connections.monitor(self) + self.alive = True - self._sh.scheduler.add('ComfoAir-init', self.send_init_commands, prio=5, cycle=600, offset=2) + self.scheduler_add('ComfoAir-init', self.send_init_commands, prio=5, cycle=600, offset=2) maxloops = 20 - loops = 0 + loops = 0 while self.alive and not self._initread and loops < maxloops: # wait for init read to finish time.sleep(0.5) loops += 1 - self._sh.scheduler.remove('ComfoAir-init') - + self.scheduler_remove('ComfoAir-init') + def stop(self): - self._sh.scheduler.remove('ComfoAir-cyclic') self.alive = False + self.scheduler_remove('ComfoAir-cyclic') self.disconnect() - + def send_init_commands(self): try: # Do the init read commands @@ -471,19 +468,19 @@ def send_init_commands(self): for commandcode in self._init_cmds: commandname = self.commandname_by_commandcode(commandcode) self.send_command(commandname) - + # Find the shortest cycle shortestcycle = -1 for commandname in list(self._cyclic_cmds.keys()): entry = self._cyclic_cmds[commandname] if shortestcycle == -1 or entry['cycle'] < shortestcycle: shortestcycle = entry['cycle'] - + # Start the worker thread if shortestcycle != -1: # Balance unnecessary calls and precision workercycle = int(shortestcycle / 2) - self._sh.scheduler.add('ComfoAir-cyclic', self.handle_cyclic_cmds, cycle=workercycle, prio=5, offset=0) + self.scheduler_add('ComfoAir-cyclic', self.handle_cyclic_cmds, cycle=workercycle, prio=5, offset=0) self.log_info('Added cyclic worker thread ({} sec cycle). Shortest item update cycle found: {} sec.'.format(workercycle, shortestcycle)) finally: self._initread = True @@ -494,30 +491,30 @@ def remove_ack_begin(self, packet): while len(packet) >= acklen and packet[0:acklen] == self._acknowledge: packet = packet[acklen:] return packet - + def calc_checksum(self, packetpart): return (sum(packetpart) + 173) % 256 - - def log_debug(self, text): + + def log_debug(self, text): self.logger.debug('ComfoAir: {}'.format(text)) - def log_info(self, text): + def log_info(self, text): self.logger.info('ComfoAir: {}'.format(text)) - def log_err(self, text): + def log_err(self, text): self.logger.error('ComfoAir: {}'.format(text)) - + def int2bytes(self, value, length): # Limit value to the passed byte length value = value % (2 ** (length * 8)) return value.to_bytes(length, byteorder='big') - + def bytes2int(self, bytesvalue): return int.from_bytes(bytesvalue, byteorder='big', signed=False) - + def bytes2hexstring(self, bytesvalue): return ":".join("{:02x}".format(c) for c in bytesvalue) - + def encode_specialchars(self, packet): specialchar = self._controlset['SpecialCharacter'] encodedpacket = bytearray() @@ -528,9 +525,9 @@ def encode_specialchars(self, packet): # Encoding works by doubling the special char self.log_debug('Encoded special char at position {} of data bytes {}.'.format(count, self.bytes2hexstring(packet))) encodedpacket.append(char) - #self.log_debug('Encoded data bytes: {}.'.format(encodedpacket)) + # self.log_debug('Encoded data bytes: {}.'.format(encodedpacket)) return encodedpacket - + def decode_specialchars(self, packet): specialchar = self._controlset['SpecialCharacter'] decodedpacket = bytearray() @@ -549,7 +546,7 @@ def decode_specialchars(self, packet): # Reset dropping marker specialcharremoved = 0 return decodedpacket - + def value_transform(self, value, commandtype, transformmethod): if transformmethod == 'Temperature': if commandtype == 'Read': @@ -562,7 +559,7 @@ def value_transform(self, value, commandtype, transformmethod): elif commandtype == 'Write': return int(1875000 / value) return value - + def commandname_by_commandcode(self, commandcode): for commandname in self._commandset.keys(): if self._commandset[commandname]['Command'] == commandcode: diff --git a/comfoair/plugin.yaml b/comfoair/plugin.yaml index 806000e79..7438bad8e 100755 --- a/comfoair/plugin.yaml +++ b/comfoair/plugin.yaml @@ -12,11 +12,11 @@ plugin: documentation: https://github.com/smarthomeNG/smarthome/wiki/Comfoair-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/31291-neues-plugin-comfoair-kwl-wohnraumlüftung-zehnder-paul-wernig - version: 1.3.0 # Plugin version + version: 1.3.1 # Plugin version sh_minversion: 1.3 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) - multi_instance: False - restartable: unknown + multi_instance: false + restartable: true classname: ComfoAir # class containing the plugin parameters: @@ -30,16 +30,19 @@ parameters: valid_list: - comfoair350 - comfoair500 + host: type: ip description: de: 'Netzwerverbindung: Hostname/IP des KWL Systems' en: 'Network connection: Hostname/IP of KWL system' + port: type: int description: de: 'Netzwerkverbindung: Port des KWL Systems' en: 'Network connection: Port of KWL system' + serialport: type: str description: @@ -61,22 +64,22 @@ item_attributes: comfoair_read_afterwrite: type: num description: - de: 'Konfiguriert eine Verzögerung in Sekunden nachdem ein Lesekommando nach einem Schreibkommando an das KWL System geschickt wird.' + de: 'Konfiguriert eine Verzögerung in Sekunden, nachdem ein Lesekommando nach einem Schreibkommando an das KWL System geschickt wird.' en: 'Configures delay in seconds to issue a read command after write command.' comfoair_read_cycle: type: num description: - de: 'Konfiguriert ein Interval in Sekunden für das Lesekommando.' + de: 'Konfiguriert ein Intervall in Sekunden für das Lesekommando.' en: 'Configures a interval in seconds for the read command.' comfoair_init: type: bool description: - de: 'Konfiguriert ob der Wert aus dem KWL System initialisiert werden soll.' + de: 'Konfiguriert, ob der Wert aus dem KWL System initialisiert werden soll.' en: 'Configures to initialize the item value with the value from the KWL system.' comfoair_trigger: type: list(str) description: - de: 'Konfiguriert Lesekommandos die nach einem Schreibvorgang auf das Item aufgerufen werden.' + de: 'Konfiguriert Lesekommandos, die nach einem Schreibvorgang auf das Item aufgerufen werden.' en: 'Configures read commands after an update to the item.' comfoair_trigger_afterwrite: type: num diff --git a/database/__init__.py b/database/__init__.py index 7a79905eb..c20aeaec7 100755 --- a/database/__init__.py +++ b/database/__init__.py @@ -51,7 +51,7 @@ class Database(SmartPlugin): """ ALLOW_MULTIINSTANCE = True - PLUGIN_VERSION = '1.6.9' + PLUGIN_VERSION = '1.6.12' # SQL queries: {item} = item table name, {log} = log table name # time, item_id, val_str, val_num, val_bool, changed @@ -251,7 +251,7 @@ def parse_item(self, item): except Exception as e: self.logger.error("Reading cache value from database for {} failed: {}".format(item.id(), e)) else: - self.logger.warning("Cache not available in database for item {}".format(item.id() )) + self.logger.notice(f"No cached value available in database for item {item.id()}") cur.close() self._db.release() elif self.get_iattr_value(item.conf, 'database').lower() == 'init': @@ -491,7 +491,6 @@ def db_lastchange(self, item): This is a public function of the plugin :param item: Item to get the ID for - :param cur: A database cursor object if available (optional) :return: id of the item within the database :rtype: int | None @@ -512,6 +511,8 @@ def db_lastchange(self, item): id = int(row[COL_ITEM_ID]) last_change = row[COL_ITEM_TIME] + if last_change is None: + return None return self._datetime(last_change) @@ -786,7 +787,11 @@ def readOldestLog(self, id, cur=None): :return: Time of oldest log record for the database ID """ params = {'id': id} - return self._fetchall("SELECT min(time) FROM {log} WHERE item_id = :id;", params, cur=cur)[0][0] + db_values = self._fetchall("SELECT min(time) FROM {log} WHERE item_id = :id;", params, cur=cur) + if db_values is None: + return None + else: + return db_values[0][0] def readLatestLog(self, id, time=None, cur=None): @@ -803,11 +808,18 @@ def readLatestLog(self, id, time=None, cur=None): """ if time is None: params = {'id': id} - return self._fetchall("SELECT max(time) FROM {log} WHERE item_id = :id;", params, cur=cur)[0][0] + db_values = self._fetchall("SELECT max(time) FROM {log} WHERE item_id = :id;", params, cur=cur) + if db_values is None: + return None + else: + return db_values[0][0] else: params = {'id': id, 'time': time} - return self._fetchall("SELECT max(time) FROM {log} WHERE item_id = :id AND time <= :time", params, cur=cur)[0][0] - + db_values = self._fetchall("SELECT max(time) FROM {log} WHERE item_id = :id AND time <= :time", params, cur=cur) + if db_values is None: + return None + else: + return db_values[0][0] def readTotalLogCount(self, id=None, time_start=None, time_end=None, cur=None): """ @@ -826,7 +838,6 @@ def readTotalLogCount(self, id=None, time_start=None, time_end=None, cur=None): return 0 return result[0][0] - def readLogCount(self, id, time_start=None, time_end=None, cur=None): """ Read database log count for given database ID @@ -907,17 +918,30 @@ def build_orphanlist(self, log_activity=False): self.orphanlist = [] items = [item.id() for item in self._buffer] - cur = self._db_maint.cursor() - try: - for item in self.readItems(cur=cur): - if item[COL_ITEM_NAME] not in items: - if log_activity: - self.logger.info(f"- Found data for item w/o database attribute: {item[COL_ITEM_NAME]}") - self.orphanitemlist.append(item) - self.orphanlist.append(item[COL_ITEM_NAME]) + try: + cur = self._db_maint.cursor() except Exception as e: - self.logger.error("Database build_orphan_list failed: {}".format(e)) - cur.close() + self.logger.error("Database build_orphan_list failed obtaining cursor: {}".format(e)) + else: + + try: + return_list = self.readItems(cur=cur) + if return_list: + for item in return_list: + if item[COL_ITEM_NAME] not in items: + if log_activity: + self.logger.info(f"- Found data for item w/o database attribute: {item[COL_ITEM_NAME]}") + self.orphanitemlist.append(item) + self.orphanlist.append(item[COL_ITEM_NAME]) + except Exception as e: + self.logger.error("Database build_orphan_list failed: {}".format(e)) + + try: + if cur: + cur.close() + except Exception as e: + self.logger.error("Database build_orphan_list failed closing cursor: {}".format(e)) + self._count_orphanlogentries() if log_activity: self.logger.info("build_orphan_list: Finished") @@ -1052,7 +1076,7 @@ def _series(self, func, start, end='now', count=100, ratio=1, update=False, step :return: data structure in the form needed by the websocket plugin return it to the visu """ - #self.logger.warning("_series: item={}, func={}, start={}, end={}, count={}".format(item, func, start, end, count)) + #self.logger.debug("_series: item={}, func={}, start={}, end={}, count={}".format(item, func, start, end, count)) init = not update if sid is None: sid = item + '|' + func + '|' + str(start) + '|' + str(end) + '|' + str(count) @@ -1061,7 +1085,10 @@ def _series(self, func, start, end='now', count=100, ratio=1, update=False, step 'avg': 'MIN(time), ' + self._precision_query('AVG(val_num * duration) / AVG(duration)'), 'avg.order': 'ORDER BY time ASC', 'integrate': 'MIN(time), SUM(val_num * duration)', - 'differentiate': 'MIN(time), (val_num - LAG(val_num,1, -1)) / duration', + 'diff': 'MIN(time), (val_num - LAG(val_num,1) OVER (ORDER BY val_num))', + 'duration': 'MIN(time), duration', + # differentiate (d/dt) is scaled to match the conversion from d/dt (kWh) = kWh: time is in ms, val_num in kWh, therefore scale by 1000ms and 3600s/h to obtain the result in kW: + 'differentiate': 'MIN(time), (val_num - LAG(val_num,1) OVER (ORDER BY val_num)) / ( (time - LAG(time,1) OVER (ORDER BY val_num)) / (3600 * 1000) )', 'count': 'MIN(time), SUM(CASE WHEN val_num{op}{value} THEN 1 ELSE 0 END)'.format(**expression['params']), 'countall': 'MIN(time), COUNT(*)', 'min': 'MIN(time), MIN(val_num)', @@ -1080,22 +1107,26 @@ def _series(self, func, start, end='now', count=100, ratio=1, update=False, step group = 'GROUP BY ROUND(time / :step)' if func + '.group' not in queries else queries[func + '.group'] logs = self._fetch_log(item, queries[func], start, end, step=step, count=count, group=group, order=order) tuples = logs['tuples'] - if tuples: - if logs['istart'] > tuples[0][0]: - tuples[0] = (logs['istart'], tuples[0][1]) - if end != 'now': - tuples.append((logs['iend'], tuples[-1][1])) - else: - tuples = [] - item_change = self._timestamp(logs['item'].last_change()) - if item_change < logs['iend']: - value = float(logs['item']()) - if item_change < logs['istart']: - tuples.append((logs['istart'], value)) - elif init: - tuples.append((item_change, value)) - if init: - tuples.append((logs['iend'], value)) + + # Append tuples by addition values (not for func differentiate) + if func != 'differentiate': + + if tuples: + if logs['istart'] > tuples[0][0]: + tuples[0] = (logs['istart'], tuples[0][1]) + if end != 'now': + tuples.append((logs['iend'], tuples[-1][1])) + else: + tuples = [] + item_change = self._timestamp(logs['item'].last_change()) + if item_change < logs['iend']: + value = float(logs['item']()) + if item_change < logs['istart']: + tuples.append((logs['istart'], value)) + elif init: + tuples.append((item_change, value)) + if init: + tuples.append((logs['iend'], value)) if expression['finalizer']: tuples = self._finalize(expression['finalizer'], tuples) @@ -1106,14 +1137,14 @@ def _series(self, func, start, end='now', count=100, ratio=1, update=False, step 'step': logs['step'], 'sid': sid}, 'update': self.shtime.now() + datetime.timedelta(seconds=int(logs['step'] / 1000)) } - #self.logger.warning("_series: result={}".format(result)) + #self.logger.debug("_series: result={}".format(result)) + return result def _single(self, func, start, end='now', item=None): """ - As far as it has been checked, this method is never called. - It is attached to the item object but no other plugin is known that calls this method. + This function is not used by any other plugin but can be used in logics :param func: :param start: @@ -1125,11 +1156,11 @@ def _single(self, func, start, end='now', item=None): queries = { 'avg': self._precision_query('AVG(val_num * duration) / AVG(duration)'), 'integrate': 'SUM(val_num * duration)', - 'differentiate': 'val_num - LAG(val_num) / duration', 'count': 'SUM(CASE WHEN val_num{op}{value} THEN 1 ELSE 0 END)'.format(**expression['params']), 'countall': 'COUNT(*)', 'min': 'MIN(val_num)', 'max': 'MAX(val_num)', + 'diff': 'MAX(val_num) - MIN(val_num)', 'on': self._precision_query('SUM(val_bool * duration) / SUM(duration)'), 'sum': 'SUM(val_num)', 'raw': 'val_num', @@ -1649,7 +1680,7 @@ def _initialize_db(self): self.last_connect_time = time.time() self._db.connect() else: - self.logger.error("Database reconnect supressed: Delta time: {0}".format(time_delta_last_connect)) + self.logger.info("Database reconnect supressed: Delta time: {0}".format(time_delta_last_connect)) return False if not self._db_initialized: diff --git a/database/plugin.yaml b/database/plugin.yaml index 389724872..1d4b38924 100755 --- a/database/plugin.yaml +++ b/database/plugin.yaml @@ -11,7 +11,7 @@ plugin: keywords: database support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1021844-neues-database-plugin - version: 1.6.9 # Plugin version + version: 1.6.12 # Plugin version sh_minversion: 1.9.3.2 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance @@ -97,7 +97,7 @@ item_attributes: # Definition of item attributes defined by this plugin database: type: str - valid_list_ci: ['', 'yes', 'init', 'true'] + valid_list_ci: ['', 'no', 'yes', 'init', 'init2', 'true'] duplicate_use: True description: de: "Wenn auf 'yes' oder 'true' gesetzt, werden die Werte des Items in die Datenbank geschrieben. Wenn auf 'init' gesetzt, wird zusätzlich beim Start von SmartHomeNG der Wert des Items aus der Datenbank gelesen." diff --git a/database/user_doc.rst b/database/user_doc.rst index d88c8ff85..c4e0335ed 100755 --- a/database/user_doc.rst +++ b/database/user_doc.rst @@ -219,3 +219,67 @@ Die `log` Tabelle enthält die folgenden Spalten: Es gibt aktuell nur eine Möglichkeit die Anzahl der Datensätze pro Item zu begrenzen: Durch die Angabe des Item Attributs ``database_maxage`` wird das maximale Alter der Einträge eines Items begrenzt. Regelmässig werden Werte deren Zeitstempel älter ist als die angegebene Zeitspanne aus der Datenbank gelöscht. + +Datenbankfunktionen für Datenreihen/Plots +========================================= + +Nachfolgende Tabelle zeigt die implementierten Datenbankfunktionen für Plots. Die Funktionen werden dabei auf die verfügbaren Datenbankwerte eines bestimmten Intervalls, definiert +mit t_start und t_end, ausgeführt und liefern Datenreihen zurück. + +=============== ================================================================ +Funktion Bedeutung +=============== ================================================================ +avg Mittelwert +integrate Diskretes Integral der Werte über der Zeit +differentiate Diskretes Differential der Werte über der Zeit +diff Differenz zu dem vorherigen Wert +duration Dauer des Wertes +count Anzahl aller Werte, die eine bestimmte Bedingung erfüllen +countall Anzahl aller Werte +min Minimalwert +max Maximalwert +on Prozentzahl der Werte > 0 +sum Summe der Werte +raw Rohwerte ohne Berechnung +=============== ================================================================ + +Über das SmartVisu Widget plot.period können die genannten Datenbankfunktionen genutzt werden, um Plots der Werte zu erstellen. +Beispiele finden sich in der SmartVisu Dokumentation unter plot.period. + +Datenbankfunktionen für Einzelauswertungen +========================================== + +Das Plugin stellt außerdem Funktionen bereit, um Berechnungen über alle Werte innerhalb eines definierten Intervalls (t_start und t_end) zu machen und als +**genau ein** Wert zurückzugeben. Diese Funktionen können dann z.B. aus Logiken heraus verwendet werden. + +Folgende Funktionen werden hier unterstützt: + +=============== ================================================================ +Funktion Bedeutung +=============== ================================================================ +avg Mittelwert +integrate Diskretes Integral über der Zeit +count Anzahl aller Werte, die eine bestimmte Bedingung erfüllen +countall Anzahl aller Werte +min Minimalwert +max Maximalwert +diff Differenz +on Prozentzahl der Werte > 0 +sum Summe aller Werte +raw Rohwerte +=============== ================================================================ + +Beispiele: + +Integral aller Werte der letzten Woche, z.B. um Leistungen zu einem Verbrauch aufzuintegrieren + +.. code-block:: yaml + + item.db('integrate','1w') + +Differenz der Datenbank zwischen heute und vor einem Jahr: + +.. code-block:: yaml + + item.db('diff','365d', 'now') + diff --git a/database/webif/templates/base_database.html b/database/webif/templates/base_database.html index e477d155a..8a305091a 100755 --- a/database/webif/templates/base_database.html +++ b/database/webif/templates/base_database.html @@ -7,20 +7,8 @@ + + + + {% endblock pluginscripts %} {% block headtable %} - - +
- + {% set first = True %} - {% for key, value in p._db._params.items() %} - {% if loop.index % 4 == 0 %} - - {% endif %} - {% if key != "passwd" %} - - {% else %} - - {% endif %} - {% if loop.index % 3 > 0 and loop.last %} - - {% endif %} - {% if loop.index % 4 == 0 and not first %} - - {% endif %} - {% endfor %} + {% if p._db %} + {% for key, value in p._db._params.items() %} + {% if loop.index % 4 == 0 %} + + {% endif %} + {% if key != "passwd" %} + + {% else %} + + {% endif %} + {% if loop.index % 3 > 0 and loop.last %} + + {% endif %} + {% if loop.index % 4 == 0 and not first %} + + {% endif %} + {% endfor %} + {% endif %} - - - - + + + + + +
{{ _('Verbunden') }}{% if p._db._connected %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}{% if p._db._connected %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %} {{ _('Treiber') }} {{ p.db_driver }} {{ _('Startup Delay') }} {{ (p.startup_run_delay) }}s
{{ key }}{{ value }}{{ key }}{% for letter in value %}*{% endfor %}
{{ key }}{{ value }}{{ key }}{% for letter in value %}*{% endfor %}
{{ _('Item in Berechnung') }}{{ p.active_queue_item }}{{ _('Arbeitsvorrat') }}{{ p.queue_backlog }} {{ _('Items') }} {{ _('Item in Berechnung') }}{{ p.active_queue_item }}{{ _('Arbeitsvorrat') }}{{ p.queue_backlog }} {{ _('Items') }} {{ _('LogLevel') }} + {{ p.log_level }} + {% if p.log_level == 10 %} + {{' || ' }} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {% endif %} +
@@ -242,25 +364,23 @@ {% block buttons %}
- + -
- - -
+ +
+
+ + +
{% endblock %} {% set tabcount = 3 %} {% set tab1title = "" ~ plugin_shortname ~ " Items (" ~ item_count ~ ")" %} -{% if maintenance %} - {% set tab2title = "" ~ plugin_shortname ~ " Maintenance" %} -{% else %} - {% set tab2title = "hidden" %} -{% endif %} +{% set tab2title = "" ~ plugin_shortname ~ " Maintenance" %} {% set tab3title = "" ~ plugin_shortname ~ " API/Doku" %} @@ -283,13 +403,23 @@ {{ p.get_item_config(item._path)['db_addon_fct'] }} {{ _(p.get_item_config(item)['cycle']|string) }} {% if p.get_item_config(item)['startup'] %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %} - {{ item._value | float }} - {{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }} - {{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }} +   +   +   + + + + + + {% endfor %} +
+ + +
{% endblock bodytab1 %} @@ -299,11 +429,15 @@ {{ _('00_items') }} - {{ p.get_item_path_list('database_addon', True) }} + {{ p._all_items() }} + + + {{ _('01_ondemand_items') }} + {{ p._ondemand_items() }} {{ _('02_admin_items') }} - {{ p.get_item_path_list('database_addon', 'admin') }} + {{ p._admin_items() }} {{ _('10_daily_items') }} @@ -373,18 +507,6 @@ {{ _('27_vorjahresendwert_dict') }} {{ p.previous_values['year'] }} - - {{ _('get_item_list') }} - {{ p.get_item_list('database_addon', True) }} - - - {{ _('_plg_item_dict') }} - {{ p._plg_item_dict }} - - - {{ _('work_item_queue_thread') }} - {% if p.work_item_queue_thread != None %}{{ p.work_item_queue_thread.is_alive() }}{% endif %} - {% endblock bodytab2 %} diff --git a/denon/__init__.py b/denon/__init__.py new file mode 100755 index 000000000..56b0f7818 --- /dev/null +++ b/denon/__init__.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2016 +######################################################################### +# This file is part of SmartHomeNG +# +# Denon AV plugin for SmartDevicePlugin class +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG If not, see . +######################################################################### + +import builtins +import os +import sys + +if __name__ == '__main__': + builtins.SDP_standalone = True + + class SmartPlugin(): + pass + + class SmartPluginWebIf(): + pass + + BASE = os.path.sep.join(os.path.realpath(__file__).split(os.path.sep)[:-3]) + sys.path.insert(0, BASE) + +else: + builtins.SDP_standalone = False + +from lib.model.sdp.globals import (PLUGIN_ATTR_NET_HOST, PLUGIN_ATTR_CONNECTION, PLUGIN_ATTR_SERIAL_PORT, PLUGIN_ATTR_CONN_TERMINATOR, CONN_NULL, CONN_NET_TCP_CLI, CONN_SER_ASYNC) +from lib.model.smartdeviceplugin import SmartDevicePlugin, Standalone + +# from .webif import WebInterface + +builtins.SDP_standalone = False + +CUSTOM_INPUT_NAME_COMMAND = 'custom_inputnames' + + +class denon(SmartDevicePlugin): + """ Device class for Denon AV. """ + + PLUGIN_VERSION = '1.0.1' + + def on_connect(self, by=None): + self.logger.debug("Checking for custom input names.") + self.send_command('general.custom_inputnames') + + def _set_device_defaults(self): + + self._custom_inputnames = {} + + # set our own preferences concerning connections + if PLUGIN_ATTR_NET_HOST in self._parameters and self._parameters[PLUGIN_ATTR_NET_HOST]: + self._parameters[PLUGIN_ATTR_CONNECTION] = CONN_NET_TCP_CLI + elif PLUGIN_ATTR_SERIAL_PORT in self._parameters and self._parameters[PLUGIN_ATTR_SERIAL_PORT]: + self._parameters[PLUGIN_ATTR_CONNECTION] = CONN_SER_ASYNC + else: + self.logger.error('Neither host nor serialport set, connection not possible. Using dummy connection, plugin will not work') + self._parameters[PLUGIN_ATTR_CONNECTION] = CONN_NULL + + b = self._parameters[PLUGIN_ATTR_CONN_TERMINATOR].encode() + b = b.decode('unicode-escape').encode() + self._parameters[PLUGIN_ATTR_CONN_TERMINATOR] = b + + # we need to receive data via callback, as the "reply" can be unrelated to + # the sent command. Getting it as return value would assign it to the wrong + # command and discard it... so break the "return result"-chain and don't + # return anything + def _send(self, data_dict): + self._connection.send(data_dict) + + def _transform_send_data(self, data=None, **kwargs): + if isinstance(data, dict): + data['limit_response'] = self._parameters[PLUGIN_ATTR_CONN_TERMINATOR] + data['payload'] = f'{data.get("payload", "")}{data["limit_response"].decode("unicode-escape")}' + return data + + def on_data_received(self, by, data, command=None): + + commands = None + if command is not None: + self.logger.debug(f'received data "{data}" from {by} for command {command}') + commands = [command] + else: + # command == None means that we got raw data from a callback and + # don't know yet to which command this belongs to. So find out... + self.logger.debug(f'received data "{data}" from {by} without command specification') + + # command can be a string (classic single command) or + # - new - a list of strings if multiple commands are identified + # in that case, work on all strings + commands = self._commands.get_commands_from_reply(data) + if not commands: + if self._discard_unknown_command: + self.logger.debug(f'data "{data}" did not identify a known command, ignoring it') + return + else: + self.logger.debug(f'data "{data}" did not identify a known command, forwarding it anyway for {self._unknown_command}') + self._dispatch_callback(self._unknown_command, data, by) + + # TODO: remove later? + assert(isinstance(commands, list)) + + # process all commands + for command in commands: + self._check_for_custominputs(command, data) + custom = None + if self.custom_commands: + custom = self._get_custom_value(command, data) + + base_command = command + value = None + try: + if CUSTOM_INPUT_NAME_COMMAND in command: + value = self._custom_inputnames + else: + value = self._commands.get_shng_data(command, data) + except OSError as e: + self.logger.warning(f'received data "{data}" for command {command}, error {e} occurred while converting. Discarding data.') + else: + self.logger.debug(f'received data "{data}" for command {command} converted to value {value}') + self._dispatch_callback(command, value, by) + + self._process_additional_data(base_command, data, value, custom, by) + + def _check_for_custominputs(self, command, data): + if CUSTOM_INPUT_NAME_COMMAND in command and isinstance(data, str): + tmp = data.split(' ', 1) + src = tmp[0][5:] + name = tmp[1] + self._custom_inputnames[src] = name + +if __name__ == '__main__': + s = Standalone(lms, sys.argv[0]) diff --git a/denon/commands.py b/denon/commands.py new file mode 100755 index 000000000..27afbd0fa --- /dev/null +++ b/denon/commands.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +""" commands for dev pioneer + +Most commands send a string (fixed for reading, attached data for writing) +while parsing the response works by extracting the needed string part by +regex. Some commands translate the device data into readable values via +lookups. +""" + +models = { + 'ALL': ['general.custom_inputnames', 'general.power', 'general.setupmenu', 'general.soundmode', 'general.inputsignal', 'general.inputrate', 'general.inputformat', 'general.inputresolution', 'general.outputresolution', 'general.ecomode', + 'tuner.preset', 'tuner.presetup', 'tuner.presetdown', 'tuner.frequency', 'tuner.frequencyup', 'tuner.frequencydown', 'tuner.band', 'tuner.tuningmode', + 'zone1.control', + 'zone1.settings.sound.general.audioinput', 'zone1.settings.sound.general.cinema_eq', 'zone1.settings.sound.general.hdmiaudioout', 'zone1.settings.sound.general.dynamicrange', 'zone1.settings.sound.general.subwoofertoggle', 'zone1.settings.sound.general.subwoofer', 'zone1.settings.sound.general.subwooferup', 'zone1.settings.sound.general.subwooferdown', 'zone1.settings.sound.general.lfe', 'zone1.settings.sound.general.lfeup', 'zone1.settings.sound.general.lfedown', 'zone1.settings.sound.tone_control', + 'zone1.settings.sound.channel_level.front_left', 'zone1.settings.sound.channel_level.front_right', 'zone1.settings.sound.channel_level.front_height_left', 'zone1.settings.sound.channel_level.front_height_right', 'zone1.settings.sound.channel_level.front_center', 'zone1.settings.sound.channel_level.surround_left', 'zone1.settings.sound.channel_level.surround_right', 'zone1.settings.sound.channel_level.surroundback_left', 'zone1.settings.sound.channel_level.surroundback_right', 'zone1.settings.sound.channel_level.rear_height_left', 'zone1.settings.sound.channel_level.rear_height_right', 'zone1.settings.sound.channel_level.subwoofer', + 'zone2.control', 'zone2.settings.sound.general.hdmiout'], + 'AVR-X6300H': ['info', 'tuner.hd', 'zone1.settings.sound.channel_level.subwoofer2', 'zone1.settings.sound.general.speakersetup', 'zone1.settings.sound.general.dialogenhance', + 'zone1.settings.video', + 'zone2.settings.sound.tone_control', 'zone2.settings.sound.channel_level', 'zone2.settings.sound.general.HPF', + 'zone3'], + 'AVR-X4300H': ['zone1.settings.sound.channel_level.subwoofer2', 'zone1.settings.video', 'zone1.settings.sound.general.dialogtoggle', 'zone1.settings.sound.general.dialog', 'zone1.settings.sound.general.dialogup', 'zone1.settings.sound.general.dialogdown', 'zone1.settings.sound.general.speakersetup', + 'zone2.settings.sound.tone_control', 'zone2.settings.sound.channel_level', 'zone2.settings.sound.general.HPF', + 'zone3'], + 'AVR-X3300W': ['tuner.title', 'tuner.album', 'tuner.artist', 'general.display', + 'zone1.settings.sound.channel_level.subwoofer2', 'zone1.settings.video.aspectratio', 'zone1.settings.video.hdmiresolution', 'zone1.settings.video.videoresolution', 'zone1.settings.video.videoinput', 'zone1.settings.video.pictureenhancer', 'zone1.settings.video.videoprocessingmode', + 'zone1.settings.sound.general.dialogtoggle', 'zone1.settings.sound.general.dialog', 'zone1.settings.sound.general.dialogup', 'zone1.settings.sound.general.dialogdown', 'zone2.settings.sound.tone_control', 'zone2.settings.sound.channel_level', 'zone2.settings.sound.general.HPF'], + 'AVR-X2300W': ['tuner.title', 'tuner.album', 'tuner.artist', 'general.display', + 'zone1.settings.video', 'zone1.settings.sound.general.dialogtoggle', 'zone1.settings.sound.general.dialog', 'zone1.settings.sound.general.dialogup', 'zone1.settings.sound.general.dialogdown', + 'zone2.settings.sound.channel_level'], + 'AVR-X1300W': ['tuner.title', 'tuner.album', 'tuner.artist', 'general.display', + 'zone1.settings.sound.general.dialogtoggle', 'zone1.settings.sound.general.dialog', 'zone1.settings.sound.general.dialogup', 'zone1.settings.sound.general.dialogdown'] +} + +commands = { + 'info': { + 'fullmodel': {'read': True, 'write': False, 'read_cmd': 'NSFRN ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'NSFRN\s(.*)', 'item_attrs': {'initial': True}}, + 'model': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'VIALL(AVR.*)', 'item_attrs': {'initial': True}}, + 'serialnumber': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLS/N\.(.*)'}, + 'main': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLMAIN:(.*)'}, + 'mainfbl': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLMAINFBL:(.*)'}, + 'dsp1': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLDSP1:(.*)'}, + 'dsp2': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLDSP2:(.*)'}, + 'dsp3': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLDSP3:(.*)'}, + 'dsp4': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLDSP4:(.*)'}, + 'apld': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLAPLD:(.*)'}, + 'vpld': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLVPLD:(.*)'}, + 'guidat': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLGUIDAT:(.*)'}, + 'heosversion': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLHEOSVER:(.*)'}, + 'heosbuild': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLHEOSBLD:(.*)'}, + 'heosmod': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLHEOSMOD:(.*)'}, + 'heoscnf': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLHEOSCNF:(.*)'}, + 'heoslanguage': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLHEOSLCL:(.*)'}, + 'mac': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLMAC:(.*)'}, + 'wifimac': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLWIFIMAC:(.*)'}, + 'btmac': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLBTMAC:(.*)'}, + 'audyif': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLAUDYIF:(.*)'}, + 'productid': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLPRODUCTID:(.*)'}, + 'packageid': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'num', 'dev_datatype': 'raw', 'reply_pattern': r'VIALLPACKAGEID:(.*)'}, + 'cmp': {'read': True, 'write': False, 'read_cmd': 'VIALL?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'VIALLCMP:(.*)'}, + 'region': {'read': True, 'write': False, 'read_cmd': 'SYMODTUN ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'SYMODTUN\s(.*)', 'item_attrs': {'initial': True}}, + }, + 'general': { + 'custom_inputnames': {'read': True, 'write': False, 'read_cmd': 'SSFUN ?', 'item_type': 'dict', 'dev_datatype': 'str', 'reply_pattern': 'SSFUN(.*)', 'item_attrs': {'item_template': 'custom_inputnames'}}, + 'power': {'read': True, 'write': True, 'read_cmd': 'PW?', 'write_cmd': 'PW{VALUE}', 'item_type': 'bool', 'dev_datatype': 'str', 'reply_pattern': 'PW{LOOKUP}', 'lookup': 'POWER'}, + 'setupmenu': {'read': True, 'write': True, 'read_cmd': 'MNMEN?', 'write_cmd': 'MNMEN {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'MNMEN (ON|OFF)'}, + 'display': {'read': True, 'write': False, 'read_cmd': 'NSE', 'item_type': 'str', 'dev_datatype': 'DenonDisplay', 'reply_pattern': 'NSE(.*)'}, + 'soundmode': {'read': True, 'write': False, 'read_cmd': 'SSSMG ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'SSSMG {LOOKUP}', 'lookup': 'SOUNDMODE', 'item_attrs': {'initial': True}}, + 'allzonestereo': {'read': True, 'write': False, 'read_cmd': 'MNZST?', 'write_cmd': 'MNZST {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': r'MNZST {ON|OFF}', 'item_attrs': {'initial': True}}, + 'inputsignal': {'read': True, 'write': False, 'read_cmd': 'SSINFAISSIG ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'SSINFAISSIG {LOOKUP}', 'lookup': 'INPUTSIGNAL', 'item_attrs': {'initial': True}}, + 'inputrate': {'read': True, 'write': False, 'read_cmd': 'SSINFAISFSV ?', 'item_type': 'num', 'dev_datatype': 'convert0', 'reply_pattern': r'SSINFAISFSV (\d{2,3}|NON)', 'item_attrs': {'initial': True}}, + 'inputformat': {'read': True, 'write': False, 'read_cmd': 'SSINFAISFOR ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'SSINFAISFOR (.*)', 'item_attrs': {'initial': True}}, + 'inputresolution': {'read': True, 'write': False, 'read_cmd': 'SSINFSIGRES ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'SSINFSIGRES I(.*)', 'item_attrs': {'initial': True}}, + 'outputresolution': {'read': True, 'write': False, 'read_cmd': 'SSINFSIGRES ?', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'SSINFSIGRES O(.*)', 'item_attrs': {'read_group_levels': 0}}, + 'ecomode': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['ON', 'OFF', 'AUTO']}, 'read_cmd': 'ECO?', 'write_cmd': 'ECO{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'ECO{VALID_LIST_CI}'}, + }, + 'tuner': { + 'title': {'read': True, 'write': False, 'read_cmd': 'NSE', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'NSE1(.*)', 'item_attrs': {'initial': True}}, + 'album': {'read': True, 'write': False, 'read_cmd': 'NSE', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'NSE4(.*)', 'item_attrs': {'read_group_levels': 0}}, + 'artist': {'read': True, 'write': False, 'read_cmd': 'NSE', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'NSE2(.*)', 'item_attrs': {'read_group_levels': 0}}, + 'preset': {'read': True, 'write': True, 'read_cmd': 'TPAN?', 'item_type': 'num', 'write_cmd': 'TPAN{RAW_VALUE:02}', 'dev_datatype': 'convert0', 'reply_pattern': r'TPAN(\d{2}|OFF)', 'item_attrs': {'initial': True}}, + 'presetup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TPANUP', 'dev_datatype': 'raw'}, + 'presetdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TPANDOWN', 'dev_datatype': 'raw'}, + 'frequency': {'read': True, 'write': True, 'read_cmd': 'TFAN?', 'item_type': 'num', 'write_cmd': 'TFAN{RAW_VALUE:06}', 'dev_datatype': 'num', 'reply_pattern': r'TFAN(\d{6})', 'item_attrs': {'initial': True}}, + 'frequencyup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TFANUP', 'dev_datatype': 'raw'}, + 'frequencydown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TFANDOWN', 'dev_datatype': 'raw'}, + 'band': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['AM', 'FM']}, 'read_cmd': 'TMAN?', 'item_type': 'str', 'write_cmd': 'TMAN{RAW_VALUE_UPPER}', 'dev_datatype': 'raw', 'reply_pattern': r'TMAN{VALID_LIST_CI}', 'item_attrs': {'initial': True}}, + 'tuningmode': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['AUTO', 'MANUAL']}, 'read_cmd': 'TMAN?', 'item_type': 'str', 'write_cmd': 'TMAN{RAW_VALUE_UPPER}', 'dev_datatype': 'raw', 'reply_pattern': r'TMAN{VALID_LIST_CI}'}, + 'hd': { + 'channel': {'read': True, 'write': True, 'read_cmd': 'TFHD?', 'item_type': 'num', 'write_cmd': 'TFHD{RAW_VALUE:06}', 'dev_datatype': 'num', 'reply_pattern': r'TFHD(\d{6})', 'item_attrs': {'initial': True}}, + 'channelup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TFHDUP', 'dev_datatype': 'raw'}, + 'channeldown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TFHDDOWN', 'dev_datatype': 'raw'}, + 'multicastchannel': {'read': True, 'write': True, 'read_cmd': 'TFHD?', 'item_type': 'num', 'write_cmd': 'TFHDMC{RAW_VALUE:01}', 'dev_datatype': 'num', 'reply_pattern': r'TFHDMC(\d{1})'}, + 'presetmemory': {'read': True, 'write': True, 'item_type': 'num', 'write_cmd': 'TPHDMEM{RAW_VALUE:02}', 'dev_datatype': 'convert0', 'reply_pattern': r'TPHDMEM(\d{2}|OFF)'}, + 'preset': {'read': True, 'write': True, 'read_cmd': 'TPHD?', 'item_type': 'num', 'write_cmd': 'TPHD{RAW_VALUE:02}', 'dev_datatype': 'convert0', 'reply_pattern': r'TPHD(\d{2}|OFF)'}, + 'presetup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TPHDUP', 'dev_datatype': 'raw'}, + 'presetdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'TPHDDOWN', 'dev_datatype': 'raw'}, + 'band': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['AM', 'FM', 'AUTO', 'MANUAL', 'AUTOHD', 'ANAAUTO', 'ANAMANU']}, 'read_cmd': 'TMHD?', 'item_type': 'str', 'write_cmd': 'TMHD{RAW_VALUE_UPPER}', 'dev_datatype': 'num', 'reply_pattern': r'TMHD{VALID_LIST_CI}', 'item_attrs': {'initial': True}} + } + + }, + 'zone1': { + 'control': { + 'power': {'read': True, 'write': True, 'read_cmd': 'ZM?', 'write_cmd': 'ZM{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'ZM(ON|OFF)', 'item_attrs': {'initial': True}}, + 'mute': {'read': True, 'write': True, 'read_cmd': 'MU?', 'write_cmd': 'MU{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'MU(ON|OFF)', 'item_attrs': {'initial': True}}, + 'volume': {'read': True, 'write': True, 'read_cmd': 'MV?', 'write_cmd': 'MV{VALUE}', 'item_type': 'num', 'dev_datatype': 'DenonVol', 'reply_pattern': r'MV(\d{2,3})', 'cmd_settings': {'force_min': 0.0, 'valid_max': 98.0}, 'item_attrs': {'initial': True}}, + 'volumeup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'MVUP', 'dev_datatype': 'raw'}, + 'volumedown': {'read': False, 'write': True, 'write_cmd': 'MVDOWN', 'item_type': 'bool', 'dev_datatype': 'raw'}, + 'volumemax': {'opcode': '{VALUE}', 'read': True, 'write': False, 'item_type': 'num', 'dev_datatype': 'str', 'reply_pattern': r'MVMAX (\d{2,3})', 'item_attrs': {'initial': True}}, + 'input': {'read': True, 'write': True, 'read_cmd': 'SI?', 'write_cmd': 'SI{VALUE}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'SI{LOOKUP}', 'lookup': 'INPUT', 'item_attrs': {'item_template': 'input', 'initial': True}}, + 'listeningmode': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['MOVIE', 'MUSIC', 'GAME', 'DIRECT', 'PURE DIRECT', 'STEREO', 'AUTO', 'DOLBY DIGITAL', 'DOLBY SURROUND', 'DTS SURROUND', 'NEURAL:X', 'AURO3D', 'AURO2DSURR', 'MCH STEREO', 'ROCK ARENA', 'JAZZ CLUB', 'MONO MOVIE', 'MATRIX', 'VIDEO GAME', 'VIRTUAL', 'LEFT', 'RIGHT']}, 'read_cmd': 'MS?', 'write_cmd': 'MS{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': r'\s?MS(.*)', 'item_attrs': {'initial': True}}, + 'sleep': {'read': True, 'write': True, 'item_type': 'num', 'read_cmd': 'SLP?', 'write_cmd': 'SLP{VALUE}', 'dev_datatype': 'convert0', 'reply_pattern': r'SLP(\d{3}|OFF)', 'cmd_settings': {'force_min': 0, 'force_max': 120}, 'item_attrs': {'initial': True}}, + 'standby': {'read': True, 'write': True, 'item_type': 'num', 'read_cmd': 'STBY?', 'write_cmd': 'STBY{VALUE}', 'dev_datatype': 'DenonStandby1', 'reply_pattern': r'STBY(\d{2}M|OFF)', 'cmd_settings': {'valid_list_ci': [0, 15, 30, 60]}, 'item_attrs': {'initial': True}}, + }, + 'settings': { + 'sound': { + 'channel_level': { + 'front_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVFL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVFL (\d{2,3})'}, + 'front_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVFR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVFR (\d{2,3})'}, + 'front_height_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVFHL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVFHL (\d{2,3})'}, + 'front_height_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVFHR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVFHR (\d{2,3})'}, + 'front_center': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVC {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVC (\d{2,3})'}, + 'surround_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVSL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVSL (\d{2,3})'}, + 'surround_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVSR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVSR (\d{2,3})'}, + 'surroundback_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVSBL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVSBL (\d{2,3})'}, + 'surroundback_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVSBR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVSBR (\d{2,3})'}, + 'rear_height_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVRHL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVRHL (\d{2,3})'}, + 'rear_height_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVRHR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVRHR (\d{2,3})'}, + 'subwoofer': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVSW {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVSW (\d{2,3})'}, + 'subwoofer2': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12.0, 'valid_max': 12.0}, 'read_cmd': 'CV?', 'item_type': 'num', 'write_cmd': 'CVSW2 {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'CVSW2 (\d{2,3})'} + }, + 'tone_control': { + 'tone': {'read': True, 'write': True, 'read_cmd': 'PSTONE CTRL ?', 'write_cmd': 'PSTONE CTRL {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'PSTONE CTRL (ON|OFF)'}, + 'treble': {'read': True, 'write': True, 'read_cmd': 'PSTRE ?', 'item_type': 'num', 'cmd_settings': {'force_min': -6, 'force_max': 6}, 'write_cmd': 'PSTRE {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'PSTRE (\d{2})'}, + 'trebleup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSTRE UP', 'dev_datatype': 'raw'}, + 'trebledown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSTRE DOWN', 'dev_datatype': 'raw'}, + 'bass': {'read': True, 'write': True, 'read_cmd': 'PSBAS ?', 'item_type': 'num', 'cmd_settings': {'force_min': -6, 'force_max': 6}, 'write_cmd': 'PSBAS {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'PSBAS (\d{2})'}, + 'bassup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSBAS UP', 'dev_datatype': 'raw'}, + 'bassdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSBAS DOWN', 'dev_datatype': 'raw'} + }, + 'general': { + 'cinema_eq': {'read': True, 'write': True, 'read_cmd': 'PSCINEMA EQ. ?', 'write_cmd': 'PSCINEMA EQ.{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'PSCINEMA EQ.(ON|OFF)'}, + 'dynamic_eq': {'read': True, 'write': True, 'read_cmd': 'PSDYNEQ ?', 'write_cmd': 'PSDYNEQ {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'PSDYNEQ (ON|OFF)'}, + 'multeq': {'read': True, 'write': True, 'read_cmd': 'PSMULTEQ: ?', 'write_cmd': 'PSMULTEQ: {RAW_VALUE_UPPER}', 'item_type': 'bool', 'dev_datatype': 'str', 'cmd_settings': {'valid_list_ci': ['AUDYSSEY', 'BYP.LR', 'FLAT', 'OFF']}, 'reply_pattern': 'PSMULTEQ:{VALID_LIST_CI}'}, + 'dynamic_vol': {'read': True, 'write': True, 'read_cmd': 'DYNVOL ?', 'write_cmd': 'DYNVOL {VALUE}', 'item_type': 'bool', 'dev_datatype': 'num', 'reply_pattern': 'DYNVOL {LOOKUP}', 'lookup': 'DYNVOL'}, + 'speakersetup': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['FL', 'HF']}, 'read_cmd': 'PSSP: ?', 'write_cmd': 'PSSP:{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'PSSP:{VALID_LIST_CI}'}, + 'hdmiaudioout': {'read': True, 'write': True, 'item_type': 'str', 'read_cmd': 'VSAUDIO ?', 'write_cmd': 'VSAUDIO {RAW_VALUE_UPPER}', 'dev_datatype': 'str', 'reply_pattern': 'VSAUDIO {VALID_LIST_CI}', 'cmd_settings': {'valid_list_ci': ['TV', 'AMP']}}, + 'dynamicrange': {'read': True, 'write': True, 'read_cmd': 'PSDRC ?', 'item_type': 'num', 'write_cmd': 'PSDRC {VALUE}', 'dev_datatype': 'str', 'reply_pattern': 'PSDRC {LOOKUP}', 'lookup': 'DYNAM'}, + 'dialogtoggle': {'read': True, 'write': True, 'read_cmd': 'PSDIL ?', 'write_cmd': 'PSDIL {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'PSDIL (ON|OFF)'}, + 'dialog': {'read': True, 'write': True, 'read_cmd': 'PSDIL ?', 'item_type': 'num', 'cmd_settings': {'force_min': -12, 'force_max': 12}, 'write_cmd': 'PSDIL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'PSDIL (\d{2})'}, + 'dialogup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSDIL UP', 'dev_datatype': 'raw'}, + 'dialogdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSDIL DOWN', 'dev_datatype': 'raw'}, + 'dialogenhance': {'read': True, 'write': True, 'read_cmd': 'PSDEH ?', 'write_cmd': 'PSDEH {VALUE}', 'item_type': 'num', 'dev_datatype': 'str', 'reply_pattern': 'PSDEH {LOOKUP}', 'lookup': 'DIALOG'}, + 'subwoofertoggle': {'read': True, 'write': True, 'read_cmd': 'PSSWL ?', 'write_cmd': 'PSSWL {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'PSSWL (ON|OFF)'}, + 'subwoofer': {'read': True, 'write': True, 'read_cmd': 'PSSWL ?', 'item_type': 'num', 'cmd_settings': {'force_min': -12, 'valid_max': 12}, 'write_cmd': 'PSSWL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'PSSWL (\d{2})'}, + 'subwooferup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSSWL UP', 'dev_datatype': 'raw'}, + 'subwooferdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSSWL DOWN', 'dev_datatype': 'raw'}, + 'lfe': {'read': True, 'write': True, 'read_cmd': 'PSLFE ?', 'item_type': 'num', 'cmd_settings': {'force_min': -10, 'valid_max': 3}, 'write_cmd': 'PSLFE {RAW_VALUE:02}', 'dev_datatype': 'int', 'reply_pattern': r'PSLFE (\d{2})'}, + 'lfeup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSLFE UP', 'dev_datatype': 'raw'}, + 'lfedown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'PSLFE DOWN', 'dev_datatype': 'raw'}, + 'digitalinput': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['AUTO', 'PCM', 'DTS']}, 'read_cmd': 'DC?', 'write_cmd': 'DC{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'DC{VALID_LIST_CI}'}, + 'audioinput': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['AUTO', 'HDMI', 'DIGITAL', 'ANALOG']}, 'read_cmd': 'SD?', 'write_cmd': 'SD{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'SD{VALID_LIST_CI}'} + } + }, + 'video': { + 'aspectratio': {'read': True, 'write': True, 'read_cmd': 'VSASP ?', 'write_cmd': 'VSASP{VALUE}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'VSASP{LOOKUP}', 'lookup': 'ASPECT'}, + 'hdmimonitor': {'read': True, 'write': True, 'cmd_settings': {'force_min': 0, 'force_max': 2}, 'read_cmd': 'VSMONI ?', 'write_cmd': 'VSMONI{VALUE}', 'item_type': 'num', 'dev_datatype': 'convertAuto', 'reply_pattern': 'VSMONI(AUTO|1|2)'}, + 'hdmiresolution': {'read': True, 'write': True, 'read_cmd': 'VSSCH ?', 'write_cmd': 'VSSCH{VALUE}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'VSSCH{LOOKUP}', 'lookup': 'RESOLUTION'}, + 'videoprocessingmode': {'read': True, 'write': True, 'item_type': 'str', 'read_cmd': 'VSVPM ?', 'write_cmd': 'VSVPM{VALUE}', 'dev_datatype': 'str', 'reply_pattern': 'VSVPM{LOOKUP}', 'lookup': 'VIDEOPROCESS'}, + 'videoresolution': {'read': True, 'write': True, 'read_cmd': 'VSSC ?', 'write_cmd': 'VSSC{VALUE}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'VSSC{LOOKUP}', 'lookup': 'RESOLUTION'}, + 'pictureenhancer': {'read': True, 'write': True, 'read_cmd': 'PVENH ?', 'item_type': 'num', 'cmd_settings': {'force_min': 0, 'force_max': 12}, 'write_cmd': 'PVENH {RAW_VALUE:02}', 'dev_datatype': 'int', 'reply_pattern': r'PVENH (\d{2})'}, + 'videoinput': {'read': True, 'write': True, 'cmd_settings': {'valid_list_ci': ['DVD', 'BD', 'TV', 'SAT/CBL', 'MPLAY', 'GAME' 'AUX1', 'AUX2', 'CD', 'ON', 'OFF']}, 'read_cmd': 'SV?', 'write_cmd': 'SV{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'SV{VALID_LIST_CI}'} + } + } + }, + 'zone2': { + 'control': { + 'power': {'read': True, 'write': True, 'read_cmd': 'Z2?', 'write_cmd': 'Z2{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'Z2(ON|OFF)'}, + 'mute': {'read': True, 'write': True, 'read_cmd': 'Z2MU?', 'write_cmd': 'Z2MU{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'Z2MU(ON|OFF)'}, + 'volume': {'read': True, 'write': True, 'read_cmd': 'Z2?', 'write_cmd': 'Z2{VALUE}', 'item_type': 'num', 'dev_datatype': 'DenonVol', 'reply_pattern': r'Z2(\d{2,3})', 'cmd_settings': {'force_min': 0.0, 'valid_max': 98.0}}, + 'volumeup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z2UP', 'dev_datatype': 'raw'}, + 'volumedown': {'read': False, 'write': True, 'write_cmd': 'Z2DOWN', 'item_type': 'bool', 'dev_datatype': 'raw'}, + 'input': {'read': True, 'write': True, 'read_cmd': 'Z2?', 'write_cmd': 'Z2{VALUE}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'Z2{LOOKUP}', 'lookup': 'INPUT', 'item_attrs': {'item_template': 'input'}}, + 'sleep': {'read': True, 'write': True, 'item_type': 'num', 'read_cmd': 'Z2SLP?', 'write_cmd': 'Z2SLP{VALUE}', 'dev_datatype': 'convert0', 'reply_pattern': r'Z2SLP(\d{3}|OFF)', 'cmd_settings': {'force_min': 0, 'force_max': 120}}, + 'standby': {'read': True, 'write': True, 'item_type': 'num', 'read_cmd': 'Z2STBY?', 'write_cmd': 'Z2STBY{VALUE}', 'dev_datatype': 'DenonStandby', 'reply_pattern': r'Z2STBY(\dH|OFF)', 'cmd_settings': {'valid_list_ci': [0, 2, 4, 8]}}, + }, + 'settings': { + 'sound': { + 'channel_level': { + 'front_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12, 'valid_max': 12}, 'read_cmd': 'Z2CV?', 'item_type': 'num', 'write_cmd': 'Z2CVFL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z2CVFL (\d{2})'}, + 'front_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12, 'valid_max': 12}, 'read_cmd': 'Z2CV?', 'item_type': 'num', 'write_cmd': 'Z2CVFR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z2CVFR (\d{2})'} + }, + 'tone_control': { + 'treble': {'read': True, 'write': True, 'read_cmd': 'Z2PSTRE ?', 'item_type': 'num', 'cmd_settings': {'force_min': -10, 'force_max': 10}, 'write_cmd': 'Z2PSTRE {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z2PSTRE (\d{2})'}, + 'trebleup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z2PSTRE UP', 'dev_datatype': 'raw'}, + 'trebledown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z2PSTRE DOWN', 'dev_datatype': 'raw'}, + 'bass': {'read': True, 'write': True, 'read_cmd': 'Z2PSBAS ?', 'item_type': 'num', 'cmd_settings': {'force_min': -10, 'force_max': 10}, 'write_cmd': 'Z2PSBAS {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z2PSBAS (\d{2})'}, + 'bassup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z2PSBAS UP', 'dev_datatype': 'raw'}, + 'bassdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z2PSBAS DOWN', 'dev_datatype': 'raw'} + }, + 'general': { + 'hdmiout': {'read': True, 'write': True, 'item_type': 'str', 'read_cmd': 'Z2HDA?', 'write_cmd': 'Z2HDA {RAW_VALUE_UPPER}', 'dev_datatype': 'str', 'reply_pattern': 'Z2HDA {VALID_LIST_CI}', 'cmd_settings': {'valid_list_ci': ['THR', 'PCM']}}, + 'HPF': {'read': True, 'write': True, 'read_cmd': 'Z2HPF?', 'write_cmd': 'Z2HPF{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'Z2HPF(ON|OFF)'} + } + } + } + }, + 'zone3': { + 'control': { + 'power': {'read': True, 'write': True, 'read_cmd': 'Z3?', 'write_cmd': 'Z3{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'Z3(ON|OFF)'}, + 'mute': {'read': True, 'write': True, 'read_cmd': 'Z3MU?', 'write_cmd': 'Z3MU{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'Z3MU(ON|OFF)'}, + 'volume': {'read': True, 'write': True, 'read_cmd': 'Z3?', 'write_cmd': 'Z3{VALUE}', 'item_type': 'num', 'dev_datatype': 'DenonVol', 'reply_pattern': r'Z3(\d{2,3})', 'cmd_settings': {'force_min': 0.0, 'valid_max': 98.0}}, + 'volumeup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z3UP', 'dev_datatype': 'raw'}, + 'volumedown': {'read': False, 'write': True, 'write_cmd': 'Z3DOWN', 'item_type': 'bool', 'dev_datatype': 'raw'}, + 'sleep': {'read': True, 'write': True, 'item_type': 'num', 'read_cmd': 'Z3SLP?', 'write_cmd': 'Z3SLP{VALUE}', 'dev_datatype': 'convert0', 'reply_pattern': r'Z3SLP(\d{3}|OFF)', 'cmd_settings': {'force_min': 0, 'valid_max': 120}}, + 'standby': {'read': True, 'write': True, 'item_type': 'num', 'read_cmd': 'Z3STBY?', 'write_cmd': 'Z3STBY{VALUE}', 'dev_datatype': 'DenonStandby', 'reply_pattern': r'Z3STBY(\dH|OFF)', 'cmd_settings': {'valid_list_ci': [0, 2, 4, 8]}}, + 'input': {'read': True, 'write': True, 'read_cmd': 'Z3?', 'write_cmd': 'Z3{RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'str', 'reply_pattern': 'Z3{LOOKUP}', 'lookup': 'INPUT3', 'item_attrs': {'item_template': 'input'}} + }, + 'settings': { + 'sound': { + 'channel_level': { + 'front_left': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12, 'valid_max': 12}, 'read_cmd': 'Z3CV?', 'item_type': 'num', 'write_cmd': 'Z3CVFL {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z3CVFL (\d{2})'}, + 'front_right': {'read': True, 'write': True, 'cmd_settings': {'force_min': -12, 'valid_max': 12}, 'read_cmd': 'Z3CV?', 'item_type': 'num', 'write_cmd': 'Z3CVFR {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z3CVFR (\d{2})'} + }, + 'tone_control': { + 'treble': {'read': True, 'write': True, 'read_cmd': 'Z3PSTRE ?', 'item_type': 'num', 'cmd_settings': {'force_min': -10, 'force_max': 10}, 'write_cmd': 'Z3PSTRE {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z3PSTRE (\d{2})'}, + 'trebleup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z3PSTRE UP', 'dev_datatype': 'raw'}, + 'trebledown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z3PSTRE DOWN', 'dev_datatype': 'raw'}, + 'bass': {'read': True, 'write': True, 'read_cmd': 'Z3PSBAS ?', 'item_type': 'num', 'cmd_settings': {'force_min': -10, 'force_max': 10}, 'write_cmd': 'Z3PSBAS {VALUE}', 'dev_datatype': 'remap50to0', 'reply_pattern': r'Z3PSBAS (\d{2})'}, + 'bassup': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z3PSBAS UP', 'dev_datatype': 'raw'}, + 'bassdown': {'read': False, 'write': True, 'item_type': 'bool', 'write_cmd': 'Z3PSBAS DOWN', 'dev_datatype': 'raw'} + }, + 'general': { + 'HPF': {'read': True, 'write': True, 'read_cmd': 'Z3HPF?', 'write_cmd': 'Z3HPF{VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': 'Z3HPF(ON|OFF)'}, + } + } + } + } +} + +lookups = { + 'ALL': { + 'INPUTSIGNAL': { + '01': 'Analog', + '02': 'PCM', + '03': 'Dolby Digital', + '04': 'Dolby TrueHD', + '05': 'Dolby Atmos', + '06': 'DTS', + '07': '07', + '08': 'DTS-HD Hi Res', + '09': 'DTS-HD MSTR', + '10': '10', + '11': '11', + '12': 'Unknown', + '13': 'PCM Zero', + '14': '14', + '15': 'MP3', + '16': '16', + '17': 'AAC', + '18': 'FLAC', + }, + 'RESOLUTION': { + '48P': '480p/576p', + '10I': '1080i', + '72P': '720p', + '10P': '1080p', + '10P24': '1080p:24Hz', + '4K': '4K', + '4KF': '4K(60/50)', + 'AUTO': 'Auto' + }, + 'ASPECT': { + 'NRM': '4:3', + 'FUL': '16:9' + }, + 'POWER': { + 'ON': True, + 'STANDBY': False + }, + 'SOUNDMODE': { + 'MUS': 'MUSIC', + 'MOV': 'MOVIE', + 'GAM': 'GAME', + 'PUR': 'PURE DIRECT' + }, + 'DYNAM': { + 'OFF': 0, + 'LOW': 1, + 'MID': 2, + 'HI': 3, + 'AUTO': 4 + }, + 'DYNVOL': { + 'OFF': 0, + 'LIT': 1, + 'MED': 2, + 'HEV': 3 + }, + 'DIALOG': { + 'OFF': 0, + 'LOW': 1, + 'MED': 2, + 'HIGH': 3, + 'AUTO': 4 + }, + 'VIDEOPROCESS': { + 'MOVI': 'Movie', + 'BYP': 'Bypass', + 'GAME': 'Game', + 'AUTO': 'Auto' + }, + 'INPUT': { + 'SOURCE': 'SOURCE', + 'TUNER': 'TUNER', + 'DVD': 'DVD', + 'BD': 'BD', + 'TV': 'TV', + 'SAT/CBL': 'SAT/CBL', + 'MPLAY': 'MPLAY', + 'GAME': 'GAME', + 'HDRADIO': 'HDRADIO', + 'NET': 'NET', + 'AUX1': 'AUX1', + 'BT': 'BT' + }, + 'INPUT3': { + 'SOURCE': 'SOURCE', + 'TUNER': 'TUNER', + 'PHONO': 'PHONO', + 'CD': 'CD', + 'DVD': 'DVD', + 'BD': 'BD', + 'TV': 'TV', + 'SAT/CBL': 'SAT/CBL', + 'MPLAY': 'MPLAY', + 'GAME': 'GAME', + 'NET': 'NET', + 'AUX1': 'AUX1', + 'AUX2': 'AUX2', + 'BT': 'BT', + 'QUICK1': 'QUICK1', + 'QUICK2': 'QUICK2', + 'QUICK3': 'QUICK3', + 'QUICK4': 'QUICK4', + 'QUICK5': 'QUICK5', + 'QUICK1 MEMORY': 'QUICK1 MEMORY', + 'QUICK2 MEMORY': 'QUICK2 MEMORY', + 'QUICK3 MEMORY': 'QUICK3 MEMORY', + 'QUICK4 MEMORY': 'QUICK4 MEMORY', + 'QUICK5 MEMORY': 'QUICK5 MEMORY' + } + }, + 'AVR-X6300H': { + 'INPUT': { + 'PHONO': 'PHONO', + 'CD': 'CD', + 'AUX2': 'AUX2' + } + }, + 'AVR-X4300H': { + 'INPUT': { + 'PHONO': 'PHONO', + 'CD': 'CD', + 'AUX2': 'AUX2' + } + }, + 'AVR-X3300W': { + 'INPUT': { + 'CD': 'CD', + 'AUX2': 'AUX2', + 'IRADIO': 'IRADIO', + 'SERVER': 'SERVER', + 'FAVORITES': 'FAVORITES', + 'USB/IPOD': 'USB/IPOD', + 'USB': 'USB', + 'IPD': 'IPD', + 'IRP': 'IRP', + 'FVP': 'FVP' + } + }, + 'AVR-X2300W': { + 'INPUT': { + 'CD': 'CD', + 'AUX2': 'AUX2', + 'IRADIO': 'IRADIO', + 'SERVER': 'SERVER', + 'FAVORITES': 'FAVORITES', + 'USB/IPOD': 'USB/IPOD', + 'USB': 'USB', + 'IPD': 'IPD', + 'IRP': 'IRP', + 'FVP': 'FVP' + } + }, + 'AVR-X1300W': { + 'INPUT': { + 'IRADIO': 'IRADIO', + 'SERVER': 'SERVER', + 'FAVORITES': 'FAVORITES', + 'USB/IPOD': 'USB/IPOD', + 'USB': 'USB', + 'IPD': 'IPD', + 'IRP': 'IRP', + 'FVP': 'FVP' + } + } +} + +item_templates = { + 'custom_inputnames': { + 'cache': True, + 'reverse': { + 'type': 'dict', + 'eval': '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}', + 'update': { + 'type': 'bool', + 'eval': 'sh...timer(2, {})', + 'eval_trigger': '...' + } + } + }, + 'input': { + 'on_change': [".custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value]",], + 'custom_name': { + 'type': 'str', + 'on_change': ".. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value]" + } + } +} diff --git a/denon/datatypes.py b/denon/datatypes.py new file mode 100755 index 000000000..11dbe3490 --- /dev/null +++ b/denon/datatypes.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab + +import lib.model.sdp.datatypes as DT + + +# read only. Depending on a status field, the result is sliced. +class DT_DenonDisplay(DT.Datatype): + def get_shng_data(self, data, type=None, **kwargs): + infotype = data[3:4] + if infotype.isdigit(): + if infotype == 0: + data = data[4:] + elif infotype == 1: + data = data[5:] + else: + data = data[6:] + return data + + return None + + +# handle pseudo-decimal values without decimal point +class DT_DenonVol(DT.Datatype): + def get_send_data(self, data, **kwargs): + if int(data) == data: + # "real" integer + return f'{int(data):02}' + else: + # float with fractional value + return f'{int(data):02}5' + + def get_shng_data(self, data, type=None, **kwargs): + if len(data) == 3: + return int(data) / 10 + else: + return data + + +class DT_DenonStandby(DT.Datatype): + def get_send_data(self, data, **kwargs): + return 'OFF' if data == 0 else f"{data:01}H" + + def get_shng_data(self, data, type=None, **kwargs): + return 0 if data == 'OFF' else data.split('H')[0] + + +class DT_DenonStandby1(DT.Datatype): + def get_send_data(self, data, **kwargs): + return 'OFF' if data == 0 else f"{data:02}M" + + def get_shng_data(self, data, type=None, **kwargs): + return 0 if data == 'OFF' else data.split('M')[0] + + +class DT_onoff(DT.Datatype): + def get_send_data(self, data, **kwargs): + return 'ON' if data else 'OFF' + + def get_shng_data(self, data, type=None, **kwargs): + return False if data == 'OFF' else True + + +class DT_convert0(DT.Datatype): + def get_send_data(self, data, **kwargs): + return 'OFF' if data == 0 else f"{data:03}" + + def get_shng_data(self, data, type=None, **kwargs): + return 0 if data in ['OFF', 'NON'] else data + + +class DT_convertAuto(DT.Datatype): + def get_send_data(self, data, **kwargs): + return 'AUTO' if data == 0 else data + + def get_shng_data(self, data, type=None, **kwargs): + return 0 if data == 'AUTO' else data + + +class DT_remap50to0(DT.Datatype): + def get_send_data(self, data, **kwargs): + if int(data) == data: + # "real" integer + return f'{(int(data)+50):02}' + else: + # float with fractional value + return f'{(int(data)+50):02}5' + + def get_shng_data(self, data, type=None, **kwargs): + if len(data) == 3: + return int(data) / 10 - 50 + else: + return int(data) - 50 diff --git a/denon/plugin.yaml b/denon/plugin.yaml new file mode 100755 index 000000000..3ab797598 --- /dev/null +++ b/denon/plugin.yaml @@ -0,0 +1,8151 @@ + +plugin: + type: interface + description: Denon AV-Receiver + maintainer: OnkelAndy + tester: Morg + state: develop + keywords: iot device av denon sdp + version: 1.0.1 + sh_minversion: 1.9.5 + py_minversion: 3.7 + multi_instance: false + restartable: true + classname: denon + +parameters: + + model: + type: str + mandatory: false + valid_list: + - '' + - AVR-X6300H + - AVR-X4300H + - AVR-X3300W + - AVR-X2300W + - AVR-X1300W + + description: + de: Modellauswahl + en: model selection + + timeout: + type: num + default: 3 + + description: + de: Timeout für Geräteantwort + en: timeout for device replies + + terminator: + type: str + default: "\r" + + description: + de: Zeilen-/Antwortbegrenzer + en: line or reply terminator + + binary: + type: bool + default: false + + description: + de: Binärer Übertragungsmodus + en: binary communication mode + + baudrate: + type: num + default: 9600 + + description: + de: Serielle Übertragungsgeschwindigkeit + en: serial transmission speed + + bytesize: + type: num + default: 8 + + description: + de: Anzahl Datenbits + en: number of data bits + + parity: + type: str + default: N + valid_list: + - N + - E + - O + - M + - S + + description: + de: Parität + en: parity + + stopbits: + type: num + default: 1 + + description: + de: Anzahl Stopbits + en: number of stop bits + + host: + type: str + mandatory: false + + description: + de: Netzwerkziel/-host + en: network host + + port: + type: int + default: 23 + + description: + de: Port für Netzwerkverbindung + en: network port + + serialport: + type: str + mandatory: false + + description: + de: Serieller Anschluss (z.B. /dev/ttyUSB0 oder COM1) + en: serial port (e.g. /dev/ttyUSB0 or COM1) + + conn_type: + type: str + mandatory: false + valid_list: + - '' + - net_tcp_client + - serial_async + + description: + de: Verbindungstyp + en: connection type + + command_class: + type: str + default: SDPCommandParseStr + valid_list: + - SDPCommand + - SDPCommandParseStr + + description: + de: Klasse für Verarbeitung von Kommandos + en: class for command processing + + autoreconnect: + type: bool + default: true + + description: + de: Automatisches Neuverbinden bei Abbruch + en: automatic reconnect on disconnect + + autoconnect: + type: bool + mandatory: false + + description: + de: Automatisches Verbinden bei Senden + en: automatic connect on send + + connect_retries: + type: num + default: 5 + + description: + de: Anzahl Verbindungsversuche + en: number of connect retries + + connect_cycle: + type: num + default: 3 + + description: + de: Pause zwischen Verbindungsversuchen + en: wait time between connect retries + + retry_cycle: + type: num + default: 30 + + description: + de: Pause zwischen Durchgängen von Verbindungsversuchen + en: wait time between connect retry rounds + + retry_suspend: + type: num + default: 3 + + description: + de: Anzahl von Durchgängen vor Verbindungsabbruch oder Suspend-Modus + en: number of connect rounds before giving up / entering suspend mode + + suspend_item: + type: str + default: '' + + description: + de: Item-Pfad für das Standby-Item + en: item path for standby switch item + + +item_attributes: + + denon_command: + type: str + + description: + de: Legt das angegebene Kommando für das Item fest + en: Assigns the given command to the item + + denon_read: + type: bool + + description: + de: Item liest/erhält Werte vom Gerät + en: Item reads/receives data from the device + + denon_read_group: + type: list(str) + + description: + de: Weist das Item der angegebenen Gruppe zum gesammelten Lesen zu. Mehrere Gruppen können als Liste angegeben werden. + en: Assigns the item to the given group for collective reading. Multiple groups can be provided as a list. + + denon_read_cycle: + type: num + + description: + de: Konfiguriert ein Intervall in Sekunden für regelmäßiges Lesen + en: Configures a interval in seconds for cyclic read actions + + denon_read_initial: + type: bool + + description: + de: Legt fest, dass der Wert beim Start vom Gerät gelesen wird + en: Sets item value to be read from the device on startup + + denon_write: + type: bool + + description: + de: Änderung des Items werden an das Gerät gesendet + en: Changes to this item will be sent to the device + + denon_read_group_trigger: + type: str + + description: + de: Wenn diesem Item ein beliebiger Wert zugewiesen wird, werden alle zum Lesen konfigurierten Items der angegebenen Gruppe neu vom Gerät gelesen, bei Gruppe 0 werden alle zum Lesen konfigurierten Items neu gelesen. Das Item kann nicht gleichzeitig mit denon_command belegt werden. + en: When set to any value, all items configured for reading for the given group will update their value from the device, if group is 0, all items configured for reading will update. The item cannot be used with denon_command in parallel. + + denon_lookup: + type: str + + description: + de: Der Inhalt der Lookup-Tabelle mit dem angegebenen Namen wird beim Start einmalig als dict oder list in das Item geschrieben. + en: The lookup table with the given name will be assigned to the item in dict or list format once on startup. + + description_long: + de: "Der Inhalt der Lookup-Tabelle mit dem angegebenen Namen wird beim\nStart einmalig als dict oder list in das Item geschrieben.\n\n\nDurch Anhängen von \"#\" an den Namen der Tabelle kann die Art\nder Tabelle ausgewählt werden:\n- fwd liefert die Tabelle Gerät -> SmartHomeNG (Standard)\n- rev liefert die Tabelle SmartHomeNG -> Gerät\n- rci liefert die Tabelle SmarthomeNG -> Gerät in Kleinbuchstaben\n- list liefert die Liste der Namen für SmartHomeNG (z.B. für Auswahllisten in der Visu)" + en: "The lookup table with the given name will be assigned to the item\nin dict or list format once on startup.\n\n\nBy appending \"#\" to the tables name the type of table can\nbe selected:\n- fwd returns the table device -> SmartHomeNG (default)\n- rev returns the table SmartHomeNG -> device\n- rci returns the table SmartHomeNG -> device in lower case\n- list return the list of names for SmartHomeNG (e.g. for selection dropdowns in visu applications)" + +item_structs: + + info: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: info + + fullmodel: + type: str + denon_command: info.fullmodel + denon_read: true + denon_write: false + denon_read_group: + - info + denon_read_initial: true + + model: + type: str + denon_command: info.model + denon_read: true + denon_write: false + denon_read_group: + - info + denon_read_initial: true + + serialnumber: + type: num + denon_command: info.serialnumber + denon_read: true + denon_write: false + denon_read_group: + - info + + main: + type: str + denon_command: info.main + denon_read: true + denon_write: false + denon_read_group: + - info + + mainfbl: + type: num + denon_command: info.mainfbl + denon_read: true + denon_write: false + denon_read_group: + - info + + dsp1: + type: num + denon_command: info.dsp1 + denon_read: true + denon_write: false + denon_read_group: + - info + + dsp2: + type: num + denon_command: info.dsp2 + denon_read: true + denon_write: false + denon_read_group: + - info + + dsp3: + type: num + denon_command: info.dsp3 + denon_read: true + denon_write: false + denon_read_group: + - info + + dsp4: + type: num + denon_command: info.dsp4 + denon_read: true + denon_write: false + denon_read_group: + - info + + apld: + type: num + denon_command: info.apld + denon_read: true + denon_write: false + denon_read_group: + - info + + vpld: + type: num + denon_command: info.vpld + denon_read: true + denon_write: false + denon_read_group: + - info + + guidat: + type: num + denon_command: info.guidat + denon_read: true + denon_write: false + denon_read_group: + - info + + heosversion: + type: str + denon_command: info.heosversion + denon_read: true + denon_write: false + denon_read_group: + - info + + heosbuild: + type: num + denon_command: info.heosbuild + denon_read: true + denon_write: false + denon_read_group: + - info + + heosmod: + type: num + denon_command: info.heosmod + denon_read: true + denon_write: false + denon_read_group: + - info + + heoscnf: + type: str + denon_command: info.heoscnf + denon_read: true + denon_write: false + denon_read_group: + - info + + heoslanguage: + type: str + denon_command: info.heoslanguage + denon_read: true + denon_write: false + denon_read_group: + - info + + mac: + type: str + denon_command: info.mac + denon_read: true + denon_write: false + denon_read_group: + - info + + wifimac: + type: str + denon_command: info.wifimac + denon_read: true + denon_write: false + denon_read_group: + - info + + btmac: + type: str + denon_command: info.btmac + denon_read: true + denon_write: false + denon_read_group: + - info + + audyif: + type: num + denon_command: info.audyif + denon_read: true + denon_write: false + denon_read_group: + - info + + productid: + type: num + denon_command: info.productid + denon_read: true + denon_write: false + denon_read_group: + - info + + packageid: + type: num + denon_command: info.packageid + denon_read: true + denon_write: false + denon_read_group: + - info + + cmp: + type: str + denon_command: info.cmp + denon_read: true + denon_write: false + denon_read_group: + - info + + region: + type: str + denon_command: info.region + denon_read: true + denon_write: false + denon_read_group: + - info + denon_read_initial: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - general + + display: + type: str + denon_command: general.display + denon_read: true + denon_write: false + denon_read_group: + - general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - general + denon_read_initial: true + + allzonestereo: + type: bool + denon_command: general.allzonestereo + denon_read: true + denon_write: false + denon_read_group: + - general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: tuner + + title: + type: str + denon_command: tuner.title + denon_read: true + denon_write: false + denon_read_group: + - tuner + denon_read_initial: true + + album: + type: str + denon_command: tuner.album + denon_read: true + denon_write: false + + artist: + type: str + denon_command: tuner.artist + denon_read: true + denon_write: false + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - tuner + + hd: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: tuner.hd + + channel: + type: num + denon_command: tuner.hd.channel + denon_read: true + denon_write: true + denon_read_group: + - tuner + - tuner.hd + denon_read_initial: true + + channelup: + type: bool + denon_command: tuner.hd.channelup + denon_read: false + denon_write: true + + channeldown: + type: bool + denon_command: tuner.hd.channeldown + denon_read: false + denon_write: true + + multicastchannel: + type: num + denon_command: tuner.hd.multicastchannel + denon_read: true + denon_write: true + denon_read_group: + - tuner + - tuner.hd + + presetmemory: + type: num + denon_command: tuner.hd.presetmemory + denon_read: true + denon_write: true + + preset: + type: num + denon_command: tuner.hd.preset + denon_read: true + denon_write: true + denon_read_group: + - tuner + - tuner.hd + + presetup: + type: bool + denon_command: tuner.hd.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.hd.presetdown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.hd.band + denon_read: true + denon_write: true + denon_read_group: + - tuner + - tuner.hd + denon_read_initial: true + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + subwoofer2: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer2 + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + dynamic_eq: + type: bool + denon_command: zone1.settings.sound.general.dynamic_eq + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + multeq: + type: bool + denon_command: zone1.settings.sound.general.multeq + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + dynamic_vol: + type: bool + denon_command: zone1.settings.sound.general.dynamic_vol + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + speakersetup: + type: str + denon_command: zone1.settings.sound.general.speakersetup + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + dialogtoggle: + type: bool + denon_command: zone1.settings.sound.general.dialogtoggle + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + dialog: + type: num + denon_command: zone1.settings.sound.general.dialog + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + dialogup: + type: bool + denon_command: zone1.settings.sound.general.dialogup + denon_read: false + denon_write: true + + dialogdown: + type: bool + denon_command: zone1.settings.sound.general.dialogdown + denon_read: false + denon_write: true + + dialogenhance: + type: num + denon_command: zone1.settings.sound.general.dialogenhance + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + digitalinput: + type: str + denon_command: zone1.settings.sound.general.digitalinput + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.sound + - zone1.settings.sound.general + + video: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone1.settings.video + + aspectratio: + type: str + denon_command: zone1.settings.video.aspectratio + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + hdmimonitor: + type: num + denon_command: zone1.settings.video.hdmimonitor + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + hdmiresolution: + type: str + denon_command: zone1.settings.video.hdmiresolution + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + videoprocessingmode: + type: str + denon_command: zone1.settings.video.videoprocessingmode + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + videoresolution: + type: str + denon_command: zone1.settings.video.videoresolution + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + pictureenhancer: + type: num + denon_command: zone1.settings.video.pictureenhancer + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + videoinput: + type: str + denon_command: zone1.settings.video.videoinput + denon_read: true + denon_write: true + denon_read_group: + - zone1 + - zone1.settings + - zone1.settings.video + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2.settings.sound.channel_level + + front_left: + type: num + denon_command: zone2.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.settings + - zone2.settings.sound + - zone2.settings.sound.channel_level + + front_right: + type: num + denon_command: zone2.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.settings + - zone2.settings.sound + - zone2.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2.settings.sound.tone_control + + treble: + type: num + denon_command: zone2.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.settings + - zone2.settings.sound + - zone2.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone2.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone2.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone2.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.settings + - zone2.settings.sound + - zone2.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone2.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone2.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.settings + - zone2.settings.sound + - zone2.settings.sound.general + + HPF: + type: bool + denon_command: zone2.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - zone2 + - zone2.settings + - zone2.settings.sound + - zone2.settings.sound.general + + zone3: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3.control + + power: + type: bool + denon_command: zone3.control.power + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.control + + mute: + type: bool + denon_command: zone3.control.mute + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.control + + volume: + type: num + denon_command: zone3.control.volume + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.control + + volumeup: + type: bool + denon_command: zone3.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone3.control.volumedown + denon_read: false + denon_write: true + + sleep: + type: num + denon_command: zone3.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.control + + standby: + type: num + denon_command: zone3.control.standby + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.control + + input: + type: str + denon_command: zone3.control.input + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3.settings.sound.channel_level + + front_left: + type: num + denon_command: zone3.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.settings + - zone3.settings.sound + - zone3.settings.sound.channel_level + + front_right: + type: num + denon_command: zone3.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.settings + - zone3.settings.sound + - zone3.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3.settings.sound.tone_control + + treble: + type: num + denon_command: zone3.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.settings + - zone3.settings.sound + - zone3.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone3.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone3.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone3.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.settings + - zone3.settings.sound + - zone3.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone3.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone3.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: zone3.settings.sound.general + + HPF: + type: bool + denon_command: zone3.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - zone3 + - zone3.settings + - zone3.settings.sound + - zone3.settings.sound.general + + ALL: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.tuner + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.tuner + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone1 + - ALL.zone1.settings + - ALL.zone1.settings.sound + - ALL.zone1.settings.sound.general + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone2.settings.sound + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: ALL.zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - ALL + - ALL.zone2 + - ALL.zone2.settings + - ALL.zone2.settings.sound + - ALL.zone2.settings.sound.general + + AVR-X6300H: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H + + info: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.info + + fullmodel: + type: str + denon_command: info.fullmodel + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + denon_read_initial: true + + model: + type: str + denon_command: info.model + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + denon_read_initial: true + + serialnumber: + type: num + denon_command: info.serialnumber + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + main: + type: str + denon_command: info.main + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + mainfbl: + type: num + denon_command: info.mainfbl + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + dsp1: + type: num + denon_command: info.dsp1 + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + dsp2: + type: num + denon_command: info.dsp2 + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + dsp3: + type: num + denon_command: info.dsp3 + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + dsp4: + type: num + denon_command: info.dsp4 + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + apld: + type: num + denon_command: info.apld + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + vpld: + type: num + denon_command: info.vpld + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + guidat: + type: num + denon_command: info.guidat + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + heosversion: + type: str + denon_command: info.heosversion + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + heosbuild: + type: num + denon_command: info.heosbuild + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + heosmod: + type: num + denon_command: info.heosmod + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + heoscnf: + type: str + denon_command: info.heoscnf + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + heoslanguage: + type: str + denon_command: info.heoslanguage + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + mac: + type: str + denon_command: info.mac + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + wifimac: + type: str + denon_command: info.wifimac + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + btmac: + type: str + denon_command: info.btmac + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + audyif: + type: num + denon_command: info.audyif + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + productid: + type: num + denon_command: info.productid + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + packageid: + type: num + denon_command: info.packageid + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + cmp: + type: str + denon_command: info.cmp + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + + region: + type: str + denon_command: info.region + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.info + denon_read_initial: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.tuner + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + + hd: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.tuner.hd + + channel: + type: num + denon_command: tuner.hd.channel + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + - AVR-X6300H.tuner.hd + denon_read_initial: true + + channelup: + type: bool + denon_command: tuner.hd.channelup + denon_read: false + denon_write: true + + channeldown: + type: bool + denon_command: tuner.hd.channeldown + denon_read: false + denon_write: true + + multicastchannel: + type: num + denon_command: tuner.hd.multicastchannel + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + - AVR-X6300H.tuner.hd + + presetmemory: + type: num + denon_command: tuner.hd.presetmemory + denon_read: true + denon_write: true + + preset: + type: num + denon_command: tuner.hd.preset + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + - AVR-X6300H.tuner.hd + + presetup: + type: bool + denon_command: tuner.hd.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.hd.presetdown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.hd.band + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.tuner + - AVR-X6300H.tuner.hd + denon_read_initial: true + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + subwoofer2: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer2 + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + speakersetup: + type: str + denon_command: zone1.settings.sound.general.speakersetup + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + dialogenhance: + type: num + denon_command: zone1.settings.sound.general.dialogenhance + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.sound + - AVR-X6300H.zone1.settings.sound.general + + video: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone1.settings.video + + aspectratio: + type: str + denon_command: zone1.settings.video.aspectratio + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + hdmimonitor: + type: num + denon_command: zone1.settings.video.hdmimonitor + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + hdmiresolution: + type: str + denon_command: zone1.settings.video.hdmiresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + videoprocessingmode: + type: str + denon_command: zone1.settings.video.videoprocessingmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + videoresolution: + type: str + denon_command: zone1.settings.video.videoresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + pictureenhancer: + type: num + denon_command: zone1.settings.video.pictureenhancer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + videoinput: + type: str + denon_command: zone1.settings.video.videoinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone1 + - AVR-X6300H.zone1.settings + - AVR-X6300H.zone1.settings.video + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2.settings.sound.channel_level + + front_left: + type: num + denon_command: zone2.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.settings + - AVR-X6300H.zone2.settings.sound + - AVR-X6300H.zone2.settings.sound.channel_level + + front_right: + type: num + denon_command: zone2.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.settings + - AVR-X6300H.zone2.settings.sound + - AVR-X6300H.zone2.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2.settings.sound.tone_control + + treble: + type: num + denon_command: zone2.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.settings + - AVR-X6300H.zone2.settings.sound + - AVR-X6300H.zone2.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone2.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone2.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone2.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.settings + - AVR-X6300H.zone2.settings.sound + - AVR-X6300H.zone2.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone2.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone2.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.settings + - AVR-X6300H.zone2.settings.sound + - AVR-X6300H.zone2.settings.sound.general + + HPF: + type: bool + denon_command: zone2.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone2 + - AVR-X6300H.zone2.settings + - AVR-X6300H.zone2.settings.sound + - AVR-X6300H.zone2.settings.sound.general + + zone3: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3.control + + power: + type: bool + denon_command: zone3.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.control + + mute: + type: bool + denon_command: zone3.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.control + + volume: + type: num + denon_command: zone3.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.control + + volumeup: + type: bool + denon_command: zone3.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone3.control.volumedown + denon_read: false + denon_write: true + + sleep: + type: num + denon_command: zone3.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.control + + standby: + type: num + denon_command: zone3.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.control + + input: + type: str + denon_command: zone3.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3.settings.sound.channel_level + + front_left: + type: num + denon_command: zone3.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.settings + - AVR-X6300H.zone3.settings.sound + - AVR-X6300H.zone3.settings.sound.channel_level + + front_right: + type: num + denon_command: zone3.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.settings + - AVR-X6300H.zone3.settings.sound + - AVR-X6300H.zone3.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3.settings.sound.tone_control + + treble: + type: num + denon_command: zone3.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.settings + - AVR-X6300H.zone3.settings.sound + - AVR-X6300H.zone3.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone3.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone3.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone3.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.settings + - AVR-X6300H.zone3.settings.sound + - AVR-X6300H.zone3.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone3.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone3.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X6300H.zone3.settings.sound.general + + HPF: + type: bool + denon_command: zone3.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - AVR-X6300H + - AVR-X6300H.zone3 + - AVR-X6300H.zone3.settings + - AVR-X6300H.zone3.settings.sound + - AVR-X6300H.zone3.settings.sound.general + + AVR-X4300H: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.tuner + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.tuner + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + subwoofer2: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer2 + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + speakersetup: + type: str + denon_command: zone1.settings.sound.general.speakersetup + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + dialogtoggle: + type: bool + denon_command: zone1.settings.sound.general.dialogtoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + dialog: + type: num + denon_command: zone1.settings.sound.general.dialog + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + dialogup: + type: bool + denon_command: zone1.settings.sound.general.dialogup + denon_read: false + denon_write: true + + dialogdown: + type: bool + denon_command: zone1.settings.sound.general.dialogdown + denon_read: false + denon_write: true + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.sound + - AVR-X4300H.zone1.settings.sound.general + + video: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone1.settings.video + + aspectratio: + type: str + denon_command: zone1.settings.video.aspectratio + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + hdmimonitor: + type: num + denon_command: zone1.settings.video.hdmimonitor + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + hdmiresolution: + type: str + denon_command: zone1.settings.video.hdmiresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + videoprocessingmode: + type: str + denon_command: zone1.settings.video.videoprocessingmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + videoresolution: + type: str + denon_command: zone1.settings.video.videoresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + pictureenhancer: + type: num + denon_command: zone1.settings.video.pictureenhancer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + videoinput: + type: str + denon_command: zone1.settings.video.videoinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone1 + - AVR-X4300H.zone1.settings + - AVR-X4300H.zone1.settings.video + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2.settings.sound.channel_level + + front_left: + type: num + denon_command: zone2.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.settings + - AVR-X4300H.zone2.settings.sound + - AVR-X4300H.zone2.settings.sound.channel_level + + front_right: + type: num + denon_command: zone2.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.settings + - AVR-X4300H.zone2.settings.sound + - AVR-X4300H.zone2.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2.settings.sound.tone_control + + treble: + type: num + denon_command: zone2.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.settings + - AVR-X4300H.zone2.settings.sound + - AVR-X4300H.zone2.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone2.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone2.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone2.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.settings + - AVR-X4300H.zone2.settings.sound + - AVR-X4300H.zone2.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone2.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone2.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.settings + - AVR-X4300H.zone2.settings.sound + - AVR-X4300H.zone2.settings.sound.general + + HPF: + type: bool + denon_command: zone2.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone2 + - AVR-X4300H.zone2.settings + - AVR-X4300H.zone2.settings.sound + - AVR-X4300H.zone2.settings.sound.general + + zone3: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3.control + + power: + type: bool + denon_command: zone3.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.control + + mute: + type: bool + denon_command: zone3.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.control + + volume: + type: num + denon_command: zone3.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.control + + volumeup: + type: bool + denon_command: zone3.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone3.control.volumedown + denon_read: false + denon_write: true + + sleep: + type: num + denon_command: zone3.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.control + + standby: + type: num + denon_command: zone3.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.control + + input: + type: str + denon_command: zone3.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3.settings.sound.channel_level + + front_left: + type: num + denon_command: zone3.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.settings + - AVR-X4300H.zone3.settings.sound + - AVR-X4300H.zone3.settings.sound.channel_level + + front_right: + type: num + denon_command: zone3.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.settings + - AVR-X4300H.zone3.settings.sound + - AVR-X4300H.zone3.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3.settings.sound.tone_control + + treble: + type: num + denon_command: zone3.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.settings + - AVR-X4300H.zone3.settings.sound + - AVR-X4300H.zone3.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone3.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone3.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone3.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.settings + - AVR-X4300H.zone3.settings.sound + - AVR-X4300H.zone3.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone3.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone3.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X4300H.zone3.settings.sound.general + + HPF: + type: bool + denon_command: zone3.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - AVR-X4300H + - AVR-X4300H.zone3 + - AVR-X4300H.zone3.settings + - AVR-X4300H.zone3.settings.sound + - AVR-X4300H.zone3.settings.sound.general + + AVR-X3300W: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + + display: + type: str + denon_command: general.display + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.tuner + + title: + type: str + denon_command: tuner.title + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.tuner + denon_read_initial: true + + album: + type: str + denon_command: tuner.album + denon_read: true + denon_write: false + + artist: + type: str + denon_command: tuner.artist + denon_read: true + denon_write: false + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.tuner + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + subwoofer2: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer2 + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + dialogtoggle: + type: bool + denon_command: zone1.settings.sound.general.dialogtoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + dialog: + type: num + denon_command: zone1.settings.sound.general.dialog + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + dialogup: + type: bool + denon_command: zone1.settings.sound.general.dialogup + denon_read: false + denon_write: true + + dialogdown: + type: bool + denon_command: zone1.settings.sound.general.dialogdown + denon_read: false + denon_write: true + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.sound + - AVR-X3300W.zone1.settings.sound.general + + video: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone1.settings.video + + aspectratio: + type: str + denon_command: zone1.settings.video.aspectratio + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.video + + hdmiresolution: + type: str + denon_command: zone1.settings.video.hdmiresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.video + + videoprocessingmode: + type: str + denon_command: zone1.settings.video.videoprocessingmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.video + + videoresolution: + type: str + denon_command: zone1.settings.video.videoresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.video + + pictureenhancer: + type: num + denon_command: zone1.settings.video.pictureenhancer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.video + + videoinput: + type: str + denon_command: zone1.settings.video.videoinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone1 + - AVR-X3300W.zone1.settings + - AVR-X3300W.zone1.settings.video + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2.settings.sound.channel_level + + front_left: + type: num + denon_command: zone2.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.settings + - AVR-X3300W.zone2.settings.sound + - AVR-X3300W.zone2.settings.sound.channel_level + + front_right: + type: num + denon_command: zone2.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.settings + - AVR-X3300W.zone2.settings.sound + - AVR-X3300W.zone2.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2.settings.sound.tone_control + + treble: + type: num + denon_command: zone2.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.settings + - AVR-X3300W.zone2.settings.sound + - AVR-X3300W.zone2.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone2.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone2.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone2.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.settings + - AVR-X3300W.zone2.settings.sound + - AVR-X3300W.zone2.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone2.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone2.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X3300W.zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.settings + - AVR-X3300W.zone2.settings.sound + - AVR-X3300W.zone2.settings.sound.general + + HPF: + type: bool + denon_command: zone2.settings.sound.general.HPF + denon_read: true + denon_write: true + denon_read_group: + - AVR-X3300W + - AVR-X3300W.zone2 + - AVR-X3300W.zone2.settings + - AVR-X3300W.zone2.settings.sound + - AVR-X3300W.zone2.settings.sound.general + + AVR-X2300W: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + + display: + type: str + denon_command: general.display + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.tuner + + title: + type: str + denon_command: tuner.title + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.tuner + denon_read_initial: true + + album: + type: str + denon_command: tuner.album + denon_read: true + denon_write: false + + artist: + type: str + denon_command: tuner.artist + denon_read: true + denon_write: false + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.tuner + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + dialogtoggle: + type: bool + denon_command: zone1.settings.sound.general.dialogtoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + dialog: + type: num + denon_command: zone1.settings.sound.general.dialog + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + dialogup: + type: bool + denon_command: zone1.settings.sound.general.dialogup + denon_read: false + denon_write: true + + dialogdown: + type: bool + denon_command: zone1.settings.sound.general.dialogdown + denon_read: false + denon_write: true + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.sound + - AVR-X2300W.zone1.settings.sound.general + + video: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone1.settings.video + + aspectratio: + type: str + denon_command: zone1.settings.video.aspectratio + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + hdmimonitor: + type: num + denon_command: zone1.settings.video.hdmimonitor + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + hdmiresolution: + type: str + denon_command: zone1.settings.video.hdmiresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + videoprocessingmode: + type: str + denon_command: zone1.settings.video.videoprocessingmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + videoresolution: + type: str + denon_command: zone1.settings.video.videoresolution + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + pictureenhancer: + type: num + denon_command: zone1.settings.video.pictureenhancer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + videoinput: + type: str + denon_command: zone1.settings.video.videoinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone1 + - AVR-X2300W.zone1.settings + - AVR-X2300W.zone1.settings.video + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone2.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone2.settings.sound.channel_level + + front_left: + type: num + denon_command: zone2.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.settings + - AVR-X2300W.zone2.settings.sound + - AVR-X2300W.zone2.settings.sound.channel_level + + front_right: + type: num + denon_command: zone2.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.settings + - AVR-X2300W.zone2.settings.sound + - AVR-X2300W.zone2.settings.sound.channel_level + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X2300W.zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X2300W + - AVR-X2300W.zone2 + - AVR-X2300W.zone2.settings + - AVR-X2300W.zone2.settings.sound + - AVR-X2300W.zone2.settings.sound.general + + AVR-X1300W: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.general + + custom_inputnames: + type: dict + denon_command: general.custom_inputnames + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + cache: true + + reverse: + type: dict + eval: '{} if sh...() == {} else {v: k for (k, v) in sh...().items()}' + + update: + type: bool + eval: sh...timer(2, {}) + eval_trigger: '...' + + power: + type: bool + denon_command: general.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + + setupmenu: + type: bool + denon_command: general.setupmenu + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + + display: + type: str + denon_command: general.display + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + + soundmode: + type: str + denon_command: general.soundmode + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + denon_read_initial: true + + inputsignal: + type: str + denon_command: general.inputsignal + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + denon_read_initial: true + + inputrate: + type: num + denon_command: general.inputrate + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + denon_read_initial: true + + inputformat: + type: str + denon_command: general.inputformat + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + denon_read_initial: true + + inputresolution: + type: str + denon_command: general.inputresolution + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + denon_read_initial: true + + outputresolution: + type: str + denon_command: general.outputresolution + denon_read: true + denon_write: false + + ecomode: + type: str + denon_command: general.ecomode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.general + + tuner: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.tuner + + title: + type: str + denon_command: tuner.title + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.tuner + denon_read_initial: true + + album: + type: str + denon_command: tuner.album + denon_read: true + denon_write: false + + artist: + type: str + denon_command: tuner.artist + denon_read: true + denon_write: false + + preset: + type: num + denon_command: tuner.preset + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.tuner + denon_read_initial: true + + presetup: + type: bool + denon_command: tuner.presetup + denon_read: false + denon_write: true + + presetdown: + type: bool + denon_command: tuner.presetdown + denon_read: false + denon_write: true + + frequency: + type: num + denon_command: tuner.frequency + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.tuner + denon_read_initial: true + + frequencyup: + type: bool + denon_command: tuner.frequencyup + denon_read: false + denon_write: true + + frequencydown: + type: bool + denon_command: tuner.frequencydown + denon_read: false + denon_write: true + + band: + type: str + denon_command: tuner.band + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.tuner + denon_read_initial: true + + tuningmode: + type: str + denon_command: tuner.tuningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.tuner + + zone1: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1.control + + power: + type: bool + denon_command: zone1.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + mute: + type: bool + denon_command: zone1.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + volume: + type: num + denon_command: zone1.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + volumeup: + type: bool + denon_command: zone1.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone1.control.volumedown + denon_read: false + denon_write: true + + volumemax: + type: num + denon_command: zone1.control.volumemax + denon_read: true + denon_write: false + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + input: + type: str + denon_command: zone1.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + listeningmode: + type: str + denon_command: zone1.control.listeningmode + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + sleep: + type: num + denon_command: zone1.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + standby: + type: num + denon_command: zone1.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.control + denon_read_initial: true + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1.settings.sound + + channel_level: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1.settings.sound.channel_level + + front_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + front_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + front_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + front_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.front_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + front_center: + type: num + denon_command: zone1.settings.sound.channel_level.front_center + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + surround_left: + type: num + denon_command: zone1.settings.sound.channel_level.surround_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + surround_right: + type: num + denon_command: zone1.settings.sound.channel_level.surround_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + surroundback_left: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + surroundback_right: + type: num + denon_command: zone1.settings.sound.channel_level.surroundback_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + rear_height_left: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_left + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + rear_height_right: + type: num + denon_command: zone1.settings.sound.channel_level.rear_height_right + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + subwoofer: + type: num + denon_command: zone1.settings.sound.channel_level.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.channel_level + + tone_control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1.settings.sound.tone_control + + tone: + type: bool + denon_command: zone1.settings.sound.tone_control.tone + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.tone_control + + treble: + type: num + denon_command: zone1.settings.sound.tone_control.treble + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.tone_control + + trebleup: + type: bool + denon_command: zone1.settings.sound.tone_control.trebleup + denon_read: false + denon_write: true + + trebledown: + type: bool + denon_command: zone1.settings.sound.tone_control.trebledown + denon_read: false + denon_write: true + + bass: + type: num + denon_command: zone1.settings.sound.tone_control.bass + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.tone_control + + bassup: + type: bool + denon_command: zone1.settings.sound.tone_control.bassup + denon_read: false + denon_write: true + + bassdown: + type: bool + denon_command: zone1.settings.sound.tone_control.bassdown + denon_read: false + denon_write: true + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone1.settings.sound.general + + cinema_eq: + type: bool + denon_command: zone1.settings.sound.general.cinema_eq + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + hdmiaudioout: + type: str + denon_command: zone1.settings.sound.general.hdmiaudioout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + dynamicrange: + type: num + denon_command: zone1.settings.sound.general.dynamicrange + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + dialogtoggle: + type: bool + denon_command: zone1.settings.sound.general.dialogtoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + dialog: + type: num + denon_command: zone1.settings.sound.general.dialog + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + dialogup: + type: bool + denon_command: zone1.settings.sound.general.dialogup + denon_read: false + denon_write: true + + dialogdown: + type: bool + denon_command: zone1.settings.sound.general.dialogdown + denon_read: false + denon_write: true + + subwoofertoggle: + type: bool + denon_command: zone1.settings.sound.general.subwoofertoggle + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + subwoofer: + type: num + denon_command: zone1.settings.sound.general.subwoofer + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + subwooferup: + type: bool + denon_command: zone1.settings.sound.general.subwooferup + denon_read: false + denon_write: true + + subwooferdown: + type: bool + denon_command: zone1.settings.sound.general.subwooferdown + denon_read: false + denon_write: true + + lfe: + type: num + denon_command: zone1.settings.sound.general.lfe + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + lfeup: + type: bool + denon_command: zone1.settings.sound.general.lfeup + denon_read: false + denon_write: true + + lfedown: + type: bool + denon_command: zone1.settings.sound.general.lfedown + denon_read: false + denon_write: true + + audioinput: + type: str + denon_command: zone1.settings.sound.general.audioinput + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone1 + - AVR-X1300W.zone1.settings + - AVR-X1300W.zone1.settings.sound + - AVR-X1300W.zone1.settings.sound.general + + zone2: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone2 + + control: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone2.control + + power: + type: bool + denon_command: zone2.control.power + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.control + + mute: + type: bool + denon_command: zone2.control.mute + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.control + + volume: + type: num + denon_command: zone2.control.volume + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.control + + volumeup: + type: bool + denon_command: zone2.control.volumeup + denon_read: false + denon_write: true + + volumedown: + type: bool + denon_command: zone2.control.volumedown + denon_read: false + denon_write: true + + input: + type: str + denon_command: zone2.control.input + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.control + on_change: + - .custom_name = '' if sh.....general.custom_inputnames() == {} else sh.....general.custom_inputnames()[value] + + custom_name: + type: str + on_change: .. = '' if sh......general.custom_inputnames.reverse() == {} else sh......general.custom_inputnames.reverse()[value] + + sleep: + type: num + denon_command: zone2.control.sleep + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.control + + standby: + type: num + denon_command: zone2.control.standby + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.control + + settings: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone2.settings + + sound: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone2.settings.sound + + general: + + read: + type: bool + enforce_updates: true + denon_read_group_trigger: AVR-X1300W.zone2.settings.sound.general + + hdmiout: + type: str + denon_command: zone2.settings.sound.general.hdmiout + denon_read: true + denon_write: true + denon_read_group: + - AVR-X1300W + - AVR-X1300W.zone2 + - AVR-X1300W.zone2.settings + - AVR-X1300W.zone2.settings.sound + - AVR-X1300W.zone2.settings.sound.general +plugin_functions: NONE +logic_parameters: NONE diff --git a/denon/user_doc.rst b/denon/user_doc.rst new file mode 100755 index 000000000..eae7e2ec4 --- /dev/null +++ b/denon/user_doc.rst @@ -0,0 +1,97 @@ +.. index:: Plugins; denon +.. index:: denon + +===== +denon +===== + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: center + +Steuerung eines Denon AV Gerätes über TCP/IP oder RS232 Schnittstelle. + +Das Plugin unterstützt eine Vielzahl von Denon Verstärkern. Folgende Modelle wurden +konkret berücksichtigt, andere Modelle funktionieren aber mit hoher Wahrscheinlichkeit +auch. + +- AVR-X6300H +- AVR-X4300H +- AVR-X3300W +- AVR-X2300W +- AVR-X1300W + + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/denon` beschrieben. + + +plugin.yaml +----------- + +.. code-block:: yaml + + # etc/plugin.yaml + denon: + plugin_name: denon + model: AVR-X6300H + timeout: 3 + terminator: "\r" + binary: false + autoreconnect: true + autoconnect: true + connect_retries: 5 + connect_cycle: 3 + host: 192.168.0.111 + port: 23 + serialport: /dev/ttyUSB0 + conn_type: serial_async + command_class: SDPCommandParseStr + + +Struct Vorlagen +=============== + +Der Itembaum sollte jedenfalls über die structs Funktion eingebunden werden. Hierzu gibt es vier +Varianten, wobei die letzte die optimale Lösung darstellt: + +- einzelne Struct-Teile wie denon.info, denon.general, denon.tuner, denon.zone1, denon.zone2, denon.zone3 +- denon.ALL: Hierbei werden sämtliche Kommandos eingebunden, die vom Plugin vorgesehen sind +- denon.AVR-X6300H bzw. die anderen unterstützten Modelle, um nur die relevanten Items einzubinden +- denon.MODEL: Es wird automatisch der Itembaum für das Modell geladen, das im plugin.yaml angegeben ist. + +Sollte das selbst verwendete Modell nicht im Plugin vorhanden sein, kann der Plugin Maintainer +angeschrieben werden, um das Modell aufzunehmen. + +.. code-block:: yaml + + # items/my.yaml + Denon: + type: foo + struct: denon.MODEL + + +Kommandos +========= + +Die RS232 oder IP-Befehle des Geräts sind in der Datei `commands.py` hinterlegt. Etwaige +Anpassungen und Ergänzungen sollten als Pull Request oder durch Rücksprache mit dem Maintainer +direkt ins Plugin einfließen, damit diese auch von anderen Nutzer:innen eingesetzt werden können. + +Über die Datei `datatypes.py` sowie die Lookup Tabellen im `commandy.py` File sind +bereits sämtliche nötige Konvertierungen abgedeckt. So werden +beispielsweise Lautstärkeangaben mit Kommawerten oder boolsche Werte automatisch +korrekt interpretiert. + + +Web Interface +============= + +Aktuell ist kein Web Interface integriert. In naher Zukunft soll dies über die +SmartDevicePlugin Bibliothek automatisch zur Verfügung gestellt werden. diff --git a/denon/webif/static/img/plugin_logo.svg b/denon/webif/static/img/plugin_logo.svg new file mode 100755 index 000000000..e1c8a9993 --- /dev/null +++ b/denon/webif/static/img/plugin_logo.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Multidevice + diff --git a/dlms/README.md b/dlms/README.md deleted file mode 100755 index 81e18e603..000000000 --- a/dlms/README.md +++ /dev/null @@ -1,290 +0,0 @@ -# DLMS - -## Requirements - -* smartmeter using DLMS (Device Language Message Specification) IEC 62056-21 -* USB IR-Reader e.g. from volkszaehler.org - -install with -``` -sudo python3 -m pip install pyserial -``` - -make sure the serial port can be used by the user executing smarthome.py - -Example for a recent version of the Volkszaehler IR-Reader, please adapt the vendor- and product-id for your own readers: - -``` -echo 'SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="0092C9FE", MODE="0666", GROUP="dialout", SYMLINK+="dlms0"' > /etc/udev/rules.d/11-dlms.rules -udevadm trigger -``` - -If you like, you can also give the serial port a descriptive name with this. - -### Optional: - -A script ``get_manufacturer_ids.py`` is provided. Upon execution within the directory of ``plugins/dlms`` this script -loads an XLSX file with list of known manufacturers from -``https://www.dlms.com/srv/lib/Export_Flagids.php``, reads the ids and the corresponding manufacturer for convenience -and finally write a YAML file to ``manufacturer.yaml`` - -The main module will use the ``manufacturer.yaml`` if it's existing to output more information for debug purposes - - -### Supported Hardware - -* smart meters using using DLMS (Device Language Message Specification) IEC 62056-21 -* e.g. Landis & Gyr ZMD120 - -## Configuration - -### plugin.yaml - -``` -dlms: - plugin_name: dlms - serialport: /dev/dlms0 - update_cycle: 900 - # SUBSYSTEM==\"tty\", ATTRS{idVendor}==\"10c4\", ATTRS{idProduct}==\"ea60\", ATTRS{serial}==\"0092C9FE\", MODE=\"0666\", GROUP=\"dialout\", SYMLINK+=\"dlms0\" -``` - -Description of the attributes: - -* __serialport__: gives the serial port for the dlms query -* __update_cycle__: interval in seconds how often the data is read from the meter - be careful not to set a shorter interval than a read operation takes (default: 300) - - -### Setup procedure: - -Start the plugin in **standalone mode** with a shell from the plugins directory e.g. **/usr/local/smarthome/plugins/dlms** with -**python3 dlms.py _serialport_** - -Copy the given obis codes to your item configuration to help generate items as needed - -### items.yaml - -You can use all obis codes available by the meter. - -Attributes: -* __dlms_obis_code__: obis code such as 'x.y', 'x.y.z' or 'x.y.z*q' plus a specifier which value to read, the type must match the conversion from OBIS value (str, num, foo) -* __dlms_obis_readout__: value irrelevant, the item will need to be of ``type: str`` at it receives the full readout of the smartmeter - -## Some background information on OBIS Codes - -OBIS codes are a combination of six value groups, which describe in a hierarchical way -the exact meaning of each data item - -| value group | meaning | -| --- | --- | -| A | characteristic of the data item to be identified (abstract data, electricity-, gas-, heat-, water-related data) -| B | channel number, i.e. the number of the input of a metering equipment having several inputs for the measurement of energy of the same or different types (e.g. in data concentrators, registration units). Data from different sources can thus be identified. The definitions for this value group are independent from the value group A. -| C | abstract or physical data items related to the information source concerned, e.g. current , voltage , power, volume, temperature. The definitions depend on the value of the value group A . Measurement, tariff processing and data storage methods of these quantities are defined by value groups D, E and F For abstract data, the hierarchical structure of the 6 code fields is not applicable. | -| D | types, or the result of the processing of physical quantities identified with the value groups A and C, according to various specific algorithms. The algorithms can deliver energy and demand quantities as well as other physical quantities. -| E | further processing of measurement results identified with value groups A to D to tariff registers, according to the tariff(s) in use. For abstract data or for measurement results for which tariffs are not relevant, this value group can be used for further classification. -| F | the storage of data, identified by value groups A to E, according to different billing periods. Where this is not relevant, this value group can be used for further classification. - -A sample single line may look like ``A-B:C.D.E*F(Value*Unit)(another Value)``. -Parts **A** and **B** are optional as well as **E** and **F**. The second value may be omitted as well as the unit from the first value. -Every smartmeter readout will look different. The way how to interpret the values is not prescribed in -the file itself. It's in the smartmeters specification but one can guess also which is the best fit. - -See the follwing examples to get an idea about the differences: - -### OBIS code example A -Some first lines of a sample OBIS Code readout for a **Landis & Gyr ZMD 310** Smartmeter for industrial purposes -``` -1-1:F.F(00000000) -1-1:0.0.0(50871031) -1-1:0.0.1(50871031) -1-1:0.9.1(155420) -1-1:0.9.2(170214) -1-1:0.1.2(0000) -1-1:0.1.3(170201) -1-1:0.1.0(18) -1-1:1.2.1(0451.17*kW) -1-1:1.2.2(0451.17*kW) -1-1:2.2.1(0060.24*kW) -1-1:2.2.2(0060.24*kW) -1-1:1.6.1(27.19*kW)(1702090945) -1-1:1.6.1*18(28.74)(1701121445) -1-1:1.6.1*17(28.95)(1612081030) -1-1:1.6.1*16(25.82)(1611291230) -1-1:1.8.0(00051206*kWh) -1-1:1.8.0*18(00049555) -1-1:1.8.0*17(00045862) -... -``` - -### OBIS code example B - -Sample OBIS Code readout from a relative simple **Pafal 12EC3g** smartmeter -``` -0.0.0(72044837)(72044837) -0.0.1(PAF)(PAF) -F.F(00)(00) -0.2.0(1.29)(1.29) -1.8.0*00(000783.16)(000783.16) -2.8.0*00(000045.38)(000045.38) -C.2.1(000000000000)( )(000000000000)( ) -0.2.2(:::::G11)!(:::::G11)(!) -``` - -### Getting Values from Codelines - -Comparing the above examples it is obvious that the basically same OBIS Code has different appearances. - -| Example A | Example B | -| :---: | :---: | -| 1-1:F.F(00000000) | F.F(00)(00) | -| 1-1:1.8.0(00051206*kWh) | 1.8.0*00(000783.16)(000783.16) | - -To get the value from ``1-1:1.8.0(00051206*kWh)`` into the item we write in the items config file -``` -dlms_obis_code: - - '1-1:1.8.0' - - 0 - - 'Value' - - 'num' -``` -This will get the first value in parentheses and cast it into a numeric value. - -To get the value from ``1.8.0*00(000783.16)(000783.16)`` into the item we write in the items config file -``` -dlms_obis_code: - - '1.8.0*00' - - 0 - - 'Value' - - 'num' -``` -This will too get the first value in parentheses and cast it into a numeric value. - -To get the unit from ``1-1:1.8.0(00051206*kWh)`` into another item we write -```yaml -dlms_obis_code: - - '1-1:1.8.0' - - 0 - - 'Unit' - - 'str' -``` - -into the item.yaml. - - -A sample item.yaml for example **A** might look like following: - -```yaml -Stromzaehler: - Auslesung: - type: str - dlms_obis_readout: yes - Seriennummer: - type: str - dlms_obis_code: - - '1-1:0.0.0 - - 0 - - 'Value' - - 'str' - - Ablesung: - # Datum und Uhrzeit der letzten Ablesung - Uhrzeit: - type: foo - dlms_obis_code: - - '1-1:0.9.1' - - 0 - - 'Value' - - 'Z6' - Datum: - type: foo - dlms_obis_code: - - '1-1:0.9.2' - - 0 - - 'Value' - - 'D6' - Datum_Aktueller_Abrechnungsmonat: - type: foo - dlms_obis_code: - - '1-1:0.1.3' - - 0 - - 'Value' - - 'D6' - Monatszaehler: - # Billing period counter - type: num - dlms_obis_code: - - '1-1:0.1.0' - - 0 - - 'Value' - - 'num' - - Bezug: - Energie: - type: num - sqlite: yes - dlms_obis_code: - - '1-1:1.8.1' - - 0 - - 'Value' - - 'num' - - Energie_Einheit: - type: str - sqlite: yes - dlms_obis_code: - - '1-1:1.8.1' - - 0 - - 'Unit' - - 'str' - - Lieferung: - Energie: - type: num - sqlite: yes - dlms_obis_code: - - '1-1:2.8.1' - - 0 - - 'Value' - - 'num' - - Energie_Einheit: - type: str - sqlite: yes - dlms_obis_code: - - '1-1:2.8.1' - - 0 - - 'Unit' - - 'str' -``` -The basic syntax of the **dlms_obis_code** attributes value is -``` -dlms_obis_code: - - 1-1:1.6.2*01 - - Index - - 'Value' or 'Unit' - - Value Type -``` -where - -* __Index__ is the number of the value group you want to read -* __Value__ or __Unit__ whether you are interested in the value (mostly) or the unit like **kWh** -* __Value Type__ can be one of - * __Z6__ (time coded with hhmmss), - * __Z4__ (time coded with hhmm), - * __D6__ (date coded with YYMMDD), - * __ZST10__ (date and time coded with YYMMDDhhmm), - * __ZST12__ (date and time coded with YYMMDDhhmmss), - * __str__, a string - * __float__, a floating point number - * __int__ an integer - * __num__ a number either float or int - -For any Value Type with ``time`` or ``date`` the python datetime will be used. -That implies that you use ``type: foo`` for the items attribute in the respective item.yaml - -| OBIS A | meaning | -| --- | --- | -|0 | Abstract Objects | -|1 | Electricity related objects| - - - diff --git a/dlms/__init__.py b/dlms/__init__.py index a5d30cfff..9f41a23e4 100755 --- a/dlms/__init__.py +++ b/dlms/__init__.py @@ -56,8 +56,6 @@ class DLMS(SmartPlugin, conversion.Conversion): - PLUGIN_VERSION = "1.9.4" - """ This class provides a Plugin for SmarthomeNG to reads out a smartmeter. The smartmeter needs to have an infrared interface and an IR-Adapter is needed for USB @@ -68,6 +66,8 @@ class DLMS(SmartPlugin, conversion.Conversion): the tag ``dlms_obis_readout`` will receive the last readout from smartmeter """ + PLUGIN_VERSION = "1.9.5" + # tags this plugin handles DLMS_OBIS_CODE = 'dlms_obis_code' # a single code in form of '1-1:1.8.1' DLMS_OBIS_READOUT = 'dlms_obis_readout' # complete readout from smartmeter, if you want to examine codes yourself in a logic @@ -78,7 +78,7 @@ class DLMS(SmartPlugin, conversion.Conversion): def __init__(self, sh, *args, **kwargs ): """ Initializes the DLMS plugin - The parameter are retrieved from get_parameter_value(parameter_name) + The parameters are retrieved from get_parameter_value(parameter_name) """ from bin.smarthome import VERSION if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': @@ -92,6 +92,9 @@ def __init__(self, sh, *args, **kwargs ): self.logger.error(f"{self.get_fullname()}: Unable to import Python package 'pyserial'") return + # Call init code of parent class (SmartPlugin) + super().__init__() + self._instance = self.get_parameter_value('instance') # the instance of the plugin for questioning multiple smartmeter self._update_cycle = self.get_parameter_value('update_cycle') # the frequency in seconds how often the device should be accessed if self._update_cycle == 0: @@ -145,7 +148,7 @@ def run(self): self.alive = True if self._update_cycle or self._update_crontab: self.scheduler_add(self.get_shortname(), self._update_values_callback, prio=5, cycle=self._update_cycle, cron=self._update_crontab, next=shtime.now()) - self.logger.debug("run dlms") + self.logger.debug(f"Plugin '{self.get_fullname()}': run method finished") def stop(self): """ diff --git a/dlms/plugin.yaml b/dlms/plugin.yaml index 963ff5052..8c5daa5da 100755 --- a/dlms/plugin.yaml +++ b/dlms/plugin.yaml @@ -9,10 +9,10 @@ plugin: tester: NONE # Who tests this plugin? state: ready keywords: dlms obis smartmeter - documentation: http://smarthomeng.de/user/plugins/dlms/user_doc.html + #documentation: http://smarthomeng.de/user/plugins/dlms/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1002464-support-thread-für-dlms-plugin - version: 1.9.4 # Plugin version + version: 1.9.5 # Plugin version sh_minversion: 1.8 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) py_minversion: 3.6 # minimum Python version to use for this plugin, due to f-strings diff --git a/dlms/user_doc.rst b/dlms/user_doc.rst index 93e077c7c..683202cec 100755 --- a/dlms/user_doc.rst +++ b/dlms/user_doc.rst @@ -20,15 +20,16 @@ Anforderungen - Smartmeter mit DLMS-Protokoll (Device Language Message Specification) gemäß IEC 62056-21 - USB-Schnittstelle mit IR Lesekopf (z.B. von `volkszaehler.org `_ ) -Das Python Modul Pyserial wird benötigt. Die Installation ab SmartHomeNG 1.8 erfolgt automatisch +Das Python Modul pyserial wird benötigt. Die Installation ab SmartHomeNG 1.8 erfolgt automatisch über den Inhalt der mitgelieferten Datei ``requirements.txt`` oder manuell auf der Konsole mit .. code:: bash python3 -m pip install pyserial --user -Es muß sichergestellt sein, das der Benutzer der SmartHomeNG ausführt auch die Brechtigung hat -den seriellen Port zu verwenden. Möglicherweise muß eine udev-Regel erstellt werden. +Es muß sichergestellt sein, das der Benutzer der SmartHomeNG ausführt auch die +Berechtigung hat den seriellen Port zu verwenden. +Möglicherweise muß eine udev-Regel erstellt werden. Ein Beispiel für eine aktuelle Version des Volkszaehler IR-Lesekopfes bei der jeweils Vendor- und Product-ID für den eigenen Lesekopf angepaßt werden müssen: @@ -43,7 +44,9 @@ Der Symlink erstellt dabei einen passenden Namen der Schnittstelle der natürlic Unterstützte Hardware ====================== -- Smart Meter mit DLMS (Device Language Message Specification) IEC 62056-21 +Smart Meter mit DLMS (Device Language Message Specification) IEC 62056-21 + +Erfolgreich getestet wurden bisher: - Landis & Gyr ZMD120 - Landis & Gyr E350 - Landis & Gyr ZMD310CT @@ -102,8 +105,9 @@ Einrichtungsverfahren: ---------------------- Das Plugin kann im **Standalone-Modus** mit einer Shell aus dem Plugin -Verzeichnis z.B. **/usr/local/smarthome/plugins/dlms** gestartet werden mit ``python3 -dlms.py `` +Verzeichnis z.B. **/usr/local/smarthome/plugins/dlms** gestartet werden mit +``python3 dlms.py `` + Eine Hilfe zu verfügbaren Parametern wird mit ``python3 dlms.py -h`` angezeigt. Wichtig ist es zunächst zu wissen ob ein Smartmeter nur auf Anforderung Daten sendet @@ -123,6 +127,24 @@ für die Itemdefinition ableiten. Die gewählten Parameter für den Standalone Modus finden sich in den Einstellungen für die ``plugin.yaml`` von SmartHomeNG wieder. Alternativ kann die Einstellung auch über das Admin Interface vorgenommen werden. +Optional +-------- + +Mit dem Python Skript ``get_manufacturer_ids.py`` im Ordner des Plugins kann eine Liste von Herstellern +als Exceldatei geladen werden. + +Dieses Skript benötigt das Python Modul ``openpyxl`` dieses ist nicht in der ``requirements.txt`` mit aufgeführt weil +es nur für dieses Skript benötigt wird. + +Wird dieses Skript im Ordner des Plugins ``plugins/dlms`` ausgeführt, so lädt +diese eine Excel Datei mit einer Liste bekannter Hersteller aus +``https://www.dlms.com/srv/lib/Export_Flagids.php``. +Daraus wird eine YAML Datei ``manufacturer.yaml`` erstellt mit den ids und den entsprechenden +Herstellern. + +Das Plugin wird die Datei ``manufacturer.yaml`` verwenden wenn sie existiert um die Ausgabeinformationen +damit anzureichern. + Einige Hintergrundinformationen zu OBIS-Codes ============================================= @@ -189,11 +211,16 @@ Wo dies nicht relevant ist, kann diese Wertegruppe für weitere Klassifizierung Im folgenden zwei Beispiele um eine Vorstellung von den Unterschieden zu bekommen: +Werte aus Codezeilen ermitteln +============================== + +Zunächst zwei Codebeispiele von unterschiedlichen Smartmetern. + OBIS-Codebeispiel A -~~~~~~~~~~~~~~~~~~~ +------------------- -Einige erste Zeilen einer beispielhaften OBIS-Code-Auslesung für einen **Landis & Gyr ZMD -310** Smartmeter für industrielle Zwecke +Einige erste Zeilen einer beispielhaften OBIS-Code-Auslesung für einen +**Landis & Gyr ZMD 310** Smartmeter für industrielle Zwecke .. code:: text @@ -219,7 +246,7 @@ Einige erste Zeilen einer beispielhaften OBIS-Code-Auslesung für einen **Landis ... OBIS-Codebeispiel B -~~~~~~~~~~~~~~~~~~~ +------------------- Beispiel für das Auslesen eines OBIS-Codes von einem relativ einfachen **Pafal 12EC3g** Smartmeter: @@ -235,20 +262,6 @@ Smartmeter: C.2.1(000000000000)( )(000000000000)( ) 0.2.2(:::::G11)!(:::::G11)(!) - -Web Interface -============= - -Das dlms Plugin verfügt über ein Webinterface, mit dessen Hilfe die Items die das Plugin nutzen -übersichtlich dargestellt werden. - -.. important:: - - Das Webinterface des Plugins kann mit SmartHomeNG v1.4.2 und davor **nicht** genutzt werden. - Es wird dann nicht geladen. Diese Einschränkung gilt nur für das Webinterface. Ansonsten gilt - für das Plugin die in den Metadaten angegebene minimale SmartHomeNG Version. - - Werte aus den Codezeilen ermitteln ---------------------------------- @@ -420,6 +433,18 @@ dabei ist Für einen Wertetyp mit ``time`` oder ``date`` wird für das Item ein Python datetime erstellt. Das impliziert, das das Item einen Typ ``foo`` in der Definition in der entsprechenden item.yaml bekommt. +Web Interface +============= + +Das dlms Plugin verfügt über ein Webinterface, mit dessen Hilfe die Items die das Plugin nutzen +übersichtlich dargestellt werden. + +.. important:: + + Das Webinterface des Plugins kann mit SmartHomeNG v1.4.2 und davor **nicht** genutzt werden. + Es wird dann nicht geladen. Diese Einschränkung gilt nur für das Webinterface. Ansonsten gilt + für das Plugin die in den Metadaten angegebene minimale SmartHomeNG Version. + Aufruf des Webinterfaces ------------------------ diff --git a/drexelundweiss/__init__.py b/drexelundweiss/__init__.py index bd6f13338..4a2b332bc 100755 --- a/drexelundweiss/__init__.py +++ b/drexelundweiss/__init__.py @@ -28,26 +28,16 @@ import re import codecs from lib.model.smartplugin import SmartPlugin -from bin.smarthome import VERSION +import serial -try: - import serial - REQUIRED_PACKAGE_IMPORTED = True -except Exception: - REQUIRED_PACKAGE_IMPORTED = False class DuW(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.5.3" + PLUGIN_VERSION = "1.5.4" def __init__(self, smarthome): - self._name = self.get_fullname() - if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': - self.logger = logging.getLogger(__name__) - if not REQUIRED_PACKAGE_IMPORTED: - self.logger.error("Unable to import Python package 'serial'") - self._init_complete = False - return + super().__init__() + try: self._LU_ID = self.get_parameter_value('LU_ID') self._WP_ID = self.get_parameter_value('WP_ID') @@ -260,12 +250,15 @@ def run(self): divisor = int(reginfo[4]) komma = int(reginfo[5]) for item in self.LUregl[register]['items']: - (data, done) = self._read_register( - reginfo[7], register, int(reginfo[4]), int(reginfo[5])) - if done: - item(data, 'DuW', 'init process') - else: - self.logger.debug("Init LU register failed: {}".format(register)) + try: + (data, done) = self._read_register( + reginfo[7], register, int(reginfo[4]), int(reginfo[5])) + if done: + item(data, 'DuW', 'init process') + else: + self.logger.debug("Init LU register failed: {}".format(register)) + except Exception as e: + self.logger.error("Init LU register not possible: {}".format(register)) # WP register init for register in self.WPregl: @@ -273,12 +266,15 @@ def run(self): divisor = int(reginfo[4]) komma = int(reginfo[5]) for item in self.WPregl[register]['items']: - (data, done) = self._read_register( - reginfo[7], register, int(reginfo[4]), int(reginfo[5])) - if done: - item(data, 'DuW', 'init process') - else: - self.logger.debug("Init WP register failed: {}".format(register)) + try: + (data, done) = self._read_register( + reginfo[7], register, int(reginfo[4]), int(reginfo[5])) + if done: + item(data, 'DuW', 'init process') + else: + self.logger.debug("Init WP register failed: {}".format(register)) + except Exception as e: + self.logger.error("Init WP register not possible: {}".format(register)) # PANEL register init for register in self.PANELregl: @@ -286,12 +282,15 @@ def run(self): divisor = int(reginfo[4]) komma = int(reginfo[5]) for item in self.PANELregl[register]['items']: - (data, done) = self._read_register( - reginfo[7], register, int(reginfo[4]), int(reginfo[5])) - if done: - item(data, 'DuW', 'init process') - else: - self.logger.debug("Init PANEL register failed: {}".format(register)) + try: + (data, done) = self._read_register( + reginfo[7], register, int(reginfo[4]), int(reginfo[5])) + if done: + item(data, 'DuW', 'init process') + else: + self.logger.debug("Init PANEL register failed: {}".format(register)) + except Exception as e: + self.logger.error("Init PANEL register not possible: {}".format(register)) # poll DuW interface dw_id = 0 diff --git a/drexelundweiss/plugin.yaml b/drexelundweiss/plugin.yaml index eaf828760..33b46c17e 100755 --- a/drexelundweiss/plugin.yaml +++ b/drexelundweiss/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/34582-drexel-weiss-plugin - version: 1.5.3 # Plugin version + version: 1.5.4 # Plugin version sh_minversion: 1.5 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/drexelundweiss/user_doc.rst b/drexelundweiss/user_doc.rst index ba89c0b11..bb34a43e1 100755 --- a/drexelundweiss/user_doc.rst +++ b/drexelundweiss/user_doc.rst @@ -8,6 +8,13 @@ drexelundweiss Einführung ========== +.. image:: webif/static/img/plugin_logo.jpg + :alt: plugin logo + :width: 900px + :height: 900px + :scale: 16 % + :align: left + Dieses Plugin ermöglicht es, Drexel und Weiß Geräte (Wärmepumpen) direkt über USB ohne Modbusadapter zu steuern. .. important:: diff --git a/drexelundweiss/webif/static/img/plugin_logo.jpg b/drexelundweiss/webif/static/img/plugin_logo.jpg new file mode 100644 index 000000000..849e5ca03 Binary files /dev/null and b/drexelundweiss/webif/static/img/plugin_logo.jpg differ diff --git a/easymeter/README.md b/easymeter/README.md deleted file mode 100755 index 66e82b6ce..000000000 --- a/easymeter/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Easymeter - -## Requirements - -* smartmeter using DLMS (Device Language Message Specification) IEC 62056-21 -* USB IR-Reader e.g. from volkszaehler.org - -install with -``` -sudo python3 -m pip install pyserial -``` - -make sure the serial port can be used by the user executing smarthome.py - -Example for a recent version of the Volkszaehler IR-Reader, please adapt the vendor- and product-id for your own readers: - -``` -echo 'SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="0092C9FE", MODE="0666", GROUP="dialout", SYMLINK+="dlms0"' > /etc/udev/rules.d/11-dlms.rules -udevadm trigger -``` -If you like, you can also give the serial port a descriptive name with this. - -## Supported Hardware - -* Easymeter Q3D with ir-reader from volkszaehler.org - -## Configuration - -### plugin.yaml - -```yaml -easymeter: - plugin_name: easymeter -``` - -Parameter for serial device are currently set to fix 9600/7E1. - -Description of the attributes: - -* none - -### items.yaml - -* __easymeter_code__: obis protocol code - -* __device__: USB device for ir-reader from volkszaehler.org - -### Example - -```yaml -output: - easymeter_code: 1-0:21.7.0*255 - device: /dev/ttyUSB0 - type: num -``` - -Please take care, there are different obis codes for different versions of Easymeter Q3D. -For example Version 3.02 reports obis code 1-0:21.7.0*255, version 3.04 -reports 1-0:21.7.255*255. diff --git a/easymeter/__init__.py b/easymeter/__init__.py deleted file mode 100755 index 2cda98b5a..000000000 --- a/easymeter/__init__.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -# -# Copyright 2013 KNX-User-Forum e.V. http://knx-user-forum.de/ -# -# This file is part of SmartHomeNG. https://github.com/smarthomeNG// -# -# SmartHomeNG is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# SmartHomeNG is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with SmartHomeNG. If not, see . -# - -import logging -import serial -import re -import time - -from lib.model.smartplugin import * -#from lib.item import Items - -#from .webif import WebInterface - -#logger = logging.getLogger('easymeter') - -PLUGIN_VERSION = '1.0.0' # (must match the version specified in plugin.yaml), use '1.0.0' for your initial plugin Release - - -class easymeter(SmartPlugin): - - def __init__(self, smarthome): - self._cycle = 10 - self._timeout = 2 - self._codes = dict() - - def run(self): - self.scheduler_add('poll_device', self.update_status, cycle=self._cycle) - self.alive = True - - def stop(self): - self.scheduler_remove('poll_device') - self.alive = False - - # parse items, if item has parameter netio_port - # add item to local list - def parse_item(self, item): - if 'easymeter_code' in item.conf: - - if item.conf['device'] not in self._codes: - self._codes[item.conf['device']] = dict() - - self._codes[item.conf['device']][ - item.conf['easymeter_code']] = item - - return None - - def update_status(self): - - for curr_port in self._codes.keys(): - ser = serial.Serial( - port=curr_port, - timeout=2, - baudrate=9600, - bytesize=serial.SEVENBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE) - - start = time.time() - ser.flushInput() - - # wait for start of next datablock - while True: - line = ser.readline().decode("utf-8") - if line.find('!') >= 0: - break - - # read next datablock - datablock = [] - - while True: - line = ser.readline().decode("utf-8") - datablock.append(line) - if line.find('!') >= 0: - break - - # close serial connection - ser.close() - - for code in self._codes[curr_port].keys(): - r = re.compile('[()]+') - for line in datablock: - line = line.split(code) - if len(line) > 1: - self._codes[curr_port][code]( - r.split(line[1])[1].split('*')[0]) - - cycletime = time.time() - start - self.logger.debug("cycle takes %d seconds", cycletime) diff --git a/easymeter/plugin.yaml b/easymeter/plugin.yaml deleted file mode 100755 index 41fbeb6ac..000000000 --- a/easymeter/plugin.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# Metadata for the classic-plugin -plugin: - # Global plugin attributes - type: interface # plugin type (gateway, interface, protocol, system, web) - description: - de: 'Easymeter Q3D Unterstützung - Parameter für serielle Devices sind aktuell fest auf 9600/7E1 gesetzt' - en: 'Easymeter Q3D support - Parameter for serial device are currently set to fix 9600/7E1' - maintainer: '?' - tester: '?' # Who tests this plugin? -# keywords: kwd1 kwd2 # keywords, where applicable - state: deprecated # No user or tester for SmartPlugin conversion could be found -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page -# support: https://knx-user-forum.de/forum/supportforen/smarthome-py - -# Following entries are for Smart-Plugins: - version: 1.0.0 # Plugin version (must match the version specified in __init__.py) - sh_minversion: 1.6 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) - multi_instance: False - restartable: True - classname: easymeter # class containing the plugin - -parameters: NONE - # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty) - -item_attributes: - # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) - easymeter_code: - type: str - description: - de: 'Obis Protokoll Code' - en: 'obis protocol code' - - device: - type: str - default: /dev/ttyUSB0 - description: - de: 'USB device für den IR-Readed von volkszaehler.org' - en: 'USB device for ir-reader from volkszaehler.org' - -item_structs: NONE - # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) - -plugin_functions: NONE - # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) - -logic_parameters: NONE - # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) - diff --git a/easymeter/requirements.txt b/easymeter/requirements.txt deleted file mode 100755 index 27b4af0a5..000000000 --- a/easymeter/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyserial>=3.2.1 diff --git a/ebus/__init__.py b/ebus/__init__.py index e4e4539b0..081300bf8 100755 --- a/ebus/__init__.py +++ b/ebus/__init__.py @@ -4,22 +4,23 @@ # Copyright 2018- Martin Sinn m.sinn@gmx.de # Copyright 2012-2013 KNX-User-Forum e.V. http://knx-user-forum.de/ ######################################################################### -# This file is part of SmartHomeNG.py. -# Visit: https://github.com/smarthomeNG/ -# https://knx-user-forum.de/forum/supportforen/smarthome-py +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py # -# SmartHomeNG.py is free software: you can redistribute it and/or modify +# SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # -# SmartHomeNG.py is distributed in the hope that it will be useful, +# SmartHomeNG is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License -# along with SmartHomeNG.py. If not, see . +# along with SmartHomeNG. If not, see . +# ######################################################################### import logging @@ -36,7 +37,7 @@ class eBus(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '1.5.1' + PLUGIN_VERSION = '1.6.0' _items = [] @@ -58,6 +59,9 @@ def __init__(self, sh, *args, **kwargs): returns the value in the datatype that is defined in the metadata. """ + # Call init code of parent class (SmartPlugin) + super().__init__() + logger = logging.getLogger(__name__) # remove for shNG v1.6 self.host = self.get_parameter_value('host') self.port = self.get_parameter_value('port') @@ -95,7 +99,7 @@ def run(self): """ self.logger.debug("Run method called".format(self.get_fullname())) self.alive = True - self.scheduler_add('eBusd', self.refresh, prio=5, cycle=self._cycle, offset=2) + self.scheduler_add(self.get_fullname(), self.refresh, prio=5, cycle=self._cycle, offset=2) def refresh(self): @@ -113,7 +117,7 @@ def refresh(self): value = self.request(request) #if reading fails (i.e. at broadcast-commands) the value will not be updated if 'command not found' not in str(value) and value is not None: - item(value, 'eBus', 'refresh') + item(value, self.get_fullname(), 'refresh') if not self.alive: break @@ -126,8 +130,13 @@ def request(self, request): :type request: str """ if not self.connected: - self.logger.info("eBusd not connected") + self.logger.info("eBusd not connected, try to connect") + self.connect() + + if not self.connected: + self.logger.info("eBusd not connected, giving up") return + self._lock.acquire() try: self._sock.send(request.encode()) @@ -165,7 +174,7 @@ def connect(self): except Exception as e: self._connection_attempts -= 1 if self._connection_attempts <= 0: - self.logger.error('eBus: could not connect to ebusd at {0}:{1}: {2}'.format(self.host, self.port, e)) + self.logger.error('eBus: could not connect to ebusd at {0}:{1}: {2}'.format(self.host, self.port, e)) self._connection_attempts = self._connection_errorlog self._lock.release() return @@ -198,6 +207,7 @@ def stop(self): """ self.logger.debug("Stop method called".format(self.get_fullname())) self.close() + self.scheduler_remove(self.get_fullname()) self.alive = False @@ -209,7 +219,7 @@ def update_item(self, item, caller=None, source=None, dest=None): :param source: if given it represents the source :param dest: if given it represents the dest """ - if caller != 'eBus': + if caller != self.get_fullname(): value = str(int(item())) cmd = item.conf['ebus_cmd'] request = "write -c " + cmd + " " + value + "\n" diff --git a/ebus/README.md.old b/ebus/_pv_1_5_1/README.md old mode 100755 new mode 100644 similarity index 100% rename from ebus/README.md.old rename to ebus/_pv_1_5_1/README.md diff --git a/ebus/_pv_1_5_1/__init__.py b/ebus/_pv_1_5_1/__init__.py new file mode 100644 index 000000000..e4e4539b0 --- /dev/null +++ b/ebus/_pv_1_5_1/__init__.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2018- Martin Sinn m.sinn@gmx.de +# Copyright 2012-2013 KNX-User-Forum e.V. http://knx-user-forum.de/ +######################################################################### +# This file is part of SmartHomeNG.py. +# Visit: https://github.com/smarthomeNG/ +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# SmartHomeNG.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG.py. If not, see . +######################################################################### + +import logging +import socket +import threading +import time + +from lib.model.smartplugin import * + + +class eBus(SmartPlugin): + """ + Main class of the Plugin. Does all plugin specific stuff and provides + the update functions for the items + """ + + PLUGIN_VERSION = '1.5.1' + + _items = [] + + def __init__(self, sh, *args, **kwargs): + """ + Initalizes the plugin. The parameters descriptions for this method are pulled from the entry in plugin.yaml. + + :param sh: **Deprecated**: The instance of the smarthome object. For SmartHomeNG versions **beyond** 1.3: **Don't use it**! + :param *args: **Deprecated**: Old way of passing parameter values. For SmartHomeNG versions **beyond** 1.3: **Don't use it**! + :param **kwargs:**Deprecated**: Old way of passing parameter values. For SmartHomeNG versions **beyond** 1.3: **Don't use it**! + + If you need the sh object at all, use the method self.get_sh() to get it. There should be almost no need for + a reference to the sh object any more. + + The parameters *args and **kwargs are the old way of passing parameters. They are deprecated. They are imlemented + to support older plugins. Plugins for SmartHomeNG v1.4 and beyond should use the new way of getting parameter values: + use the SmartPlugin method get_parameter_value(parameter_name) instead. Anywhere within the Plugin you can get + the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It + returns the value in the datatype that is defined in the metadata. + """ + + logger = logging.getLogger(__name__) # remove for shNG v1.6 + self.host = self.get_parameter_value('host') + self.port = self.get_parameter_value('port') + self._cycle = self.get_parameter_value('cycle') + + self._sock = False + self.connected = False + self._connection_attempts = 0 + self._connection_errorlog = 60 + self._lock = threading.Lock() + # self.refresh_cycle = self._cycle # not used + + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + The plugin can, corresponding to its attribute keywords, decide what to do with + the item in future, like adding it to an internal array for future reference + :param item: The item to process. + :return: If the plugin needs to be informed of an items change you should return a call back function + like the function update_item down below. An example when this is needed is the knx plugin + where parse_item returns the update_item function when the attribute knx_send is found. + This means that when the items value is about to be updated, the call back function is called + with the item, caller, source and dest as arguments and in case of the knx plugin the value + can be sent to the knx with a knx write function within the knx plugin. + """ + if 'ebus_type' in item.conf and 'ebus_cmd' in item.conf: + self._items.append(item) + return self.update_item + + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug("Run method called".format(self.get_fullname())) + self.alive = True + self.scheduler_add('eBusd', self.refresh, prio=5, cycle=self._cycle, offset=2) + + + def refresh(self): + """ + Refresh items with data from ebusd + """ + for item in self._items: + time.sleep(1) + ebus_type = item.conf['ebus_type'] + ebus_cmd = item.conf['ebus_cmd'] + if ebus_cmd == "cycle": + request = ebus_type + " " + ebus_cmd + "\n" # build command + else: + request = "read" + " -c " + ebus_cmd + "\n" # build command + value = self.request(request) + #if reading fails (i.e. at broadcast-commands) the value will not be updated + if 'command not found' not in str(value) and value is not None: + item(value, 'eBus', 'refresh') + if not self.alive: + break + + + def request(self, request): + """ + send request to ebusd deamon + + :param request: Command to send to ebusd + :type request: str + """ + if not self.connected: + self.logger.info("eBusd not connected") + return + self._lock.acquire() + try: + self._sock.send(request.encode()) + self.logger.debug("REQUEST: {0}".format(request)) + except Exception as e: + self._lock.release() + self.close() + self.logger.warning("error sending request: {0} => {1}".format(request, e)) + return + try: + answer = self._sock.recv(256).decode()[:-2] + self.logger.debug("ANSWER: {0}".format(answer)) + except socket.timeout: + self._lock.release() + self.logger.warning("error receiving answer: timeout") + return + except Exception as e: + self._lock.release() + self.close() + self.logger.warning("error receiving answer: {0}".format(e)) + return + self._lock.release() + return answer + + + def connect(self): + """ + Open socket connection to ebusd deamon + """ + self._lock.acquire() + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(2) + self._sock.connect((self.host, self.port)) + except Exception as e: + self._connection_attempts -= 1 + if self._connection_attempts <= 0: + self.logger.error('eBus: could not connect to ebusd at {0}:{1}: {2}'.format(self.host, self.port, e)) + self._connection_attempts = self._connection_errorlog + self._lock.release() + return + self.logger.info('Connected to {0}:{1}'.format(self.host, self.port)) + self.connected = True + self._connection_attempts = 0 + self._lock.release() + + + def close(self): + """ + Close socket connection + """ + self.connected = False + try: + self._sock.shutdown(socket.SHUT_RDWR) + except: + pass + try: + self._sock.close() + self._sock = False + self.logger.info('Connection closed to {0}:{1}'.format(self.host, self.port)) + except: + pass + + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.debug("Stop method called".format(self.get_fullname())) + self.close() + self.alive = False + + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Write items values + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + if caller != 'eBus': + value = str(int(item())) + cmd = item.conf['ebus_cmd'] + request = "write -c " + cmd + " " + value + "\n" + set_answer = self.request(request) + #just check if set was no broadcast-message + if 'broadcast done' not in set_answer: + request = "read -c " + cmd + "\n" + answer = self.request(request) + #transfer value and answer to float for better comparsion + if float(answer) != float(value) or answer is None: + self.logger.warning("Failed to set parameter: value: {0} cmd: {1} answer {2}".format(value, request, answer)) diff --git a/ebus/_pv_1_5_1/plugin.yaml b/ebus/_pv_1_5_1/plugin.yaml new file mode 100644 index 000000000..dbe420e78 --- /dev/null +++ b/ebus/_pv_1_5_1/plugin.yaml @@ -0,0 +1,79 @@ +# Metadata for the classic-plugin +plugin: + # Global plugin attributes + type: interface # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Unterstützt eBus Heizungen (z.B. Vailant, Wolf, Kromschroeder) - Dieses Plugin verbindet sich zu einem ebusd Deamon (http://www.cometvisu.de/wiki/Ebusd), welcher mit einer eBus Heizung kommuniziert. Voraussetzungen: Ein ebusd Deamon läuft auf dem Netzwerk. (Anmerkung: Der ebusd benötigt ein ebus-Interface um mit ihm zu kommunizieren.' + en: 'Supports eBus heating systems (e.g. Vailant, Wolf, Kromschroeder) - The plugin connects to a ebusd damon (http://www.cometvisu.de/wiki/Ebusd) which is communicating with eBus heatings. Requirements: running ebusd deamon on the network (note: ebusd also requires an ebus-interface)' + maintainer: '? (msinn)' + tester: Sandman60 + state: ready +# keywords: kwd1 kwd2 # keywords, where applicable +# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page +# support: https://knx-user-forum.de/forum/supportforen/smarthome-py + +# Following entries are for Smart-Plugins: + version: 1.5.1 # Plugin version + sh_minversion: 1.5 # minimum shNG version to use this plugin + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + multi_instance: False + restartable: unknown + classname: eBus # class containing the plugin + + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml + + host: + type: ip + default: 127.0.0.1 + description: + de: 'IP Adresse des ebusd Deamons' + en: 'ip address of ebusd deamon' + + port: + type: int + valid_min: 0 + default: 8888 + description: + de: 'Port auf dem der ebusd Deamon lauscht' + en: 'port of ebusd deamon' + + cycle: + type: int + valid_min: 0 + default: 240 + description: + de: 'Cycle Zeit zur Abfrage jedes Items' + en: 'cycle of each item' + +item_attributes: + # Definition of item attributes defined by this plugin + + ebus_cmd: + type: str + description: + de: "ebus_cmd ist das Kommando, welches durch die Telnet Verbindung zum ebusd übertragen wird - z.B. 'cir2 heat_pump_curr', 'mv yield_sum' or 'short hw_load'" + en: "ebus_cmd is the command you use though the telnet-connection to ebusd - e.g. 'cir2 heat_pump_curr', 'mv yield_sum' or 'short hw_load'" + + ebus_type: + type: str + default: 'get' + valid_list: ['get', 'set'] + valid_list_description: + de: ['get', 'set'] + en: ['Items will only be readable, i.e. sensors', 'Items are read/write. All "set"-items will be read cyclic too!'] + description: + de: 'ebus_type legt fest, ob vom ebusd Deamon nur gelesen werden soll oder ob auch Daten an ebusd übertragen werden sollen.' + en: 'ebus_type determins, if data should only be read from the ebusd deamon or if data should be written to ebusd too.' + + +item_structs: NONE + # Definition of item-structure templates for this plugin + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin + +plugin_functions: NONE + # Definition of function interface of the plugin + diff --git a/ebus/plugin.yaml b/ebus/plugin.yaml index 9519b28b0..9d25ddfe0 100755 --- a/ebus/plugin.yaml +++ b/ebus/plugin.yaml @@ -3,17 +3,24 @@ plugin: # Global plugin attributes type: interface # plugin type (gateway, interface, protocol, system, web) description: - de: 'Unterstützt eBus Heizungen (z.B. Vailant, Wolf, Kromschroeder) - Dieses Plugin verbindet sich zu einem ebusd Deamon (http://www.cometvisu.de/wiki/Ebusd), welcher mit einer eBus Heizung kommuniziert. Voraussetzungen: Ein ebusd Deamon läuft auf dem Netzwerk. (Anmerkung: Der ebusd benötigt ein ebus-Interface um mit ihm zu kommunizieren.' - en: 'Supports eBus heating systems (e.g. Vailant, Wolf, Kromschroeder) - The plugin connects to a ebusd damon (http://www.cometvisu.de/wiki/Ebusd) which is communicating with eBus heatings. Requirements: running ebusd deamon on the network (note: ebusd also requires an ebus-interface)' + de: 'Unterstützt eBus Heizungen (z.B. Vailant, Wolf, Kromschroeder) + Dieses Plugin verbindet sich zu einem ebusd Deamon (https://ebusd.de/), + welcher mit einer eBus Heizung kommuniziert. + Voraussetzungen: Ein ebusd Deamon läuft auf dem Netzwerk. + Anmerkung: Der ebusd benötigt ein ebus-Interface um mit ihm zu kommunizieren. + ' + en: 'Supports eBus heating systems (e.g. Vailant, Wolf, Kromschroeder) + The plugin connects to a ebusd damon (https://ebusd.de/) + which is communicating with eBus heatings. + Requirements: running ebusd deamon on the network (note: ebusd also requires an ebus-interface) + ' maintainer: '? (msinn)' - tester: Sandman60 + tester: android, z1marco state: ready -# keywords: kwd1 kwd2 # keywords, where applicable -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page -# support: https://knx-user-forum.de/forum/supportforen/smarthome-py + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1925957 # Following entries are for Smart-Plugins: - version: 1.5.0 # Plugin version + version: 1.6.0 # Plugin version sh_minversion: 1.5 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False diff --git a/ebus/user_doc.rst b/ebus/user_doc.rst new file mode 100644 index 000000000..73ec8d1d1 --- /dev/null +++ b/ebus/user_doc.rst @@ -0,0 +1,99 @@ +.. index:: Plugins; ebus +.. index:: ebus + + +==== +ebus +==== + +Dieses Plugin verbindet sich zu einem ebus daemon und kann über diesen mit Geräten mit eBus Schnittstellen kommunizieren. + +Anforderungen +============= + +Eine ebus Schnittstelle + +Notwendige Software +------------------- + +Ein konfigurierter und funktionierender ebus Daemon der im Netzwerk erreichbar ist. + +Unterstützte Geräte +------------------- + +Beispielsweise Geräte von Vaillant, Wolf, Kromschroeder und andere die über eine ebus Schnittstelle kommunizieren können. + + +Konfiguration +============= + +Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/ebus` beschrieben. + + +plugin.yaml +----------- + +Zu den Informationen, welche Parameter in der ../etc/plugin.yaml konfiguriert werden können bzw. müssen, bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +items.yaml +---------- + +Zu den Informationen, welche Attribute in der Item Konfiguration verwendet werden können bzw. müssen, bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +logic.yaml +---------- + +Zu den Informationen, welche Konfigurationsmöglichkeiten für Logiken bestehen, bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +Funktionen +---------- + +Zu den Informationen, welche Funktionen das Plugin bereitstellt (z.B. zur Nutzung in Logiken), bitte +bitte die Dokumentation :doc:`Dokumentation ` lesen, die aus +den Metadaten der plugin.yaml erzeugt wurde (siehe oben). + +Beispiele +========= + +.. code:: yaml + + ebus_geraet: + + hk_pumpe_perc: + type: num + knx_dpt: 5 + knx_send: 8/6/110 + knx_reply: 8/6/110 + ebus_cmd: cir2 heat_pump_curr + ebus_type: get + # akt. PWM-Wert Heizkreizpumpe + + ernergie_summe: + type: num + knx_dpt: 12 + knx_send: 8/6/22 + knx_reply: 8/6/22 + ebus_cmd: mv yield_sum + ebus_type: get + # Energieertrag + + speicherladung: + type: bool + knx_dpt: 1 + knx_listen: 8/7/1 + ebus_cmd: short hw_load + ebus_type: set + # Quick - WW Speicherladung + + +Web Interface +============= + +Das Plugin hat derzeit kein Web Interface \ No newline at end of file diff --git a/ecmd/README.md b/ecmd/README.md deleted file mode 100755 index 46b88fb03..000000000 --- a/ecmd/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# ecmd - -## Requirements - -The ECMD plugin connects to an AVR microcontroller board with ethersex firmware via network. -The ECMD protocoll provides access to attached 1wire temperature sensors DS1820. - -## Supported Hardware - -* 8-bit AVR microcontroller boards with network support, like NetIO (Pollin), Etherrape (lochraster.org), etc. -* 1-wire temperature and other sensors -* - DS1820 (temperature sensor) -* - DS18B20 (temperature sensor) -* - DS1822 (temperature sensor) -* - DS2502 (EEPROM) -* - DS2450 (4 channel ADC) - -## Configuration - -### plugin.yaml - -You can specify the host ip of your ethersex device. - -```yaml -ecmd: - plugin_name: ecmd - host: 10.10.10.10 - # port: 2701 -``` - -This plugin needs an host attribute and you could specify a port attribute which differs from the default '1010'. - -### items.yaml - -The item needs to define the 1-wire address of the sensor. - -#### ecmd1wire_addr - -```yaml -mysensor: - ecmd1wire_addr: 10f01929020800dc - type: num -``` - -#### Example - -Please provide an item configuration with every attribute and usefull settings. - -```yaml -someroom: - - temperature: - name: Raumtemperatur - ecmd1wire_addr: 10f01929020800dc - type: num - sqlite: 'yes' - history: 'yes' - visu: 'yes' - sv_widget: "\"{{ basic.float('item', 'item', '°') }}\" , \"{{ plot.period('item-plot', 'item') }}\"" -``` diff --git a/ecmd/__init__.py b/ecmd/__init__.py deleted file mode 100755 index 184589159..000000000 --- a/ecmd/__init__.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -######################################################################### -# Copyright 2013 Dirk Wallmeier dirk@wallmeier.info -######################################################################### -# This file is part of SmartHomeNG. https://github.com/smarthomeNG// -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, -# MA 02110-1301, USA. -# -######################################################################### - - -import logging -import socket -import threading -import time - -logger = logging.getLogger('') - - -class owex(Exception): - pass - - -class ECMD1wireBase(): - - def __init__(self, host='127.0.0.1', port=2701): - self.host = host - self.port = int(port) - self._lock = threading.Lock() - self.connected = False - self._connection_attempts = 0 - self._connection_errorlog = 60 - - def connect(self): - self._lock.acquire() - try: - self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._sock.settimeout(2) - self._sock.connect((self.host, self.port)) - except Exception as e: - self._connection_attempts -= 1 - if self._connection_attempts <= 0: - logger.error('ecmd1wire: could not connect to {0}:{1}: {2}'.format(self.host, self.port, e)) - self._connection_attempts = self._connection_errorlog - return - else: - self.connected = True - logger.info('ecmd1wire: connected to {0}:{1}'.format(self.host, self.port)) - self._connection_attempts = 0 - finally: - self._lock.release() - - def request(self): - # name: request - # get a table of all DS1820 sensors and their names and values, - # separated by '\t' and terminated by 'OK\n': - # 10f01929020800dc sensor1 26.4 - # 100834290208001b sensor2 25.4 - # OK - # @return dict {'addr' : value} - # - if not self.connected: - raise owex("ecmd1wire: No connection to ethersex server {0}:{1}.".format(self.host, self.port)) - self._lock.acquire() - try: - self._sock.send("1w list\n") - except Exception as e: - self._lock.release() - raise owex("error sending request: {0}".format(e)) - table = {} - while 1: - try: - response = self._sock.recv(1024) - except socket.timeout: - self.close() - break - if not response: - self.close() - break - if response != "OK": - for r in response.split("\n"): - if r and len(r.split("\t")) == 3: - addr, name, value = r.split("\t") - table[addr] = float(value) - logger.debug('ecmd1wire: append Sensor {0} = {1}\n'.format(addr, table[addr])) - self._lock.release() - return table - - def close(self): - self.connected = False - try: - self._sock.close() - except: - pass - - -class ECMD(ECMD1wireBase): - _sensors = {} - alive = True - - def __init__(self, smarthome, cycle=120, host='192.168.178.10', port=2701): - ECMD1wireBase.__init__(self, host, port) - self._sh = smarthome - self._cycle = int(cycle) - - def _refresh(self): - start = time.time() - table = self.request() - for addr in self._sensors: - if not self.alive: - break - if addr not in table: - logger.debug("ecmd1wire: {0} not in sensors watched".format(addr)) - else: - try: - value = table[addr] - except Exception: - logger.info("ecmd1wire: problem reading {0}".format(addr)) - continue - else: - logger.info("ecmd1wire: sensor {0} has {1}°".format(addr, value)) - if value == '85': - logger.info("ecmd1wire: problem reading {0}. Wiring problem?".format(addr)) - continue - item = self._sensors[addr] - item(value, 'ECMD1Wire') - cycletime = time.time() - start - logger.debug("cycle takes {0} seconds".format(cycletime)) - - def run(self): - self.alive = True - self._sh.scheduler.add('ecmd1wire', self._refresh, cycle=self._cycle, prio=5, offset=0) - - def stop(self): - self.alive = False - - def parse_item(self, item): - if 'ecmd1wire_addr' not in item.conf: - return - addr = item.conf['ecmd1wire_addr'] - self._sensors[addr] = item - logger.info("ecmd1wire: Sensor {0} added.".format(addr)) diff --git a/ecmd/plugin.yaml b/ecmd/plugin.yaml deleted file mode 100755 index 6777a45a1..000000000 --- a/ecmd/plugin.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Metadata for the classic-plugin -plugin: - # Global plugin attributes - type: gateway # plugin type (gateway, interface, protocol, system, web) - description: - de: 'Anbindung eines AVRMicrocontrollers. Das Protokoll gibt Zugriff auf 1wire Sensoren DS1820' - en: '' - maintainer: '? (Dirk Wallmeier)' -# tester: waldi # Who tests this plugin? - keywords: 1wire onewire - state: deprecated # No user or tester for SmartPlugin conversion could be found -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page - -# Following entries are for Smart-Plugins: -# version: 1.3.3 # Plugin version -# sh_minversion: 1.3 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) -# multi_instance: False - classname: ECMD # class containing the plugin - -#parameters: - # Definition of parameters to be configured in etc/plugin.yaml - -#item_attributes: - # Definition of item attributes defined by this plugin - diff --git a/elro/README.md b/elro/README.md deleted file mode 100755 index f8204adba..000000000 --- a/elro/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# Elro - -## Description - -You can use this Plugin to control elro (or elro-based) remote-control-switches (rc-switches). -If the backend-server uses the same command-syntax as the rc_switch_server project, -you can even control non-elro rc-switches too! (Or everything other that can be switched on and off) - -For rc_switch_server command-syntax look at https://github.com/Brootux/rc_switch_server.py (Server-Clients) - -## Requirements - - * Installed and running rc_switch_server (https://github.com/Brootux/rc_switch_server.py) - -## Configuration - -### plugin.yaml - -You have to just simply copy the following into your plugin.yaml file. The ip-address/hostname of the rc_switch_server has to be setup later in the items.yaml! - -```yaml -elro: - plugin_name: elro -``` - -### items.yaml - -The most item-fields of this plugin are mandatory. So you should always use all of the fields showed in the following example. - -#### Example - -```yaml -RCS: - type: str - elro_host: localhost - elro_port: 6700 - - A: - type: bool - elro_system_code: 0.0.0.0.1 - elro_unit_code: 1 - elro_send: value - enforce_updates: 'yes' - visu_acl: rw - - B: - type: bool - elro_system_code: 0.0.0.0.1 - elro_unit_code: 2 - elro_send: value - enforce_updates: 'yes' - visu_acl: rw - - C: - type: bool - elro_system_code: 0.0.0.0.1 - elro_unit_code: 4 - elro_send: value - enforce_updates: 'yes' - visu_acl: rw - - D: - type: bool - elro_system_code: 0.0.0.0.1 - elro_unit_code: 8 - elro_send: value - enforce_updates: 'yes' - visu_acl: rw -``` - -Description of the attributes: - -* __elro_host__: the ip-address/hostname of the rc_switch_server (mandatory) -* __elro_port__: the port of the rc_switch_server -* __elro_system_code__: the code of your home (mandatory) -* __elro_unit_code__: the code of the unit, you want to switch (mandatory) -* __elro_send__: use always "value" here (mandatory) - -Hints: -* __You have to setup the items as showed in a tree structure with the `elro_host` as its root!__ (The tree can be a subtree of a greater tree but always has to be `elro_host` as a attribute of the root item) -* For __elro_system_code__ you have to set the correct bits of you code (no conversion) -* For __elro_unit_code__ you have to convert your settings to binary (A = 1, B = 2, C = 4, D = 8, ...) -* For __elro_send__ always use the transmitting of a value per button (because sometimes the signals dont get transported correctly from remote-transmitter, so you should have the chance to send "on" or "off" more than once) - -### Example for multiple rc_switch_server´s - -```yaml -RCS1: - type: str - elro_host: localhost - elro_port: 6700 - A: - type: bool - elro_system_code: '0.0.0.0.1' - elro_unit_code: 1 - elro_send: value - enforce_updates: yes - visu_acl: rw - -RCS2: - type: str - elro_host: 192.168.0.100 - elro_port: 6666 - A: - type: bool - elro_system_code: '0.0.0.0.2' - elro_unit_code: 1 - elro_send: value - enforce_updates: yes - visu_acl: rw -``` - -### SmartVisu - -I suggest you to use the following setup per rc-switch: - -```html - -

TV-Center

- {{ basic.button('rcs_tv_on', 'RCS.A', 'On', '', '1', 'midi') }} - {{ basic.button('rcs_tv_off', 'RCS.A', 'Off', '', '0', 'midi') }} -
-``` diff --git a/elro/__init__.py b/elro/__init__.py deleted file mode 100755 index 9bf856dc2..000000000 --- a/elro/__init__.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -# -# Copyright 2014 Brootux (https://github.com/Brootux) as GNU-GPL -# -# This file is part of SmartHomeNG. https://github.com/smarthomeNG// -# -# SmartHomeNG is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# SmartHomeNG is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with SmartHomeNG. If not, see . -# - -import logging -import re -import socket - -logger = logging.getLogger('elro') - - -class Elro(): - - def __init__(self, smarthome): - self._sh = smarthome - self._host = "localhost" - self._port = 6700 - - def run(self): - self.alive = True - - def stop(self): - self.alive = False - - def parse_item(self, item): - - # Parse just the parent-items wich define the 'elro_host' - # and/or 'elro_port' attribute - if 'elro_host' in item.conf: - - # Get the host-name/ip from parent-item - self._host = item.conf['elro_host'] - - # Try to get the port from parent-item - # (else the default value will be uesed) - if 'elro_port' in item.conf: - self._port = int(item.conf['elro_port']) - - # Get all child-item-lists for the given fields (parse to set) - escSet = set(self._sh.find_children(item, 'elro_system_code')) - eucSet = set(self._sh.find_children(item, 'elro_unit_code')) - esSet = set(self._sh.find_children(item, 'elro_send')) - - # Just get those child-items which have all mandatory fields - # set (elro_system_code and elro_unit_code and elro_send) - validItems = list(escSet & eucSet & esSet) - - # Iterate over all valid child-items - for item in validItems: - # Add fields of parent-item to all valid child-items - item.conf['elro_host'] = self._host - item.conf['elro_port'] = self._port - - # Add method trigger to all valid child-items - item.add_method_trigger(self._send) - - def _send(self, item, caller=None, source=None, dest=None): - - # Just let calls from outside pass - if (caller != 'Elro'): - # Send informations to server (e.g. "0.0.0.0.1;2;0") - self.send("%s;%s;%s" % ( - item.conf['elro_system_code'], - item.conf['elro_unit_code'], - int(item()) - ), - item.conf['elro_host'], - item.conf['elro_port']) - - def send(self, payload="0.0.0.0.1;1;0", host="localhost", port=6700): - - # Print what will be send as a debug-message - logger.debug("ELRO: Sending %s to %s:%s" % (payload, host, port)) - - # Create socket - s = socket.socket() - - # Connect to server - s.connect((host, port)) - - # Write payload to server - s = s.makefile(mode="rw") - s.write(payload) - s.flush() - - # Close server-connection - s.close() diff --git a/elro/plugin.yaml b/elro/plugin.yaml deleted file mode 100755 index 9427e3820..000000000 --- a/elro/plugin.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Metadata for the classic-plugin -plugin: - # Global plugin attributes - type: gateway # plugin type (gateway, interface, protocol, system, web) - description: - de: 'Unterstützt elro-basierter Remote-Control-Switches' - en: '' - maintainer: '? (Brootux)' -# tester: efgh # Who tests this plugin? -# keywords: kwd1 kwd2 # keywords, where applicable - state: deprecated # No user or tester for SmartPlugin conversion could be found -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page - -# Following entries are for Smart-Plugins: -# version: 1.3.3 # Plugin version -# sh_minversion: 1.3 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) -# multi_instance: False - classname: Elro # class containing the plugin - -#parameters: - # Definition of parameters to be configured in etc/plugin.yaml - -#item_attributes: - # Definition of item attributes defined by this plugin - diff --git a/enigma2/sv_widgets/widget_enigma2.html b/enigma2/sv_widgets/widget_enigma2.html index 6ee29109f..b13a643b2 100755 --- a/enigma2/sv_widgets/widget_enigma2.html +++ b/enigma2/sv_widgets/widget_enigma2.html @@ -21,7 +21,7 @@ */ {% macro config_info(id, gad_model, gad_webif, gad_enigma, gad_image, gad_dhcp, gad_ip, gad_gw, gad_mask, gad_mac) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% set uid = uid(page, id) %}
@@ -54,7 +54,7 @@ */ {% macro config_info2(id, gad_box) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% set uid = uid(page, id) %}
@@ -88,7 +88,7 @@ */ {% macro status_info(id, gad_standby, gad_servicename, gad_eventtitle, gad_eventdesc, gad_vwidth, gad_vheight, gad_vpid, gad_apid) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% set uid = uid(page, id) %}
@@ -120,7 +120,7 @@ */ {% macro status_info2(id, gad_box) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% set uid = uid(page, id) %}
@@ -152,7 +152,7 @@ */ {% macro status_channel(id, gad_box) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% set uid = uid(page, id) %}
@@ -178,7 +178,7 @@ */ {% macro remote2(id, gad_boxremote, gad_size, alternative) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% set uid = uid(page, id) %} {% if config_version == 2.9 %} diff --git a/enocean/__init__.py b/enocean/__init__.py index 1363441a9..09b4a505b 100755 --- a/enocean/__init__.py +++ b/enocean/__init__.py @@ -23,7 +23,6 @@ import serial import os import sys -import logging import struct import time import threading @@ -166,10 +165,10 @@ class EnOcean(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.3.8" + PLUGIN_VERSION = "1.4.0" - def __init__(self, sh, *args, **kwargs): + def __init__(self, sh): """ Initalizes the plugin. @@ -180,7 +179,6 @@ def __init__(self, sh, *args, **kwargs): self._sh = sh self.port = self.get_parameter_value("serialport") - self.logger = logging.getLogger(__name__) tx_id = self.get_parameter_value("tx_id") if (len(tx_id) < 8): self.tx_id = 0 @@ -189,7 +187,7 @@ def __init__(self, sh, *args, **kwargs): self.tx_id = int(tx_id, 16) self.logger.info(f"Stick TX ID configured via plugin.conf to: {tx_id}") self._log_unknown_msg = self.get_parameter_value("log_unknown_messages") - self._tcm = serial.Serial(self.port, 57600, timeout=1.5) + self._tcm = None self._cmd_lock = threading.Lock() self._response_lock = threading.Condition() self._rx_items = {} @@ -443,7 +441,18 @@ def run(self): self.logger.debug("Call function << run >>") self.alive = True self.UTE_listen = False - #self.learn_id = 0 + + # open serial or serial2TCP device: + try: + self._tcm = serial.serial_for_url(self.port, 57600, timeout=1.5) + except Exception as e: + self._tcm = None + self._init_complete = False + self.logger.error(f"Exception occurred during serial open: {e}") + return + else: + self.logger.info(f"Serial port successfully opened at port {self.port}") + t = threading.Thread(target=self._startup, name="enocean-startup") # if you need to create child threads, do not make them daemon = True! # They will not shutdown properly. (It's a python bug) @@ -451,62 +460,77 @@ def run(self): t.start() msg = [] while self.alive: - readin = self._tcm.read(1000) - if readin: - msg += readin - if logger_debug: - self.logger.debug("Data received") - # check if header is complete (6bytes including sync) - # 0x55 (SYNC) + 4bytes (HEADER) + 1byte(HEADER-CRC) - while (len(msg) >= 6): - #check header for CRC - if (msg[0] == PACKET_SYNC_BYTE) and (self._calc_crc8(msg[1:5]) == msg[5]): - # header bytes: sync; length of data (2); optional length; packet type; crc - data_length = (msg[1] << 8) + msg[2] - opt_length = msg[3] - packet_type = msg[4] - msg_length = data_length + opt_length + 7 - if logger_debug: - self.logger.debug("Received header with data_length = {} / opt_length = 0x{:02x} / type = {}".format(data_length, opt_length, packet_type)) - - # break if msg is not yet complete: - if (len(msg) < msg_length): - break - - # msg complete - if (self._calc_crc8(msg[6:msg_length - 1]) == msg[msg_length - 1]): + try: + readin = self._tcm.read(1000) + except Exception as e: + self.logger.error(f"Exception during tcm read occurred: {e}") + break + else: + if readin: + msg += readin + if logger_debug: + self.logger.debug("Data received") + # check if header is complete (6bytes including sync) + # 0x55 (SYNC) + 4bytes (HEADER) + 1byte(HEADER-CRC) + while (len(msg) >= 6): + #check header for CRC + if (msg[0] == PACKET_SYNC_BYTE) and (self._calc_crc8(msg[1:5]) == msg[5]): + # header bytes: sync; length of data (2); optional length; packet type; crc + data_length = (msg[1] << 8) + msg[2] + opt_length = msg[3] + packet_type = msg[4] + msg_length = data_length + opt_length + 7 if logger_debug: - self.logger.debug("Accepted package with type = 0x{:02x} / len = {} / data = [{}]!".format(packet_type, msg_length, ', '.join(['0x%02x' % b for b in msg]))) - data = msg[6:msg_length - (opt_length + 1)] - optional = msg[(6 + data_length):msg_length - 1] - if (packet_type == PACKET_TYPE_RADIO): - self._process_packet_type_radio(data, optional) - elif (packet_type == PACKET_TYPE_SMART_ACK_COMMAND): - self._process_packet_type_smart_ack_command(data, optional) - elif (packet_type == PACKET_TYPE_RESPONSE): - self._process_packet_type_response(data, optional) - elif (packet_type == PACKET_TYPE_EVENT): - self._process_packet_type_event(data, optional) + self.logger.debug("Received header with data_length = {} / opt_length = 0x{:02x} / type = {}".format(data_length, opt_length, packet_type)) + + # break if msg is not yet complete: + if (len(msg) < msg_length): + break + + # msg complete + if (self._calc_crc8(msg[6:msg_length - 1]) == msg[msg_length - 1]): + if logger_debug: + self.logger.debug("Accepted package with type = 0x{:02x} / len = {} / data = [{}]!".format(packet_type, msg_length, ', '.join(['0x%02x' % b for b in msg]))) + data = msg[6:msg_length - (opt_length + 1)] + optional = msg[(6 + data_length):msg_length - 1] + if (packet_type == PACKET_TYPE_RADIO): + self._process_packet_type_radio(data, optional) + elif (packet_type == PACKET_TYPE_SMART_ACK_COMMAND): + self._process_packet_type_smart_ack_command(data, optional) + elif (packet_type == PACKET_TYPE_RESPONSE): + self._process_packet_type_response(data, optional) + elif (packet_type == PACKET_TYPE_EVENT): + self._process_packet_type_event(data, optional) + else: + self.logger.error("Received packet with unknown type = 0x{:02x} - len = {} / data = [{}]".format(packet_type, msg_length, ', '.join(['0x%02x' % b for b in msg]))) else: - self.logger.error("Received packet with unknown type = 0x{:02x} - len = {} / data = [{}]".format(packet_type, msg_length, ', '.join(['0x%02x' % b for b in msg]))) + self.logger.error("Crc error - dumping packet with type = 0x{:02x} / len = {} / data = [{}]!".format(packet_type, msg_length, ', '.join(['0x%02x' % b for b in msg]))) + msg = msg[msg_length:] else: - self.logger.error("Crc error - dumping packet with type = 0x{:02x} / len = {} / data = [{}]!".format(packet_type, msg_length, ', '.join(['0x%02x' % b for b in msg]))) - msg = msg[msg_length:] - else: - #self.logger.warning("Consuming [0x{:02x}] from input buffer!".format(msg[0])) - msg.pop(0) - self._tcm.close() + #self.logger.warning("Consuming [0x{:02x}] from input buffer!".format(msg[0])) + msg.pop(0) + try: + self._tcm.close() + except Exception as e: + self.logger.error(f"Exception during tcm close occured: {e}") + else: + self.logger.info(f"Enocean serial device closed") self.logger.info("Run method stopped") def stop(self): self.logger.debug("Call function << stop >>") - self.alive = False - + self.alive = False def get_tx_id_as_hex(self): hexstring = "{:08X}".format(self.tx_id) return hexstring + def get_serial_status_as_string(self): + if (self._tcm and self._tcm.is_open): + return "open" + else: + return "not connected" + def get_log_unknown_msg(self): return self._log_unknown_msg @@ -698,7 +722,34 @@ def _send_packet(self, packet_type, data=[], optional=[]): packet += bytes(data + optional) packet += bytes([self._calc_crc8(packet[6:])]) self.logger.info("Sending packet with len = {} / data = [{}]!".format(len(packet), ', '.join(['0x%02x' % b for b in packet]))) - self._tcm.write(packet) + + # Send out serial data: + if not (self._tcm and self._tcm.is_open): + self.logger.debug("Trying serial reinit") + try: + self._tcm = serial.serial_for_url(self.port, 57600, timeout=1.5) + except Exception as e: + self._tcm = None + self.logger.error(f"Exception occurred during serial reinit: {e}") + else: + self.logger.debug("Serial reinit successful") + if self._tcm: + try: + self._tcm.write(packet) + except Exception as e: + self.logger.error(f"Exception during tcm write occurred: {e}") + self.logger.debug("Trying serial reinit after failed write") + try: + self._tcm = serial.serial_for_url(self.port, 57600, timeout=1.5) + except Exception as e: + self._tcm = None + self.logger.error(f"Exception occurred during serial reinit after failed write: {e}") + else: + self.logger.debug("Serial reinit successful after failed write") + try: + self._tcm.write(packet) + except Exception as e: + self.logger.error(f"Exception occurred during tcm write after successful serial reinit: {e}") def _send_smart_ack_command(self, _code, data=[]): #self.logger.debug("enocean: call function << _send_smart_ack_command >>") diff --git a/enocean/plugin.yaml b/enocean/plugin.yaml index a020510a8..ebaf6538b 100755 --- a/enocean/plugin.yaml +++ b/enocean/plugin.yaml @@ -16,9 +16,9 @@ plugin: # url of the support thread support: https://knx-user-forum.de/forum/supportforen/smarthome-py/26542-featurewunsch-enocean-plugin/page13 - version: 1.3.8 # Plugin version + version: 1.4.0 # Plugin version sh_minversion: 1.3 # minimum shNG version to use this plugin - #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance restartable: unknown classname: EnOcean # class containing the plugin @@ -29,8 +29,8 @@ parameters: type: str default: /dev/ttyAMA0 description: - de: 'Name der Schnittstelle, an der sich der EnOcean Adapter befindet' - en: 'name of the port where the EnOcean adapter is plugged in' + de: 'Name der Schnittstelle, an der sich der EnOcean Adapter befindet (als Linux-Handle oder RFC2217-URL)' + en: 'name of the port where the EnOcean adapter is plugged in (as a linux file handle or RFC2217 URL)' tx_id: type: str diff --git a/enocean/user_doc.rst b/enocean/user_doc.rst index 18889ba24..0d4b9c0e4 100755 --- a/enocean/user_doc.rst +++ b/enocean/user_doc.rst @@ -29,8 +29,8 @@ Es wird ein Hardware Radio Transceiver Modul benötigt, z.B.: .. important:: - Der user `smarthome`, unter dem smarthomeNG ausgeführt wird, muss die nötigen Zugriffsrechte - auf die Linux Gruppe `dialout` besitzen, damit die Hardware über Linux devices angesprochen und konfiguriert werden kann. + Der user `smarthome`, unter dem smarthomeNG ausgeführt wird, muss die nötigen Zugriffsrechte + auf die Linux Gruppe `dialout` besitzen, damit die Hardware über Linux devices angesprochen und konfiguriert werden kann. Hierzu folgendes in der Linux Konsole ausführen: @@ -38,9 +38,23 @@ Hierzu folgendes in der Linux Konsole ausführen: sudo gpasswd --add smarthome dialout -Aktoren, Schalter oder Stellglieder, die vom Enocean plugin gesteuert werden sollen, müssen vorher einmalig angelert werden. Der Anlernvorgang wird unten im Kaptiel Webinterface beschrieben. -Für das Auslesen von Statusinfromationen von Enocean Sensoren ist kein Anlernvorgang nötig. +Aktoren, Schalter oder Stellglieder, die vom Enocean plugin gesteuert werden sollen, müssen vorher einmalig angelert werden. Der Anlernvorgang wird unten im Kapitel Webinterface beschrieben. +Für das Auslesen von Statusinformationen von Enocean Sensoren ist kein Anlernvorgang nötig. +Verwendung via IP +----------------- + +Alternativ kann eines der oben erwähnten Serial-Geräte auch über das Netzwerk freigegeben werden und per RFC2217 eingebunden werden. Die Freigabe erfolgt auf dem Host mithilfe von `ser2net `. Eine beispielhafte Konfiguration (ser2net.yaml) könnte so aussehen: + +.. code-block:: yaml + + connection: &tS4telnet + accepter: telnet(rfc2217),tcp, + connector: serialdev, + , + 57600n81,local + +Für und sind die entsprechenden Werte einzufügen. Konfiguration ============= @@ -52,15 +66,15 @@ plugin.yaml **serialport** -Hier wird der `serialport` zum Enocean Hardwareadapter angegeben werden. +Hier wird der `serialport` zum Enocean Hardwareadapter angegeben. Unter Linux wird empfohlen, das entsprechende Linux Uart device über eine Udev-Regel auf einen Link zu mappen und diesen Link dann als `serialport` anzugeben. **tx_id** Die tx_id ist die Transmitter ID der Enocean Hardware und als achtstelliger Hexadezimalwert definiert. Die Angabe ist erstmal optional und muss nur zwingend angegeben werden, -falls Enocean Aktoren geschaltet werden sollen, d.h. der Hardware Kontroller auch Senebefehle absetzen muss. - -Werden mehrere Aktuatoren betrieben, sollte die Base-ID (**not Unique-ID or Chip-ID**) der Enocean Hardware als Transmitter ID angegeben werden. +falls Enocean Aktoren geschaltet werden sollen, d.h. das Plugin auch Sendebefehle absetzen soll. + +Werden mehrere Aktuatoren betrieben, sollte die Base-ID (**not Unique-ID or Chip-ID**) der Enocean Hardware als Transmitter ID angegeben werden. Weitere Information zum Unterschied zwischen Base-ID und Chip-ID finden sich unter: https://www.enocean.com/en/knowledge-base-doku/enoceansystemspecification%3Aissue%3Awhat_is_a_base_id/ @@ -77,14 +91,14 @@ Es gibt zwei verschiedene Wege, um die Base ID der Enocean Hardware auszulesen: Zu a) - + 1. Konfiguriere das Enocean plugin in der plugin.yaml mit leerer tx_id (oder tx_id = 0). 2. Starte SmarthomeNG neu. 3. Öffne das Enocean webinterface des Plugins unter: http://smarthome.local:8383/enocean -4. Ablesen der Transceiver's BaseID, welches auf der oberen recten Seite angezeigt wird. +4. Ablesen der Transceiver's BaseID, welches auf der oberen recten Seite angezeigt wird. 5. Übernahme der im Webinterface angezeigten Base-ID in die plugin.yaml als Parameter `tx_id`. @@ -93,60 +107,34 @@ Zu b) 1. Konfiguriere das Enocean plugin in der plugin.yaml mit leerer tx_id (oder tx_id = 0). -2. Konfiguriere das Loglevel INFO in logger.yaml für das Enocean Plugin. Alternativ über das Admin Interface unter Logger +2. Konfiguriere das Loglevel INFO in logger.yaml für das Enocean Plugin. Alternativ über das Admin Interface unter Logs -> Liste der Ligger -> Plugin Logger 3. Starte SmarthomeNG neu. 4. Nach dem Neustart das Logfile öffnen und nach dem Eintrag ``enocean: Base ID = 0xYYYYZZZZ`` suchen. -6. Übernahme dieser im Log angezeigten Base-ID in die plugin.yaml als Parameter `tx_id`. +5. Übernahme dieser im Log angezeigten Base-ID in die plugin.yaml als Parameter `tx_id`. item.yaml --------- -#### enocean_rx_id, enocean_rx_eep and enocean_tx_id_offset Ein EnOcean Item (Sensor oder Aktor) muss mindestens ein ``enocean_rx_id`` und ein ``enocean_rx_eep`` Attribut definieren. -Der Attribut ``enocean_rx_id'' gibt dabei die eindeutige ID (EnOcean Identification Number) als hexadezimaler String an. Dieser ist auf dem Gerät vermerkt. -Alternativ kann über das Webinterface unbekannte IDs von sendeden Enocean Geräten in der Nähe angezeigt werden. +Das Attribut ``enocean_rx_id`` gibt dabei die eindeutige ID (EnOcean Identification Number) als hexadezimaler String an. Sie ist häufig als Aufkleber auf dem EnOcen Gerät vermerkt. +Falls nicht, können über das Webinterface bisher unbekannte IDs von sendenden Enocean Geräten in der Nähe angezeigt werden. Die Enocean EEP gibt das EnOcean Equipment Profile an. Das Datenblatt des Enocean Geräts verrät, welche EEPs unterstützt werden. -In einer Nachricht einer bestimmten Nachricht können sich verschiedene Signale wie z.B. Batteriestatus, Schaltstatus (an/aus) etc. befinden. Um diese Signale den einzelnen smarthomeNG -Items zuzuordnen, werden abgekürzte `Keys` verwendet und als Attribut ``enocean_rx_key angegeben. - -Im Beispiel sind die verschiedenen `Keys` ersichtlich. Folgende `Keys` werden vom Plugin unterstützt: - - **Schalter mit zwei Tastern**, (EEP F6_02_01 oder F6_02_02) - - AI = left rocker down - A0 = left rocker up - BI = right rocker down - B0 = right rocker up - - - **Schalter mit zwei Tastern), (EEP F6_02_03) +In einer bestimmten Nachricht können sich verschiedene Signale wie z.B. Batteriestatus, Schaltstatus (an/aus) etc. befinden. Um diese Signale den einzelnen smarthomeNG +Items zuzuordnen, werden für empfangende Items zusätzlich zu den EEPs noch abgekürzte `Keys` verwendet und als Attribut ``enocean_rx_key`` angegeben. Die EEP definiert also den +von Enocean definierten Nachrichtentyp auf den das Item hören soll. Der zusätzlich nötige Key dann das genaue Signal, da eine EEP mehrere Signale enthält. - AI = left rocker down - A0 = left rocker up - BI = right rocker down - B0 = right rocker up - A = last state of left rocker - B = last state of right rocker - - - **Fenstergriff**, (EEP F6_10_0) - - STATUS = handle_status - - - Sendende Items, z.B. um Schaltaktoren zu schalten, benötigen weiterhin eine Transmitting ID. Diese wird im Attribut ``enocean_tx_id_offset`` definiert. Enocean Equipment Profiles ========================== -Das Encoean Protokoll basiert auf sogenannten EnOcean Equipment Profilen (EEPs). Sie definieren den Nachrichtentyp der vm EnOcean Gerät gesendet wird. +Das Encoean Protokoll basiert auf sogenannten EnOcean Equipment Profilen (EEPs). Sie definieren den Nachrichtentyp der vm EnOcean Gerät gesendet wird. EEPs sind standardisiert. Informationen dazu können unter http://www.enocean-alliance.org/eep/ gefunden werden. @@ -174,10 +162,53 @@ Die folgenden Status EEPs werden vom Plugin aktuell unterstützt: * F6_02_02 2-Button-Rocker * F6_02_03 2-Button-Rocker, Status feedback from manual buttons on different actors, e.g. Eltako FT55, FSUD-230, FSVA-230V, FSB61NP-230V or Gira switches. * F6_10_00 Mechanical Handle (value: 0(closed), 1(open), 2(tilted) - * F6_0G_03 Feedback of shutter actor (Eltako FSB14, FSB61, FSB71 - actor for Shutter) if reaching the endposition and if motor is active + * F6_0G_03 Feedback of shutter actor (Eltako FSB14, FSB61, FSB71 - actor for Shutter) if reaching the endposition and if motor is active Eine vollständige Liste aller EEPs mit detallierten Informationen findet sich unter [EnOcean Alliance](http://www.enocean-alliance.org/eep/) +Keys (nur für empfangende Items) +-------------------------------- + +=============== ==================== ========= ====================================== +Key EEP Itemtyp Bedeutung +=============== ==================== ========= ====================================== +AI F6_02_01, F6_02_02 bool linker Taster runter +A0 F6_02_01, F6_02_02 bool linker Taster rauf +BI F6_02_01, F6_02_02 bool rechter Taster runter +B0 F6_02_01, F6_02_02 bool rechter Taster rauf + +AI F6_02_03 bool linker Taster runter +A0 F6_02_03 bool linker Taster rauf +BI F6_02_03 bool rechter Taster runter +B0 F6_02_03 bool rechter Taster rauf +A F6_02_03 bool letzter Status des linken Tasters +B F6_02_03 bool letzter Status des linken Tasters + +STATUS F6_10_0, D5_00_01 num Fenstergriff- Türstatus + F6_0G_03 + +TMP A5_02_05 num Außentemperatur +BRI A5_06_01, A5_08_01 num Helligkeit +MOV A5_06_01, A5_08_01 bool Bewegung + +STAT A5_11_04, D2_01_07 bool Schaltstatus + +ILL A5_07_03 num Lux +PIR A5_07_03 bool Bewegung +SVC A5_07_03 bool Spannung + +TMP A5_04_02 num Temperatur +HUM A5_04_02 num Luftfeuchtigkeit +ENG A5_04_02 num Powerstatus + +DI_0 A5_3F_7F num RGB Dimmewert rot +DI_1 A5_3F_7F num RGB Dimmewert grün +DI_2 A5_3F_7F num RGB Dimmewert blau +DI_3 A5_3F_7F num RGB Dimmewert weiß + +ALARM A5_30_03 bool Wasseralarm +TEMP A5_30_03 num Temperatur +=============== ==================== ========= ====================================== Steuer EEPs ----------- @@ -206,30 +237,28 @@ Zeile das Icon in der Spalte **Web Interface** anklicken. Außerdem kann das Webinterface direkt über http://smarthome.local:8383/enocean aufgerufen werden. -Das Webinterface zeigt oben rechts allgemeine Informationen, wie +Das Webinterface zeigt oben rechts allgemeine Informationen, wie - * die BaseID der verwendeten Hardware - * ob der Sendemodus aktiviert ist - * ob empfangene Enocean Nachrichten von unbekannten (nicht konfigurierten) Geräten in die Konsole geloggt werden sollen - * ob der UTE Anlernmodus aktiviert ist. +* die BaseID der verwendeten Hardware +* ob der Sendemodus aktiviert ist +* ob empfangene Enocean Nachrichten von unbekannten (nicht konfigurierten) Geräten in die Konsole geloggt werden sollen +* ob der UTE Anlernmodus aktiviert ist. Weiterhin können über Schaltflächen - * der Sendemodus aktiviert und deaktiviert werden - * der UTE Anlernmodus aktiviert und deaktiviert werden - * das Logging von empfangenen Nachrichten unbekannten Enocean Geräte aktiviert und deaktiviert werden. +* der Sendemodus aktiviert und deaktiviert werden +* der UTE Anlernmodus aktiviert und deaktiviert werden +* das Logging von empfangenen Nachrichten unbekannten Enocean Geräte aktiviert und deaktiviert werden. Unter dem TAB `Übersicht` werden alle konfigurierten Enocean items angezeigt. -Unter dem TAB 'Neu anlerenen' können neue Enocean Aktuatoren angelernt werden. Hierzu wird - - a) Der entsprechende Aktor/Stellglied in den Anlernmodus gebracht (siehe jeweilige Bedienungsanleitung) - b) Eine Transmit ID ausgewählt (TX ID). Enocean unterstützt bis zu 127 verschiedene IDs. - c) Als Hinweis bzw. Vorschlag wird die erste freie ID auf der linken Seite angezeigt. - c) Der Aktortyp ausgwählt. Im Plugin wird anhand des Typs das Lerntelegram ausgewählt. - d) Auf die Schaltfläche ``Anlernen`` klicken. Das Anlerntelegram wird gesendet und der Aktor sollte den Anlernvorgang quittieren (siehe jeweilige Bedienungsanleitung). - +Unter dem TAB 'Neu anlerenen' können neue Enocean Aktuatoren angelernt werden. Hierzu wird +a) Der entsprechende Aktor/Stellglied in den Anlernmodus gebracht (siehe jeweilige Bedienungsanleitung) +b) Eine Transmit ID ausgewählt (TX ID). Enocean unterstützt bis zu 127 verschiedene IDs. +c) Als Hinweis bzw. Vorschlag wird die erste freie ID auf der linken Seite angezeigt. +d) Der Aktortyp ausgewählt. Im Plugin wird anhand des Typs das Lerntelegram ausgewählt. +e) Auf die Schaltfläche ``Anlernen`` klicken. Das Anlerntelegram wird gesendet und der Aktor sollte den Anlernvorgang quittieren (siehe jeweilige Bedienungsanleitung). Beispiele @@ -239,309 +268,309 @@ Beispiele für eine Item.yaml mit verschiedenen Enocean Sensoren und Aktoren: .. code-block:: yaml - EnOcean_Item: - Outside_Temperature: - type: num - enocean_rx_id: 0180924D - enocean_rx_eep: A5_02_05 - enocean_rx_key: TMP - - Door: - enocean_rx_id: 01234567 - enocean_rx_eep: D5_00_01 - status: - type: bool - enocean_rx_key: STATUS - - FT55switch: - enocean_rx_id: 012345AA - enocean_rx_eep: F6_02_03 - up: - type: bool - enocean_rx_key: BO - down: - type: bool - enocean_rx_key: BI - - Brightness_Sensor: - name: brightness_sensor_east - remark: Eltako FAH60 - type: num - enocean_rx_id: 01A51DE6 - enocean_rx_eep: A5_06_01 - enocean_rx_key: BRI - visu_acl: rw - sqlite: 'yes' - - dimmer1: - remark: Eltako FDG14 - Dimmer - enocean_rx_id: 00112233 - enocean_rx_eep: A5_11_04 - light: - type: bool - enocean_rx_key: STAT - enocean_tx_eep: A5_38_08_02 - enocean_tx_id_offset: 1 - level: - type: num - enocean_rx_key: D - enocean_tx_eep: A5_38_08_03 - enocean_tx_id_offset: 1 - ref_level: 80 - dim_speed: 100 - block_dim_value: 'False' - - handle: - enocean_rx_id: 01234567 - enocean_rx_eep: F6_10_00 - status: + EnOcean_Item: + Outside_Temperature: type: num - enocean_rx_key: STATUS + enocean_rx_id: 0180924D + enocean_rx_eep: A5_02_05 + enocean_rx_key: TMP - actor1: - enocean_rx_id: FFAABBCC - enocean_rx_eep: A5_12_01 - power: + Door: + enocean_rx_id: 01234567 + enocean_rx_eep: D5_00_01 + status: + type: bool + enocean_rx_key: STATUS + + FT55switch: + enocean_rx_id: 012345AA + enocean_rx_eep: F6_02_03 + up: + type: bool + enocean_rx_key: BO + down: + type: bool + enocean_rx_key: BI + + Brightness_Sensor: + name: brightness_sensor_east + remark: Eltako FAH60 type: num - enocean_rx_key: VALUE - - actor1B: - remark: Eltako FSR61, FSR61NP, FSR61G, FSR61LN, FLC61NP - Switch for Ligths - enocean_rx_id: 1A794D3 - enocean_rx_eep: F6_02_03 - light: - type: bool - enocean_tx_eep: A5_38_08_01 - enocean_tx_id_offset: 1 - enocean_rx_key: B - block_switch: 'False' - cache: 'True' - enforce_updates: 'True' + enocean_rx_id: 01A51DE6 + enocean_rx_eep: A5_06_01 + enocean_rx_key: BRI visu_acl: rw + sqlite: 'yes' - actor_D2: - remark: Actor with VLD Command - enocean_rx_id: FFDB7381 - enocean_rx_eep: D2_01_07 - move: + dimmer1: + remark: Eltako FDG14 - Dimmer + enocean_rx_id: 00112233 + enocean_rx_eep: A5_11_04 + light: type: bool enocean_rx_key: STAT - enocean_tx_eep: D2_01_07 + enocean_tx_eep: A5_38_08_02 enocean_tx_id_offset: 1 - # pulsewith-attribute removed use autotimer functionality instead - autotimer: 1 = 0 - - actorD2_01_12: - enocean_rx_id: 050A2FF4 - enocean_rx_eep: D2_01_12 - switch: - cache: 'on' - type: bool - enocean_rx_key: STAT_A - enocean_channel: A - enocean_tx_eep: D2_01_12 - enocean_tx_id_offset: 2 - - awning: - name: Eltako FSB14, FSB61, FSB71 - remark: actor for Shutter - type: str - enocean_rx_id: 1A869C3 - enocean_rx_eep: F6_0G_03 - enocean_rx_key: STATUS - move: - type: num - enocean_tx_eep: A5_3F_7F - enocean_tx_id_offset: 0 - enocean_rx_key: B - enocean_rtime: 60 - block_switch: 'False' - enforce_updates: 'True' - cache: 'True' - visu_acl: rw + level: + type: num + enocean_rx_key: D + enocean_tx_eep: A5_38_08_03 + enocean_tx_id_offset: 1 + ref_level: 80 + dim_speed: 100 + block_dim_value: 'False' + + handle: + enocean_rx_id: 01234567 + enocean_rx_eep: F6_10_00 + status: + type: num + enocean_rx_key: STATUS + + actor1: + enocean_rx_id: FFAABBCC + enocean_rx_eep: A5_12_01 + power: + type: num + enocean_rx_key: VALUE + + actor1B: + remark: Eltako FSR61, FSR61NP, FSR61G, FSR61LN, FLC61NP - Switch for Ligths + enocean_rx_id: 1A794D3 + enocean_rx_eep: F6_02_03 + light: + type: bool + enocean_tx_eep: A5_38_08_01 + enocean_tx_id_offset: 1 + enocean_rx_key: B + block_switch: 'False' + cache: 'True' + enforce_updates: 'True' + visu_acl: rw + + actor_D2: + remark: Actor with VLD Command + enocean_rx_id: FFDB7381 + enocean_rx_eep: D2_01_07 + move: + type: bool + enocean_rx_key: STAT + enocean_tx_eep: D2_01_07 + enocean_tx_id_offset: 1 + # pulsewith-attribute removed use autotimer functionality instead + autotimer: 1 = 0 + + actorD2_01_12: + enocean_rx_id: 050A2FF4 + enocean_rx_eep: D2_01_12 + switch: + cache: 'on' + type: bool + enocean_rx_key: STAT_A + enocean_channel: A + enocean_tx_eep: D2_01_12 + enocean_tx_id_offset: 2 - rocker: - enocean_rx_id: 0029894A - enocean_rx_eep: F6_02_01 - short_800ms_directly_to_knx: - type: bool - enocean_rx_key: AI - enocean_rocker_action: **toggle** - enocean_rocker_sequence: released **within** 0.8 - knx_dpt: 1 - knx_send: 3/0/60 + awning: + name: Eltako FSB14, FSB61, FSB71 + remark: actor for Shutter + type: str + enocean_rx_id: 1A869C3 + enocean_rx_eep: F6_0G_03 + enocean_rx_key: STATUS + move: + type: num + enocean_tx_eep: A5_3F_7F + enocean_tx_id_offset: 0 + enocean_rx_key: B + enocean_rtime: 60 + block_switch: 'False' + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + + rocker: + enocean_rx_id: 0029894A + enocean_rx_eep: F6_02_01 + short_800ms_directly_to_knx: + type: bool + enocean_rx_key: AI + enocean_rocker_action: '**toggle**' + enocean_rocker_sequence: 'released **within** 0.8' + knx_dpt: 1 + knx_send: 3/0/60 - long_800ms_directly_to_knx: - type: bool - enocean_rx_key: AI - enocean_rocker_action: toggle - enocean_rocker_sequence: released **after** 0.8 - knx_dpt: 1 - knx_send: 3/0/61 + long_800ms_directly_to_knx: + type: bool + enocean_rx_key: AI + enocean_rocker_action: toggle + enocean_rocker_sequence: 'released **after** 0.8' + knx_dpt: 1 + knx_send: 3/0/61 - rocker_double_800ms_to_knx_send_1: - type: bool - enforce_updates: true - enocean_rx_key: AI - enocean_rocker_action: **set** - enocean_rocker_sequence: **released within 0.4, pressed within 0.4** - knx_dpt: 1 - knx_send: 3/0/62 - - brightness_sensor: - enocean_rx_id: 01234567 - enocean_rx_eep: A5_08_01 - lux: - type: num - enocean_rx_key: BRI + rocker_double_800ms_to_knx_send_1: + type: bool + enforce_updates: true + enocean_rx_key: AI + enocean_rocker_action: '**set**' + enocean_rocker_sequence: '**released within 0.4, pressed within 0.4**' + knx_dpt: 1 + knx_send: 3/0/62 + + brightness_sensor: + enocean_rx_id: 01234567 + enocean_rx_eep: A5_08_01 + lux: + type: num + enocean_rx_key: BRI + + movement: + type: bool + enocean_rx_key: MOV - movement: - type: bool - enocean_rx_key: MOV + occupancy_sensor: + enocean_rx_id: 01234567 + enocean_rx_eep: A5_07_03 + lux: + type: num + enocean_rx_key: ILL - occupancy_sensor: - enocean_rx_id: 01234567 - enocean_rx_eep: A5_07_03 - lux: - type: num - enocean_rx_key: ILL + movement: + type: bool + enocean_rx_key: PIR - movement: - type: bool - enocean_rx_key: PIR + voltage: + type: bool + enocean_rx_key: SVC - voltage: - type: bool - enocean_rx_key: SVC + temperature_sensor: + enocean_rx_id: 01234567 + enocean_rx_eep: A5_04_02 + temperature: + type: num + enocean_rx_key: TMP - temperature_sensor: - enocean_rx_id: 01234567 - enocean_rx_eep: A5_04_02 - temperature: - type: num - enocean_rx_key: TMP + humidity: + type: num + enocean_rx_key: HUM - humidity: - type: num - enocean_rx_key: HUM + power_status: + type: num + enocean_rx_key: ENG - power_status: - type: num - enocean_rx_key: ENG - - sunblind: - name: Eltako FSB14, FSB61, FSB71 - remark: actor for Shutter - type: str - enocean_rx_id: 1A869C3 - enocean_rx_eep: F6_0G_03 - enocean_rx_key: STATUS - # runtime Range [0 - 255] s - enocean_rtime: 80 - Tgt_Position: + sunblind: name: Eltako FSB14, FSB61, FSB71 - remark: Pos. 0...255 - type: num - enocean_rx_id: ..:. - enocean_rx_eep: ..:. - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - Act_Position: - name: Eltako FSB14, FSB61, FSB71 - remark: Ist-Pos. 0...255 berechnet aus (letzer Pos. + Fahrzeit * 255/rtime) - type: num - enocean_rx_id: ..:. - enocean_rx_eep: ..:. - enocean_rx_key: POSITION - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - eval: min(max(value, 0), 255) - on_update: - - EnOcean_Item.sunblind = 'stopped' - Run: - name: Eltako FSB14, FSB61, FSB71 - remark: Ansteuerbefehl 0x00, 0x01, 0x02 - type: num - enocean_rx_id: ..:. - enocean_rx_eep: ..:. - enocean_tx_eep: A5_3F_7F - enocean_tx_id_offset: 0 - enocean_rx_key: B - enocean_rtime: ..:. - # block actuator - block_switch: 'True' - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - struct: uzsu.child - Movement: - name: Eltako FSB14, FSB61, FSB71 - remark: Wenn Rolladen gestoppt wurde steht hier die gefahrene Zeit in s und die Richtung - type: num - enocean_rx_id: ..:. - enocean_rx_eep: A5_0G_03 - enocean_rx_key: MOVE - cache: 'False' - enforce_updates: 'True' - eval: value * 255/int(sh.EnOcean_Item.sunblind.property.enocean_rtime) - on_update: - - EnOcean_Item.sunblind = 'stopped' - - EnOcean_Item.sunblind.Act_Position = EnOcean_Item.sunblind.Act_Position() + value - - RGBdimmer: - type: num - remark: Eltako FRGBW71L - RGB Dimmer - enocean_rx_id: 1A869C3 - enocean_rx_eep: A5_3F_7F - enocean_rx_key: DI_0 - red: + remark: actor for Shutter + type: str + enocean_rx_id: 1A869C3 + enocean_rx_eep: F6_0G_03 + enocean_rx_key: STATUS + # runtime Range [0 - 255] s + enocean_rtime: 80 + Tgt_Position: + name: Eltako FSB14, FSB61, FSB71 + remark: Pos. 0...255 + type: num + enocean_rx_id: ..:. + enocean_rx_eep: ..:. + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + Act_Position: + name: Eltako FSB14, FSB61, FSB71 + remark: Ist-Pos. 0...255 berechnet aus (letzer Pos. + Fahrzeit * 255/rtime) + type: num + enocean_rx_id: ..:. + enocean_rx_eep: ..:. + enocean_rx_key: POSITION + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + eval: min(max(value, 0), 255) + on_update: + - EnOcean_Item.sunblind = 'stopped' + Run: + name: Eltako FSB14, FSB61, FSB71 + remark: Ansteuerbefehl 0x00, 0x01, 0x02 + type: num + enocean_rx_id: ..:. + enocean_rx_eep: ..:. + enocean_tx_eep: A5_3F_7F + enocean_tx_id_offset: 0 + enocean_rx_key: B + enocean_rtime: ..:. + # block actuator + block_switch: 'True' + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + struct: uzsu.child + Movement: + name: Eltako FSB14, FSB61, FSB71 + remark: Wenn Rolladen gestoppt wurde steht hier die gefahrene Zeit in s und die Richtung + type: num + enocean_rx_id: ..:. + enocean_rx_eep: A5_0G_03 + enocean_rx_key: MOVE + cache: 'False' + enforce_updates: 'True' + eval: value * 255/int(sh.EnOcean_Item.sunblind.property.enocean_rtime) + on_update: + - EnOcean_Item.sunblind = 'stopped' + - EnOcean_Item.sunblind.Act_Position = EnOcean_Item.sunblind.Act_Position() + value + + RGBdimmer: type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 + remark: Eltako FRGBW71L - RGB Dimmer + enocean_rx_id: 1A869C3 + enocean_rx_eep: A5_3F_7F enocean_rx_key: DI_0 - ref_level: 80 - dim_speed: 100 - color: red - green: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_1 - ref_level: 80 - dim_speed: 100 - color: green - blue: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_2 - ref_level: 80 - dim_speed: 100 - color: blue - white: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_3 - ref_level: 80 - dim_speed: 100 - color: white - water_sensor: - enocean_rx_id: 00000000 - enocean_rx_eep: A5_30_03 - - alarm: - type: bool - enocean_rx_key: ALARM - visu_acl: ro + red: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_0 + ref_level: 80 + dim_speed: 100 + color: red + green: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_1 + ref_level: 80 + dim_speed: 100 + color: green + blue: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_2 + ref_level: 80 + dim_speed: 100 + color: blue + white: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_3 + ref_level: 80 + dim_speed: 100 + color: white + water_sensor: + enocean_rx_id: 00000000 + enocean_rx_eep: A5_30_03 + + alarm: + type: bool + enocean_rx_key: ALARM + visu_acl: ro + + temperature: + type: num + enocean_rx_key: TEMP + visu_acl: ro - temperature: - type: num - enocean_rx_key: TEMP - visu_acl: ro - diff --git a/enocean/webif/templates/base_enocean.html b/enocean/webif/templates/base_enocean.html index ffa8d235c..61cdc0892 100755 --- a/enocean/webif/templates/base_enocean.html +++ b/enocean/webif/templates/base_enocean.html @@ -7,21 +7,31 @@ {{ _('BaseID') }} {{ p.get_tx_id_as_hex() }} + + {{ _('Hardware Port') }} + {{ p.get_serial_status_as_string() }} - {{ _('Senden') }} {% if p._block_ext_out_msg %}{{ _('Blockiert') }}{% else %}{{ _('Ein') }}{% endif %} + + + {{ _('UTE Listen') }} {% if p.UTE_listen %}{{ _('Aktiv') }}{% else %}{{ _('Aus') }}{% endif %} + + + {{ _('Log Unknown') }} {% if p.get_log_unknown_msg() %}{{ _('Aktiv') }}{% else %}{{ _('Aus') }}{% endif %} + + + - {% endblock headtable %} diff --git a/epson/__init__.py b/epson/__init__.py new file mode 100755 index 000000000..f68022d22 --- /dev/null +++ b/epson/__init__.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2016 +######################################################################### +# This file is part of SmartHomeNG +# +# Denon AV plugin for SmartDevicePlugin class +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG If not, see . +######################################################################### + +import builtins +import os +import sys + +if __name__ == '__main__': + builtins.SDP_standalone = True + + class SmartPlugin(): + pass + + class SmartPluginWebIf(): + pass + + BASE = os.path.sep.join(os.path.realpath(__file__).split(os.path.sep)[:-3]) + sys.path.insert(0, BASE) + +else: + builtins.SDP_standalone = False + +from lib.model.sdp.globals import (PLUGIN_ATTR_CONNECTION, PLUGIN_ATTR_SERIAL_PORT, PLUGIN_ATTR_CONN_TERMINATOR, CONN_NULL, CONN_SER_ASYNC) +from lib.model.smartdeviceplugin import SmartDevicePlugin, Standalone + + +class epson(SmartDevicePlugin): + """ Device class for Epson projectors. """ + + PLUGIN_VERSION = '1.0.0' + + def _set_device_defaults(self): + + if PLUGIN_ATTR_SERIAL_PORT in self._parameters and self._parameters[PLUGIN_ATTR_SERIAL_PORT]: + self._parameters[PLUGIN_ATTR_CONNECTION] = CONN_SER_ASYNC + else: + self.logger.error('No serialport set, connection not possible. Using dummy connection, plugin will not work') + self._parameters[PLUGIN_ATTR_CONNECTION] = CONN_NULL + + b = self._parameters[PLUGIN_ATTR_CONN_TERMINATOR].encode() + b = b.decode('unicode-escape').encode() + self._parameters[PLUGIN_ATTR_CONN_TERMINATOR] = b + + def _transform_send_data(self, data=None, **kwargs): + if isinstance(data, dict): + data['limit_response'] = self._parameters[PLUGIN_ATTR_CONN_TERMINATOR] + data['payload'] = f'{data.get("payload", "")}{data["limit_response"].decode("unicode-escape")}' + return data + + def on_data_received(self, by, data, command=None): + + commands = None + if command is not None: + self.logger.debug(f'received data "{data}" from {by} for command {command}') + commands = [command] + else: + # command == None means that we got raw data from a callback and + # don't know yet to which command this belongs to. So find out... + self.logger.debug(f'received data "{data}" from {by} without command specification') + + # command can be a string (classic single command) or + # - new - a list of strings if multiple commands are identified + # in that case, work on all strings + commands = self._commands.get_commands_from_reply(data) + if not commands: + if self._discard_unknown_command: + self.logger.debug(f'data "{data}" did not identify a known command, ignoring it') + return + else: + self.logger.debug(f'data "{data}" did not identify a known command, forwarding it anyway for {self._unknown_command}') + self._dispatch_callback(self._unknown_command, data, by) + + # TODO: remove later? + assert(isinstance(commands, list)) + + # process all commands + for command in commands: + custom = None + if self.custom_commands: + custom = self._get_custom_value(command, data) + + base_command = command + value = None + try: + value = self._commands.get_shng_data(command, data) + except OSError as e: + self.logger.warning(f'received data "{data}" for command {command}, error {e} occurred while converting. Discarding data.') + else: + self.logger.debug(f'received data "{data}" for command {command} converted to value {value}') + self._dispatch_callback(command, value, by) + + self._process_additional_data(base_command, data, value, custom, by) + +if __name__ == '__main__': + s = Standalone(epson, sys.argv[0]) diff --git a/epson/commands.py b/epson/commands.py new file mode 100755 index 000000000..6e7cf1260 --- /dev/null +++ b/epson/commands.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +""" commands for dev epson + +Most commands send a string (fixed for reading, attached data for writing) +while parsing the response works by extracting the needed string part by +regex. Some commands translate the device data into readable values via +lookups. +""" + +models = { + 'ALL': ['power', 'source'] +} + +commands = { + 'power': {'read': True, 'write': True, 'read_cmd': 'PWR?', 'write_cmd': 'PWR {VALUE}', 'item_type': 'bool', 'dev_datatype': 'onoff', 'reply_pattern': ['^(?:\:+)?\s?PWR=0(0|1)', '^:+WR=0(0|1)'], 'item_attrs': {'cycle': '60', 'initial': True}}, + 'source': {'read': True, 'write': True, 'write_cmd': 'SOURCE {RAW_VALUE_UPPER}', 'item_type': 'str', 'dev_datatype': 'raw', 'reply_pattern': 'SOURCE {LOOKUP}', 'lookup': 'SOURCE'} +} + +lookups = { + 'ALL': { + 'SOURCE': { + '11': 'Analog', + '12': 'Digital', + '13': 'Video', + '14': 'YCbCr (4 Component)', + '15': 'YPbPr (4 Component)', + '1F': 'Auto', + '21': 'Analog', + '22': 'Video', + '23': 'YCbCr', + '24': 'YPbPr (5 Component)', + '25': 'YPbPr', + '2F': 'Auto', + 'A0': 'HDMI', + '41': 'Video', + '42': 'S-Video', + '43': 'YCbCr', + '44': 'YPbPr' + } + } +} diff --git a/epson/datatypes.py b/epson/datatypes.py new file mode 100755 index 000000000..8dce04e3e --- /dev/null +++ b/epson/datatypes.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab + +import lib.model.sdp.datatypes as DT + +class DT_onoff(DT.Datatype): + def get_send_data(self, data, **kwargs): + return 'ON' if data else 'OFF' + + def get_shng_data(self, data, type=None, **kwargs): + return False if data == '0' else True if data == '1' else None diff --git a/epson/plugin.yaml b/epson/plugin.yaml new file mode 100755 index 000000000..947df4d6c --- /dev/null +++ b/epson/plugin.yaml @@ -0,0 +1,297 @@ + +plugin: + type: interface + description: Epson Projectors + maintainer: OnkelAndy + tester: Morg + state: develop + keywords: iot device av epson sdp + version: 1.0.0 + sh_minversion: 1.9.5 + py_minversion: 3.7 + multi_instance: false + restartable: true + classname: epson + +parameters: + + model: + type: str + mandatory: false + valid_list: + - '' + - TW-5000 + + description: + de: Modellauswahl + en: model selection + + timeout: + type: num + default: 3 + + description: + de: Timeout für Geräteantwort + en: timeout for device replies + + terminator: + type: str + default: "\r" + + description: + de: Zeilen-/Antwortbegrenzer + en: line or reply terminator + + binary: + type: bool + default: false + + description: + de: Binärer Übertragungsmodus + en: binary communication mode + + baudrate: + type: num + default: 9600 + + description: + de: Serielle Übertragungsgeschwindigkeit + en: serial transmission speed + + bytesize: + type: num + default: 8 + + description: + de: Anzahl Datenbits + en: number of data bits + + parity: + type: str + default: N + valid_list: + - N + - E + - O + - M + - S + + description: + de: Parität + en: parity + + stopbits: + type: num + default: 1 + + description: + de: Anzahl Stopbits + en: number of stop bits + + host: + type: str + mandatory: false + + description: + de: Netzwerkziel/-host + en: network host + + port: + type: int + default: 23 + + description: + de: Port für Netzwerkverbindung + en: network port + + serialport: + type: str + mandatory: false + + description: + de: Serieller Anschluss (z.B. /dev/ttyUSB0 oder COM1) + en: serial port (e.g. /dev/ttyUSB0 or COM1) + + conn_type: + type: str + default: serial_async + valid_list: + - '' + - net_tcp_client + - serial_async + + description: + de: Verbindungstyp + en: connection type + + command_class: + type: str + default: SDPCommandParseStr + valid_list: + - SDPCommand + - SDPCommandParseStr + + description: + de: Klasse für Verarbeitung von Kommandos + en: class for command processing + + autoreconnect: + type: bool + default: true + + description: + de: Automatisches Neuverbinden bei Abbruch + en: automatic reconnect on disconnect + + autoconnect: + type: bool + mandatory: false + + description: + de: Automatisches Verbinden bei Senden + en: automatic connect on send + + connect_retries: + type: num + default: 5 + + description: + de: Anzahl Verbindungsversuche + en: number of connect retries + + connect_cycle: + type: num + default: 3 + + description: + de: Pause zwischen Verbindungsversuchen + en: wait time between connect retries + + retry_cycle: + type: num + default: 30 + + description: + de: Pause zwischen Durchgängen von Verbindungsversuchen + en: wait time between connect retry rounds + + retry_suspend: + type: num + default: 3 + + description: + de: Anzahl von Durchgängen vor Verbindungsabbruch oder Suspend-Modus + en: number of connect rounds before giving up / entering suspend mode + + suspend_item: + type: str + default: '' + + description: + de: Item-Pfad für das Standby-Item + en: item path for standby switch item + + +item_attributes: + + epson_command: + type: str + + description: + de: Legt das angegebene Kommando für das Item fest + en: Assigns the given command to the item + + epson_read: + type: bool + + description: + de: Item liest/erhält Werte vom Gerät + en: Item reads/receives data from the device + + epson_read_group: + type: list(str) + + description: + de: Weist das Item der angegebenen Gruppe zum gesammelten Lesen zu. Mehrere Gruppen können als Liste angegeben werden. + en: Assigns the item to the given group for collective reading. Multiple groups can be provided as a list. + + epson_read_cycle: + type: num + + description: + de: Konfiguriert ein Intervall in Sekunden für regelmäßiges Lesen + en: Configures a interval in seconds for cyclic read actions + + epson_read_initial: + type: bool + + description: + de: Legt fest, dass der Wert beim Start vom Gerät gelesen wird + en: Sets item value to be read from the device on startup + + epson_write: + type: bool + + description: + de: Änderung des Items werden an das Gerät gesendet + en: Changes to this item will be sent to the device + + epson_read_group_trigger: + type: str + + description: + de: Wenn diesem Item ein beliebiger Wert zugewiesen wird, werden alle zum Lesen konfigurierten Items der angegebenen Gruppe neu vom Gerät gelesen, bei Gruppe 0 werden alle zum Lesen konfigurierten Items neu gelesen. Das Item kann nicht gleichzeitig mit epson_command belegt werden. + en: When set to any value, all items configured for reading for the given group will update their value from the device, if group is 0, all items configured for reading will update. The item cannot be used with epson_command in parallel. + + epson_lookup: + type: str + + description: + de: Der Inhalt der Lookup-Tabelle mit dem angegebenen Namen wird beim Start einmalig als dict oder list in das Item geschrieben. + en: The lookup table with the given name will be assigned to the item in dict or list format once on startup. + + description_long: + de: "Der Inhalt der Lookup-Tabelle mit dem angegebenen Namen wird beim\nStart einmalig als dict oder list in das Item geschrieben.\n\n\nDurch Anhängen von \"#\" an den Namen der Tabelle kann die Art\nder Tabelle ausgewählt werden:\n- fwd liefert die Tabelle Gerät -> SmartHomeNG (Standard)\n- rev liefert die Tabelle SmartHomeNG -> Gerät\n- rci liefert die Tabelle SmarthomeNG -> Gerät in Kleinbuchstaben\n- list liefert die Liste der Namen für SmartHomeNG (z.B. für Auswahllisten in der Visu)" + en: "The lookup table with the given name will be assigned to the item\nin dict or list format once on startup.\n\n\nBy appending \"#\" to the tables name the type of table can\nbe selected:\n- fwd returns the table device -> SmartHomeNG (default)\n- rev returns the table SmartHomeNG -> device\n- rci returns the table SmartHomeNG -> device in lower case\n- list return the list of names for SmartHomeNG (e.g. for selection dropdowns in visu applications)" + +item_structs: + + power: + type: bool + epson_command: power + epson_read: true + epson_write: true + epson_read_group: [] + epson_read_initial: true + epson_read_cycle: '60' + + source: + type: str + epson_command: source + epson_read: true + epson_write: true + + ALL: + + read: + type: bool + enforce_updates: true + epson_read_group_trigger: ALL + + power: + type: bool + epson_command: power + epson_read: true + epson_write: true + epson_read_group: + - ALL + epson_read_initial: true + epson_read_cycle: '60' + + source: + type: str + epson_command: source + epson_read: true + epson_write: true +plugin_functions: NONE +logic_parameters: NONE diff --git a/epson/user_doc.rst b/epson/user_doc.rst new file mode 100755 index 000000000..3f2859a59 --- /dev/null +++ b/epson/user_doc.rst @@ -0,0 +1,87 @@ +.. index:: Plugins; epson +.. index:: epson + +===== +epson +===== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 768px + :height: 249px + :scale: 25 % + :align: center + +Steuerung eines Epson Projektors über RS232 Schnittstelle. Theoretisch klappt auch +die Verbindung via TCP - wurde aber nicht getestet! + +Das Plugin unterstützt eine Reihe von Epson Projektoren. Folgendes Modell wurde +konkret berücksichtigt, andere Modelle funktionieren aber mit hoher Wahrscheinlichkeit +auch. + +- TW-5000 + + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/epson` beschrieben. + + +plugin.yaml +----------- + +.. code-block:: yaml + + # etc/plugin.yaml + epson: + plugin_name: epson + model: TW-5000 + timeout: 3 + terminator: "\r" + binary: false + autoreconnect: true + autoconnect: true + connect_retries: 5 + connect_cycle: 3 + serialport: /dev/ttyUSB0 + conn_type: serial_async + command_class: SDPCommandParseStr + + +Struct Vorlagen +=============== + +Der Itembaum sollte jedenfalls über die structs Funktion eingebunden werden. Hierzu gibt es vier +Varianten, wobei die letzte die optimale Lösung darstellt: + +- einzelne Struct-Teile wie epson.power +- epson.ALL: Hierbei werden sämtliche Kommandos eingebunden, die vom Plugin vorgesehen sind +- epson.TW-5000 bzw. die anderen unterstützten Modelle, um nur die relevanten Items einzubinden +- epson.MODEL: Es wird automatisch der Itembaum für das Modell geladen, das im plugin.yaml angegeben ist. + +Sollte das selbst verwendete Modell nicht im Plugin vorhanden sein, kann der Plugin Maintainer +angeschrieben werden, um das Modell aufzunehmen. + +.. code-block:: yaml + + # items/my.yaml + Epson: + type: foo + struct: epson.MODEL + + +Kommandos +========= + +Die RS232 oder IP-Befehle des Geräts sind in der Datei `commands.py` hinterlegt. Etwaige +Anpassungen und Ergänzungen sollten als Pull Request oder durch Rücksprache mit dem Maintainer +direkt ins Plugin einfließen, damit diese auch von anderen Nutzer:innen eingesetzt werden können. + + +Web Interface +============= + +Aktuell ist kein Web Interface integriert. In naher Zukunft soll dies über die +SmartDevicePlugin Bibliothek automatisch zur Verfügung gestellt werden. diff --git a/epson/webif/static/img/plugin_logo.png b/epson/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..f9c47f41b Binary files /dev/null and b/epson/webif/static/img/plugin_logo.png differ diff --git a/executor/__init__.py b/executor/__init__.py index c4f70e5b8..740b18b69 100755 --- a/executor/__init__.py +++ b/executor/__init__.py @@ -40,7 +40,7 @@ class Executor(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '1.1.1' + PLUGIN_VERSION = '1.2.1' def __init__(self, sh): """ @@ -142,4 +142,3 @@ def init_webinterface(self): description='') return True - diff --git a/executor/examples/check_device_presence.py b/executor/examples/check_device_presence.py new file mode 100644 index 000000000..c7dfcc743 --- /dev/null +++ b/executor/examples/check_device_presence.py @@ -0,0 +1,14 @@ +import os + +with os.popen('ip neigh show') as result: + # permanent, noarp, reachable, stale, none, incomplete, delay, probe, failed + ip = '192.168.10.56' + mac = "b4:b5:2f:ce:6d:29" + value = False + lines = str(result.read()).splitlines() + for line in lines: + if (ip in line or mac in line) and ("REACHABLE" in line or "STALE" in line): + value = True + break + #sh.devices.laptop.status(value)​ + print(f"set item to {value}") \ No newline at end of file diff --git a/executor/examples/check_items.py b/executor/examples/check_items.py new file mode 100644 index 000000000..e41eb3316 --- /dev/null +++ b/executor/examples/check_items.py @@ -0,0 +1,95 @@ +""" +given following items within a yaml: + + +MyItem: + MyChildItem: + type: num + initial_value: 12 + MyGrandchildItem: + type: str + initial_value: "foo" + +Within a logic it is possible to set the value of MyChildItem to 42 with +``sh.MyItem.MyChildItem(42)`` and retrieve the Items value with +``value = sh.MyItem.MyChildItem()`` + +Often beginners forget the parentheses and instead write +``sh.MyItem.MyChildItem = 42`` when they really intend to assign the value ``42`` +to the item or write ``value = sh.MyItem.MyChildItem`` when they really want to +retrieve the item's value. + +But using ``sh.MyItem.MyChildItem = 42`` destroys the structure here and makes +it impossible to retrieve the value of the child +``MyItem.MyChildItem.MyGrandchildItem`` +Alike, an instruction as ``value = sh.MyItem.MyChildItem`` will not assign the +value of ``sh.MyItem.MyChildItem`` but assign a reference to the item object +``sh.MyItem.MyChildItem`` + +It is not possible with Python to intercept an assignment to a variable or an +objects' attribute. The only thing one can do is search all items for a +mismatching item type. + +This logic checks all items returned by SmartHomeNG, and if it encounters one +which seems to be damaged like described before, it attempts to repair the +broken assignment. + +""" +from lib.item import Items +from lib.item.item import Item + +def repair_item(sh, item): + path = item.id() + path_elems = path.split('.') + ref = sh + + # traverse through object structure sh.path1.path2... + try: + for path_part in path_elems[:-1]: + ref = getattr(ref, path_part) + + setattr(ref, path_elems[-1], item) + print(f'Item reference repaired for {path}') + return True + except NameError: + print(f'Error: item traversal for {path} failed at part {path_part}. Item list not sorted?') + + return False + + +def get_item_type(sh, path): + expr = f'type(sh.{path})' + return str(eval(expr)) + + +def check_item(sh, path): + global get_item_type + + return get_item_type(sh, path) == "" + + +# to get access to the object instance: +items = Items.get_instance() + +# to access a method (eg. to get the list of Items): +# allitems = items.return_items() +problems_found = 0 +problems_fixed = 0 + +for one in items.return_items(ordered=True): + # get the items full path + path = one.property.path + try: + if not check_item(sh, path): + logger.error(f"Error: item {path} has type {get_item_type(sh, path)} but should be an Item Object") + problems_found += 1 + if repair_item(sh, one): + if check_item(sh, path): + problems_fixed += 1 + except ValueError as e: + logger.error(f'Error {e} while processing item {path}, parent defective? Items not sorted?') + +if problems_found: + logger.error(f"{problems_found} problematic item assignment{'' if problems_found == 1 else 's'} found, {problems_fixed} item assignment{'' if problems_fixed == 1 else 's'} fixed") +else: + logger.warning("no problems found") diff --git a/executor/examples/database_count.py b/executor/examples/database_count.py new file mode 100644 index 000000000..3d80771a5 --- /dev/null +++ b/executor/examples/database_count.py @@ -0,0 +1,9 @@ +from lib.item import Items +items = Items.get_instance() +myfiller = " " +allItems = items.return_items() +for myItem in allItems: + if not hasattr(myItem,'db'): + continue + mycount = myItem.db('countall', 0) + print (myItem.property.name + myfiller[0:len(myfiller)-len(myItem.property.name)]+ ' - Anzahl Datensätze :'+str(mycount)) diff --git a/executor/examples/database_series.py b/executor/examples/database_series.py new file mode 100644 index 000000000..88a4bcbe3 --- /dev/null +++ b/executor/examples/database_series.py @@ -0,0 +1,9 @@ +import json + +def myconverter(o): +import datetime +if isinstance(o, datetime.datetime): + return o.__str__() +data = sh..series('max','1d','now') +pretty = json.dumps(data, default = myconverter, indent = 2, separators=(',', ': ')) +print(pretty) diff --git a/executor/plugin.yaml b/executor/plugin.yaml index 3933b87be..351405348 100755 --- a/executor/plugin.yaml +++ b/executor/plugin.yaml @@ -6,13 +6,13 @@ plugin: de: 'Ausführen von Python Statements im Kontext von SmartHomeNG v1.5 und höher' en: 'Execute Python statements in the context of SmartHomeNG v1.5 and up' maintainer: bmxp - tester: nobody # Who tests this plugin? + tester: onkelandy state: ready # change to ready when done with development keywords: Python eval exec code test documentation: https://www.smarthomeng.de/user/plugins/executor/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1425152-support-thread-plugin-executor - version: 1.1.1 # Plugin version + version: 1.2.1 # Plugin version sh_minversion: 1.9 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) py_minversion: 3.8 # minimum Python version to use for this plugin, use f-strings for debug diff --git a/executor/user_doc.rst b/executor/user_doc.rst index 7c749a259..53238c335 100755 --- a/executor/user_doc.rst +++ b/executor/user_doc.rst @@ -16,7 +16,7 @@ executor Einführung ~~~~~~~~~~ -Das executor plugin kann genutzt werden, um **Python Code** (z.B. für **Logiken**) und **eval Ausdrücke** zu testen. +Das executor Plugin kann genutzt werden, um **Python Code** (z.B. für **Logiken**) zu testen. .. important:: @@ -35,8 +35,8 @@ Damit wird dem Plugin eine relative Pfadangabe unterhalb *var* angegeben wo Skri Webinterface ============ -Im Webinterface findet sich eine Listbox mit den auf dem Rechner gespeicherten Skripten. -Um das Skript in den Editor zu laden entweder ein Skript in der Liste einfach anklicken und auf *aus Datei laden* klicken oder +Im Webinterface findet sich eine Listbox mit den auf dem Rechner gespeicherten Skripten. +Um das Skript in den Editor zu laden, entweder ein Skript in der Liste einfach anklicken und auf *aus Datei laden* klicken oder direkt in der Liste einen Doppelklick auf die gewünschte Datei ausführen. Der Dateiname wird entsprechend der gewählten Datei gesetzt. Mit Klick auf *aktuellen Code speichern* wird der Code im konfigurierten @@ -46,7 +46,7 @@ Mit einem Klick auf *Code ausführen!* oder der Kombination Ctrl+Return wird der Das kann gerade bei Datenbank Abfragen recht lange dauern. Es kann keine Rückmeldung von SmartHomeNG abgefragt werden wie weit der Code derzeit ist. Das Ergebnis wird unten angezeigt. Solange kein Ergebnis vorliegt, steht im Ergebniskasten **... processing ...** -Mit einem Klick auf Datei löschen wird versucht die unter Dateiname angezeigte Datei ohne Rückfrage zu löschen. +Mit einem Klick auf *Datei löschen* wird versucht, die unter Dateiname angezeigte Datei ohne Rückfrage zu löschen. Anschliessend wird die Liste der Skripte aktualisiert. Beispiel Python Code @@ -55,7 +55,9 @@ Beispiel Python Code Sowohl ``logger`` als auch ``print`` funktionieren für die Ausgabe von Ergebnissen. Die Idee ist, dass Logiken mehr oder weniger 1:1 kopiert und getestet werden können. + Loggertest +---------- .. code-block:: python @@ -66,6 +68,7 @@ Loggertest Datenserien für ein Item ausgeben +--------------------------------- Abfragen von Daten aus dem database plugin für ein spezifisches Item: @@ -111,4 +114,20 @@ würde in folgendem Ergebnis münden: ] } -Damit die Nutzung + +Zählen der Datensätze in der Datenbank +-------------------------------------- + +Das folgende Snippet zeigt alle Datenbank-Items an und zählt die Einträge in der Datenbank. Vorsicht: Dies kann sehr lange dauern, wenn Sie eine große Anzahl von Einträgen mit Datenbankattributen haben. + +.. code-block:: python + + from lib.item import Items + items = Items.get_instance() + myfiller = " " + allItems = items.return_items() + for myItem in allItems: + if not hasattr(myItem,'db'): + continue + mycount = myItem.db('countall', 0) + print (myItem.property.name + myfiller[0:len(myfiller)-len(myItem.property.name)]+ ' - Anzahl Datensätze :'+str(mycount)) diff --git a/executor/webif/__init__.py b/executor/webif/__init__.py index a74caa780..512d899c9 100755 --- a/executor/webif/__init__.py +++ b/executor/webif/__init__.py @@ -48,7 +48,7 @@ import csv from jinja2 import Environment, FileSystemLoader -import sys +import sys class PrintCapture: """this class overwrites stdout and stderr temporarily to capture output""" @@ -197,11 +197,11 @@ def eval_statement(self, eline, path, reload=None): def exec_code(self, eline, reload=None): """ evaluate a whole python block in eline - + :return: result of the evaluation """ result = "" - stub_logger = Stub(warning=print, info=print, debug=print, error=print) + stub_logger = Stub(warning=print, info=print, debug=print, error=print, criticl=print, notice=print, dbghigh=print, dbgmed=print, dbglow=print) g = {} l = { 'sh': self.plugin.get_sh(), @@ -233,15 +233,19 @@ def get_code(self, filename=''): """loads and returns the given filename from the defined script path""" self.logger.debug(f"get_code called with {filename=}") try: - if self.plugin.executor_scripts is not None and filename != '': - filepath = os.path.join(self.plugin.executor_scripts,filename) - self.logger.debug(f"{filepath=}") + if (self.plugin.executor_scripts is not None and filename != '') or filename.startswith('examples/'): + if filename.startswith('examples/'): + filepath = os.path.join(self.plugin.get_plugin_dir(),filename) + self.logger.debug(f"Getting file from example path {filepath=}") + else: + filepath = os.path.join(self.plugin.executor_scripts,filename) + self.logger.debug(f"Getting file from script path {filepath=}") code_file = open(filepath) data = code_file.read() code_file.close() return data - except: - self.logger.error(f"{filepath} could not be read") + except Exception as e: + self.logger.error(f"{filepath} could not be read: {e}") return f"### {filename} could not be read ###" @cherrypy.expose @@ -283,20 +287,29 @@ def delete_file(self, filename=''): @cherrypy.expose def get_filelist(self): - """returns all filenames from the defined script path with suffix ``.py``""" - + """returns all filenames from the defined script path with suffix ``.py``, newest first""" + files = [] + files2 = [] + subdir = "{}/examples".format(self.plugin.get_plugin_dir()) + self.logger.debug(f"list files in plugin examples {subdir}") + mtime = lambda f: os.stat(os.path.join(subdir, f)).st_mtime + files = list(reversed(sorted(os.listdir(subdir), key=mtime))) + files = [f for f in files if os.path.isfile(os.path.join(subdir,f))] + files = ["examples/{}".format(f) for f in files if f.endswith(".py")] + #files = '\n'.join(f for f in files) + self.logger.debug(f"Examples Scripts {files}") if self.plugin.executor_scripts is not None: subdir = self.plugin.executor_scripts self.logger.debug(f"list files in {subdir}") - files = os.listdir(subdir) - files = [f for f in files if os.path.isfile(os.path.join(subdir,f))] - files = [f for f in files if f.endswith(".py")] - files = '\n'.join(f for f in files) - self.logger.debug(f"{files=}\n\n") - return files - - return '' - + files2 = list(reversed(sorted(os.listdir(subdir), key=mtime))) + files2 = [f for f in files2 if os.path.isfile(os.path.join(subdir,f))] + files2 = [f for f in files2 if f.endswith(".py")] + #files = '\n'.join(f for f in files) + self.logger.debug(f"User scripts {files2}") + + return json.dumps(files2 + files) + + @cherrypy.expose def get_autocomplete(self): _sh = self.plugin.get_sh() @@ -310,11 +323,11 @@ def get_autocomplete(self): if api is not None: for function in api: plugin_list.append("sh."+plugin_config_name + "." + function) - + myItems = _sh.return_items() itemList = [] for item in myItems: - itemList.append("sh."+str(item)+"()") + itemList.append("sh."+str(item.property.path)+"()") retValue = {'items':itemList,'plugins':plugin_list} - return (json.dumps(retValue)) \ No newline at end of file + return (json.dumps(retValue)) diff --git a/executor/webif/templates/index.html b/executor/webif/templates/index.html index 20bc2ca95..22967b243 100755 --- a/executor/webif/templates/index.html +++ b/executor/webif/templates/index.html @@ -1,14 +1,8 @@ -{% extends "base.html" %} +{% extends "base_plugin.html" %} {% set logo_frame = false %} {% block pluginscripts %} - -{% endblock pluginscripts %} - - -{% block content -%} - +{% endblock pluginscripts %} +{% block pluginstyles %} +{% endblock pluginstyles %} +{% block content -%}
@@ -358,12 +478,13 @@
{{ _('Instanz') }}: {{ p.get_instance_name( {% endif %}
{{ _('Plugin') }}     : {% if p.alive %}{{ _('Aktiv') }}{% else %}{{ _('Gestoppt') }}{% endif %}
-
+
+ + + +
@@ -380,14 +501,14 @@
{{ _('Plugin') }}     : {% if p.aliv
-
-
+
+
-
-
+
+
diff --git a/garminconnect/requirements.txt b/garminconnect/requirements.txt index f13dc3ee8..2b83de542 100755 --- a/garminconnect/requirements.txt +++ b/garminconnect/requirements.txt @@ -1 +1,2 @@ -garminconnect>=0.1.28 \ No newline at end of file +garminconnect>=0.1.28,<0.2.0;python_version<='3.9' +garminconnect>=0.1.28;python_version>='3.10' diff --git a/gpio/user_doc.rst b/gpio/user_doc.rst index b04e129fc..ea5c7bcae 100755 --- a/gpio/user_doc.rst +++ b/gpio/user_doc.rst @@ -15,7 +15,7 @@ gpio Konfiguration ============= -Die Informationen zur Konfiguration des Plugins sind unter :doc:`/user/plugins_doc/config/gpio` beschrieben. +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/gpio` beschrieben. Beschreibung ============ diff --git a/harmony/README.md b/harmony/README.md index f525b9334..dfc85dc7b 100755 --- a/harmony/README.md +++ b/harmony/README.md @@ -9,7 +9,7 @@ For support, questions and bug reports, please refer to [KNX-User-Forum](https:/ - an Harmony Hub device - SmarthomeNG version >= 1.3 - Python3 module sleekxmpp -- (optional) create a dummy Harmony Hub activity, [see remarks](#dummy) +- (optional) create a dummy Harmony Hub activity, see remarks ``` sudo pip3 install sleekxmpp diff --git a/helios/__init__.py b/helios/__init__.py index ff7e14bd7..ed0f9bb1e 100755 --- a/helios/__init__.py +++ b/helios/__init__.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +# !/usr/bin/env python ######################################################################### # Copyright 2014 Marcel Tiews marcel.tiews@gmail.com # Modified 2014-2017 by René Jahncke aka Tom-Bom-badil @ github.com @@ -22,11 +22,8 @@ import sys import serial import logging -import socket import threading -import struct import time -import datetime import array from lib.model.smartplugin import SmartPlugin @@ -34,10 +31,10 @@ # old / removed: logger = logging.getLogger("") # Old CONST's - previous definitions -#CONST_BUSMEMBER__MAINBOARD = 0x11 -#CONST_BUSMEMBER__SLAVEBOARDS = 0x10 -#CONST_BUSMEMBER__CONTROLBOARDS = 0x20 -#CONST_BUSMEMBER__ME = 0x2F +# CONST_BUSMEMBER__MAINBOARD = 0x11 +# CONST_BUSMEMBER__SLAVEBOARDS = 0x10 +# CONST_BUSMEMBER__CONTROLBOARDS = 0x20 +# CONST_BUSMEMBER__ME = 0x2F # Broadcast addresses - no way to address slave boards in the units directly (according to Vallox) @@ -45,10 +42,10 @@ CONST_BUS_ALL_REMOTES = 0x20 # Individual addresses -CONST_BUS_MAINBOARD1 = 0x11 # 1st of max 15 ventilation units (mainboards 1-F) -CONST_BUS_REMOTE1 = 0x21 # 1st of max 15 remote controls (remotes 1-F, default jumper = 1) -CONST_BUS_LON = 0x28 # default for LON bus module (just for information --> expensive) -CONST_BUS_ME = 0x2F # stealth mode - we are behaving like a regular remote control +CONST_BUS_MAINBOARD1 = 0x11 # 1st of max 15 ventilation units (mainboards 1-F) +CONST_BUS_REMOTE1 = 0x21 # 1st of max 15 remote controls (remotes 1-F, default jumper = 1) +CONST_BUS_LON = 0x28 # default for LON bus module (just for information --> expensive) +CONST_BUS_ME = 0x2F # stealth mode - we are behaving like a regular remote control CONST_MAP_VARIABLES_TO_ID = { "power_state" : {"varid" : 0xA3, 'type': 'bit', 'bitposition': 0, 'read': True, 'write': True }, @@ -70,9 +67,9 @@ "boost_status" : {"varid" : 0x71, 'type': 'bit', 'bitposition': 6, 'read': True, 'write': False }, "boost_remaining" : {"varid" : 0x79, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': False }, "fan_in_on_off" : {"varid" : 0x08, 'type': 'bit', 'bitposition': 3, 'read': True, 'write': True }, - "fan_in_percent" : {"varid" : 0xB0, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': True }, + "fan_in_percent" : {"varid" : 0xB0, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': True }, "fan_out_on_off" : {"varid" : 0x08, 'type': 'bit', 'bitposition': 5, 'read': True, 'write': True }, - "fan_out_percent" : {"varid" : 0xB1, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': True }, + "fan_out_percent" : {"varid" : 0xB1, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': True }, "clean_filter" : {"varid" : 0xAB, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': True }, "device_error" : {"varid" : 0x36, 'type': 'dec', 'bitposition': -1, 'read': True, 'write': False } } @@ -96,54 +93,56 @@ class HeliosException(Exception): class HeliosBase(SmartPlugin): - PLUGIN_VERSION = "1.4.2" + PLUGIN_VERSION = "1.4.3" ALLOW_MULTIINSTANCE = False - def __init__(self, tty='/dev/ttyUSB0'): + def __init__(self, sh, **kwargs): self.logger = logging.getLogger(__name__) - self._tty = tty self._is_connected = False - self._port = False self._lock = threading.Lock() - + if 'tty' in kwargs: + self._tty = kwargs['tty'] + if 'port' in kwargs: + self._port = kwargs['port'] + def connect(self): if self._is_connected and self._port: return True - + try: self.logger.debug("Helios: Connecting...") self._port = serial.Serial( - self._tty, - baudrate=9600, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, + self._tty, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, timeout=1) self._is_connected = True return True except: self.logger.error("Helios: Could not open {0}.".format(self._tty)) return False - + def disconnect(self): if self._is_connected and self._port: self.logger.debug("HeliosBase: Disconnecting...") self._port.close() self._is_connected = False - + def _createTelegram(self, sender, receiver, function, value): - telegram = [1,sender,receiver,function,value,0] + telegram = [1, sender, receiver, function, value, 0] telegram[5] = self._calculateCRC(telegram) return telegram - + def _waitForSilence(self): # Modbus RTU only allows one master (client which controls communication). # So lets try to wait a bit and jump in when nobody's speaking. # Modbus defines a waittime of 3,5 Characters between telegrams: - # (1/9600baud * (1 Start bit + 8 Data bits + 1 Parity bit + 1 Stop bit) + # (1/9600baud * (1 Start bit + 8 Data bits + 1 Parity bit + 1 Stop bit) # => about 4ms # Lets go with 7ms! ;O) - + gotSlot = False backupTimeout = self._port.timeout end = time.time() + 3 @@ -155,128 +154,127 @@ def _waitForSilence(self): gotSlot = True break self._port.timeout = backupTimeout - return gotSlot + return gotSlot def _sendTelegram(self, telegram): if not self._is_connected: return False - + self.logger.debug("Helios: Sending telegram '{0}'".format(self._telegramToString(telegram))) self._port.write(bytearray(telegram)) return True - + def _readTelegram(self, sender, receiver, datapoint): # sometimes a lot of garbage is received...so lets get a bit robust # and read a bit of this junk and see whether we are getting something # useful out of it! # How long does it take until something useful is received??? timeout = time.time() + 1 - telegram = [0,0,0,0,0,0] + telegram = [0, 0, 0, 0, 0, 0] while self._is_connected and timeout > time.time(): char = self._port.read(1) - if(len(char) > 0): + if (len(char) > 0): byte = bytearray(char)[0] telegram.pop(0) telegram.append(byte) # Telegrams always start with a 0x01, is the CRC valid?, ... - if (telegram[0] == 0x01 and - telegram[1] == sender and - telegram[2] == receiver and - telegram[3] == datapoint and - telegram[5] == self._calculateCRC(telegram)): + if (telegram[0] == 0x01 and + telegram[1] == sender and + telegram[2] == receiver and + telegram[3] == datapoint and + telegram[5] == self._calculateCRC(telegram)): self.logger.debug("Telegram received '{0}'".format(self._telegramToString(telegram))) return telegram[4] - return None - + def _calculateCRC(self, telegram): sum = 0 for c in telegram[:-1]: sum = sum + c return sum % 256 - + def _telegramToString(self, telegram): str = "" for c in telegram: # str = str + hex(c) + " " 0x01 was showing as 0x1, 0x1A was showing as 0x1a - str = str + '0x%0*X' % (2,c) + " " - str = str[:-1] # remove trailing space + str = str + '0x%0*X' % (2, c) + " " + str = str[:-1] # remove trailing space return str - + def _convertFromRawValue(self, varname, rawvalue): value = None vardef = CONST_MAP_VARIABLES_TO_ID[varname] - + if vardef["type"] == "temperature": value = CONST_TEMPERATURE[rawvalue] elif vardef["type"] == "fanspeed": if rawvalue == 0x01: value = 1 - elif rawvalue == 0x03: + elif rawvalue == 0x03: value = 2 - elif rawvalue == 0x07: + elif rawvalue == 0x07: value = 3 - elif rawvalue == 0x0F: + elif rawvalue == 0x0F: value = 4 - elif rawvalue == 0x1F: + elif rawvalue == 0x1F: value = 5 - elif rawvalue == 0x3F: + elif rawvalue == 0x3F: value = 6 - elif rawvalue == 0x7F: + elif rawvalue == 0x7F: value = 7 - elif rawvalue == 0xFF: + elif rawvalue == 0xFF: value = 8 else: value = None elif vardef["type"] == "bit": value = rawvalue >> vardef["bitposition"] & 0x01 - elif vardef["type"] == "dec": # decimal value + elif vardef["type"] == "dec": # decimal value value = rawvalue - - return value + + return value def _convertFromValue(self, varname, value, prevvalue): rawvalue = None vardef = CONST_MAP_VARIABLES_TO_ID[varname] - + if vardef['type'] == "temperature": rawvalue = CONST_TEMPERATURE.index(int(value)) elif vardef["type"] == "fanspeed": value = int(value) if value == 1: rawvalue = 0x01 - elif value == 2: + elif value == 2: rawvalue = 0x03 - elif value == 3: + elif value == 3: rawvalue = 0x07 - elif value == 4: + elif value == 4: rawvalue = 0x0F - elif value == 5: + elif value == 5: rawvalue = 0x1F - elif value == 6: + elif value == 6: rawvalue = 0x3F - elif value == 7: + elif value == 7: rawvalue = 0x7F - elif value == 8: + elif value == 8: rawvalue = 0xFF else: rawvalue = None elif vardef["type"] == "bit": # for bits we have to keep the other bits of the byte (previous value) - if value in (True,1,"true","True","1","On","on"): + if value in (True, 1, "true", "True", "1", "On", "on"): rawvalue = prevvalue | (1 << vardef["bitposition"]) else: rawvalue = prevvalue & ~(1 << vardef["bitposition"]) - elif vardef["type"] == "dec": # decimal value + elif vardef["type"] == "dec": # decimal value rawvalue = int(value) - - return rawvalue - - def writeValue(self,varname, value): - if CONST_MAP_VARIABLES_TO_ID[varname]["write"] != True: + + return rawvalue + + def writeValue(self, varname, value): + if CONST_MAP_VARIABLES_TO_ID[varname]["write"] is not True: self.logger.error("Helios: Variable {0} may not be written!".format(varname)) - return False + return False success = False - + self._lock.acquire() try: # if we have got to write a single bit, we need the current (byte) value to @@ -287,43 +285,42 @@ def writeValue(self,varname, value): # Send poll request telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_MAINBOARD1, - 0, + CONST_BUS_MAINBOARD1, + 0, CONST_MAP_VARIABLES_TO_ID[varname]["varid"] ) self._sendTelegram(telegram) # Read response currentval = self._readTelegram( - CONST_BUS_MAINBOARD1, - CONST_BUS_ME, + CONST_BUS_MAINBOARD1, + CONST_BUS_ME, CONST_MAP_VARIABLES_TO_ID[varname]["varid"] ) - if currentval == None: - self.logger.error("Helios: Sending value to ventilation system failed. Can not read current variable value '{0}'." - .format(varname)) + if currentval is None: + self.logger.error("Helios: Sending value to ventilation system failed. Can not read current variable value '{0}'.".format(varname)) return False rawvalue = self._convertFromValue(varname, value, currentval) - else: + else: rawvalue = self._convertFromValue(varname, value, None) - - # send the new value + + # send the new value if self._waitForSilence(): - if rawvalue != None: + if rawvalue is not None: # Broadcasting value to all remote control boards telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_ALL_REMOTES, - CONST_MAP_VARIABLES_TO_ID[varname]["varid"], + CONST_BUS_ALL_REMOTES, + CONST_MAP_VARIABLES_TO_ID[varname]["varid"], rawvalue ) self._sendTelegram(telegram) - + # Broadcasting value to all mainboards telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_ALL_MAINBOARDS, - CONST_MAP_VARIABLES_TO_ID[varname]["varid"], + CONST_BUS_ALL_MAINBOARDS, + CONST_MAP_VARIABLES_TO_ID[varname]["varid"], rawvalue ) self._sendTelegram(telegram) @@ -331,75 +328,75 @@ def writeValue(self,varname, value): # Writing value to 1st mainboard telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_MAINBOARD1, - CONST_MAP_VARIABLES_TO_ID[varname]["varid"], - rawvalue + CONST_BUS_MAINBOARD1, + CONST_MAP_VARIABLES_TO_ID[varname]["varid"], + rawvalue ) self._sendTelegram(telegram) - + # Send checksum a second time self._sendTelegram([telegram[5]]) -#################### Special treatment to switch on remote controls after off state: + # Special treatment to switch on remote controls after off state: + # TODO: doesn't work so far if CONST_MAP_VARIABLES_TO_ID[varname]["varid"] == 0xA3 and CONST_MAP_VARIABLES_TO_ID[varname]["bitposition"] == 0: - self.logger.debug("On/off command - special treatment for the remote controls") + self.logger.debug("On/off command - special treatment for the remote controls") telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_ALL_REMOTES, - CONST_MAP_VARIABLES_TO_ID[varname]["varid"], - rawvalue + CONST_BUS_ALL_REMOTES, + CONST_MAP_VARIABLES_TO_ID[varname]["varid"], + rawvalue ) self._sendTelegram(telegram) telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_REMOTE1, - CONST_MAP_VARIABLES_TO_ID[varname]["varid"], - rawvalue + CONST_BUS_REMOTE1, + CONST_MAP_VARIABLES_TO_ID[varname]["varid"], + rawvalue ) self._sendTelegram(telegram) self._sendTelegram([telegram[5]]) -#################### Doesn't work so far + # Doesn't work so far success = True - + else: - self.logger.error("Helios: Sending value to ventilation system failed. Can not convert value '{0}' for variable '{1}'." - .format(value,varname)) + self.logger.error("Helios: Sending value to ventilation system failed. Can not convert value '{0}' for variable '{1}'.".format(value,varname)) success = False else: self.logger.error("Helios: Sending value to ventilation system failed. No free slot for sending telegrams available.") success = False except Exception as e: - self.logger.error("Helios: Exception in writeValue() occurred: {0}".format(e)) + self.logger.error("Helios: Exception in writeValue() occurred: {0}".format(e)) finally: self._lock.release() - + return success - - def readValue(self,varname): - if CONST_MAP_VARIABLES_TO_ID[varname]["read"] != True: + + def readValue(self, varname): + if CONST_MAP_VARIABLES_TO_ID[varname]["read"] is not True: self.logger.error("Variable {0} may not be read!".format(varname)) return False value = None - + self._lock.acquire() try: - self.logger.debug("Helios: Reading value: {0}".format(varname)) + self.logger.debug("Helios: Reading value: {0}".format(varname)) if self._waitForSilence(): # Send poll request telegram = self._createTelegram( CONST_BUS_ME, - CONST_BUS_MAINBOARD1, - 0, + CONST_BUS_MAINBOARD1, + 0, CONST_MAP_VARIABLES_TO_ID[varname]["varid"] ) self._sendTelegram(telegram) # Read response value = self._readTelegram( - CONST_BUS_MAINBOARD1, - CONST_BUS_ME, + CONST_BUS_MAINBOARD1, + CONST_BUS_ME, CONST_MAP_VARIABLES_TO_ID[varname]["varid"] ) if value is not None: @@ -408,36 +405,37 @@ def readValue(self,varname): self.logger.debug("Value for {0} ({1}) received: {2}|{3}|{4} --> converted = {5}" .format(varname, '0x%0*X' % (2, CONST_MAP_VARIABLES_TO_ID[varname]["varid"]), '0x%0*X' % (2,raw_value), "{0:08b}".format(raw_value), raw_value, value) - ) + ) else: # logging as info only, so we stop spamming log file as some noise on the bus seems to be normal - self.logger.info("Helios: No valid value for '{0}' from ventilation system received." - .format(varname) - ) + self.logger.info("Helios: No valid value for '{0}' from ventilation system received.".format(varname) + ) else: self.logger.warning("Helios: Reading value from ventilation system failed. No free slot to send poll request available.") except Exception as e: - self.logger.error("Helios: Exception in readValue() occurred: {0}".format(e)) + self.logger.error("Helios: Exception in readValue() occurred: {0}".format(e)) finally: self._lock.release() - + return value - -class Helios(HeliosBase): + +class Helios(HeliosBase): _items = {} - - def __init__(self, smarthome, tty, cycle=300): - HeliosBase.__init__(self, tty) - self._sh = smarthome - self._cycle = int(cycle) + + def __init__(self, sh, **kwargs): + self._tty = self.get_parameter_value('tty') + self._cycle = self.get_parameter_value('cycle') + self._port = None + super().__init__(sh, **kwargs) self._alive = False - + def run(self): self.connect() self._alive = True - self._sh.scheduler.add('Helios', self._update, cycle=self._cycle) + self.scheduler_add('Helios', self._update, cycle=self._cycle) def stop(self): + self.scheduler_remove('Helios') self.disconnect() self._alive = False @@ -449,62 +447,65 @@ def parse_item(self, item): return self.update_item else: self.logger.warning("Helios: Ignoring unknown variable '{0}'".format(varname)) - + def update_item(self, item, caller=None, source=None, dest=None): if caller != 'Helios': - self.writeValue(item.conf['helios_var'], item()) - + self.writeValue(item.conf['helios_var'], item()) + def _update(self): self.logger.debug("Helios: Updating values") for var in self._items.keys(): val = self.readValue(var) - if val != None: - self._items[var](val,"Helios") + if val is not None: + self._items[var](val, "Helios") + - def main(): - import argparse - + import argparse + parser = argparse.ArgumentParser( - description="Helios ventilation system commandline interface.", - epilog="Without arguments all readable values using default tty will be retrieved.", - argument_default=argparse.SUPPRESS) - parser.add_argument("-t", "--tty", dest="port", default="/dev/ttyUSB0", help="Serial device to use") + description="Helios ventilation system commandline interface.", + epilog="Without arguments all readable values using default tty will be retrieved.", + argument_default=argparse.SUPPRESS + ) + parser.add_argument("-t", "--tty", dest="tty", default="/dev/ttyUSB0", help="Serial device to use") parser.add_argument("-r", "--read", dest="read_var", help="Read variables from ventilation system") parser.add_argument("-w", "--write", dest="write_var", help="Write variable to ventilation system") parser.add_argument("-v", "--value", dest="value", help="Value to write (required with option -v)") parser.add_argument("-d", "--debug", dest="enable_debug", action="store_true", help="Prints debug statements.") args = vars(parser.parse_args()) - + if "write_var" in args.keys() and "value" not in args.keys(): parser.print_usage() return + logger = logging.getLogger() logger.setLevel(logging.DEBUG) # old log version -# ch = logging.StreamHandler() -# if "enable_debug" in args.keys(): -# ch.setLevel(logging.DEBUG) -# else: -# ch.setLevel(logging.INFO) -# formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") -# ch.setFormatter(formatter) -# logger.addHandler(ch) +# ch = logging.StreamHandler() +# if "enable_debug" in args.keys(): +# ch.setLevel(logging.DEBUG) +# else: +# ch.setLevel(logging.INFO) +# formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +# ch.setFormatter(formatter) +# logger.addHandler(ch) + helios = None try: - helios = HeliosBase(args["port"]) + helios = HeliosBase(None, tty=args["tty"]) helios.connect() if not helios._is_connected: raise Exception("Not connected") - + if "read_var" in args.keys(): - print("{0} = {1}".format(args["read_var"],helios.readValue(args["read_var"]))) + print("{0} = {1}".format(args["read_var"], helios.readValue(args["read_var"]))) elif "write_var" in args.keys(): helios.writeValue(args["write_var"],args["value"]) else: for var in CONST_MAP_VARIABLES_TO_ID.keys(): - print("{0} = {1}".format(var,helios.readValue(var))) + print("{0} = {1}".format(var, helios.readValue(var))) except Exception as e: print("Exception: {0}".format(e)) return 1 @@ -512,5 +513,6 @@ def main(): if helios: helios.disconnect() + if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) diff --git a/helios/plugin.yaml b/helios/plugin.yaml index 457c49c97..2a643b9ed 100755 --- a/helios/plugin.yaml +++ b/helios/plugin.yaml @@ -1,3 +1,6 @@ +%YAML 1.1 +--- + plugin: type: interface description: @@ -9,10 +12,10 @@ plugin: keywords: 'helios vallox ventilation' documentation: https://github.com/Tom-Bom-badil/helios/wiki support: https://knx-user-forum.de/forum/supportforen/smarthome-py/40092-erweiterung-helios-vallox-plugin - version: 1.4.2 - sh_minversion: 1.1 - multi_instance: False - restartable: unknown + version: 1.4.3 + sh_minversion: 1.6 + multi_instance: false + restartable: true classname: 'Helios' parameters: diff --git a/homeconnect/requirements.txt b/homeconnect/requirements.txt index d9edc73dd..fc8232c73 100755 --- a/homeconnect/requirements.txt +++ b/homeconnect/requirements.txt @@ -1 +1,2 @@ -oauthlib>=3.1.0 \ No newline at end of file +oauthlib>=3.1.0 +requests-oauthlib diff --git a/hue/sv_widgets/widget_hue.html b/hue/sv_widgets/widget_hue.html index 0a017dec3..908e785a9 100755 --- a/hue/sv_widgets/widget_hue.html +++ b/hue/sv_widgets/widget_hue.html @@ -12,7 +12,7 @@ * */ {% macro control(id,g_power,g_reach,g_r,g_g,g_b,g_alert,g_effect,g_bri,g_sat,g_hue) %} -{% import "basic.html" as basic %} +{% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} @@ -76,7 +76,7 @@ * */ {% macro control_group(id,g_power,g_alert,g_effect,g_bri,g_sat,g_hue) %} -{% import "basic.html" as basic %} +{% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %}
diff --git a/hue2/__init__.py b/hue2/__init__.py index 327fbb2a7..b3998f669 100755 --- a/hue2/__init__.py +++ b/hue2/__init__.py @@ -48,7 +48,7 @@ class Hue2(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '2.3.0' # (must match the version specified in plugin.yaml) + PLUGIN_VERSION = '2.3.1' # (must match the version specified in plugin.yaml) hue_group_action_values = ['on', 'bri', 'hue', 'sat', 'ct', 'xy', 'bri_inc', 'colormode', 'alert', 'effect'] hue_light_action_writable_values = ['on', 'bri', 'hue', 'sat', 'ct', 'xy', 'bri_inc'] @@ -322,8 +322,9 @@ def update_light_from_item(self, plugin_item, item): msg = f"qhue exception {e.message}" else: msg = f"{e}" - msg = f"update_light_from_item: item {plugin_item['item'].id()} - function={plugin_item['function']} - '{msg}'" - if msg.find(' 201 ') >= 0 or msg.find(' 201,201 ') >= 0: + msg = f"update_light_from_item: item {plugin_item['item'].id()} - function={plugin_item['function']} - PROBLEM: '{msg}'" + msg += f" - last_change_by={plugin_item['item'].property.last_change_by}" + if msg.find(' 201 ') >= 0 or msg.find(' 201,201 ') >= 0 or str(e).endswith('is not modifiable. Device is set to off.'): self.logger.info(msg) else: self.logger.error(msg) diff --git a/hue2/plugin.yaml b/hue2/plugin.yaml index 805ac343d..a414ded05 100755 --- a/hue2/plugin.yaml +++ b/hue2/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1586861-support-thread-für-das-hue2-plugin - version: 2.3.0 # Plugin version (must match the version specified in __init__.py) + version: 2.3.1 # Plugin version (must match the version specified in __init__.py) sh_minversion: 1.8.2 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) # py_minversion: 3.6 # minimum Python version to use for this plugin diff --git a/hue2/requirements.txt b/hue2/requirements.txt index 7618d59c7..7fe17bf99 100755 --- a/hue2/requirements.txt +++ b/hue2/requirements.txt @@ -3,4 +3,18 @@ qhue # zeroconf below v0.27, because newer versions need intensive testing and actual version has dropped support for Python 3.6 #zeroconf<=0.26.3 #Zeroconf >= 0.28 for testing (to resolve conflict with appletv plugin) -zeroconf<=0.28.3 +#zeroconf<=0.28.3 + +#zeroconf>0.28.3,<=0.31 # funktioniert anscheinend +# gibt folgenden Console output: +# ?gleiche? Version von zeroconf: consolidated = , further = >0.28.3, used by ["plugin 'hue2'"] + +#zeroconf>=0.32,<0.33 # 0.32.1 funktioniert anscheinend +#zeroconf>=0.33,<0.38 # 0.37 funktioniert anscheinend +#zeroconf>=0.38,<0.39 # 0.38.7 funktioniert anscheinend (nicht ganz -> Warnung im Log: ) +# 2023-05-19 11:50:15 WARNING lib.smarthome The following threads have not been terminated properly by their plugins (please report to the plugin's author): +# 2023-05-19 11:50:15 WARNING lib.smarthome -Thread: zeroconf-ServiceBrowser-_hue._tcp-29820, still alive +# 2023-05-19 11:50:15 WARNING lib.smarthome -Thread: zeroconf-ServiceBrowser-_hue._tcp-29871, still alive +# 2023-05-19 11:50:15 WARNING lib.smarthome -Thread: zeroconf-ServiceBrowser-_hue._tcp-29916, still alive + +zeroconf<=0.52.0 diff --git a/hue2/sv_widgets/widget_hue2.html b/hue2/sv_widgets/widget_hue2.html index 134d5fb26..b3ae35846 100755 --- a/hue2/sv_widgets/widget_hue2.html +++ b/hue2/sv_widgets/widget_hue2.html @@ -8,7 +8,7 @@ * */ {% macro color_control(id, g_lamp, g_alert_effect) %} -{% import "basic.html" as basic %} +{% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %}
@@ -69,7 +69,7 @@ * */ {% macro attributes(id, g_lamp) %} -{% import "basic.html" as basic %} +{% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} Name: {{ basic.print(id~'.lightname', g_lamp~'.lightname') }}
Type: {{ basic.print(id~'.lighttype', g_lamp~'.lighttype') }}
diff --git a/husky2/__init__.py b/husky2/__init__.py index 1809dafe9..507196512 100755 --- a/husky2/__init__.py +++ b/husky2/__init__.py @@ -27,7 +27,7 @@ import asyncio import threading from concurrent.futures import CancelledError -from datetime import datetime +from datetime import datetime, timedelta import time import json @@ -233,6 +233,9 @@ def __init__(self, sh): self.historylength = int(self.get_parameter_value('historylength')) self.maxgpspoints = int(self.get_parameter_value('maxgpspoints')) + # poll is only additional, because normal state updates are recieved by the websocket connection of the api + self.poll_cycle = 600 # call every 10 min to make sure the monthly api call limit of 10000 gets not exceeded + self.token = None self.tokenExp = 0 @@ -280,7 +283,7 @@ def run(self): Run method for the plugin """ # if you need to create child threads, do not make them daemon = True! - # They will not shutdown properly. (It's a python bug) + # They will not shut down properly. (It's a python bug) self.logger.debug("Run method called") try: @@ -328,11 +331,16 @@ def startFinished(self, args): self.alive = True self.logger.debug("Init finished, husky2 plugin is running") + dt = self.shtime.now() + timedelta(seconds=self.poll_cycle) + self.scheduler_add('poll_husky_device_' + self.instance, + self.poll_device, cycle=self.poll_cycle, prio=5, next=dt) + def stop(self): """ Stop method for the plugin """ self.logger.debug("Stop method called. Shutting down Thread...") + self.scheduler_remove('poll_husky_device_' + self.instance) self.asyncLoop.call_soon_threadsafe(self.asyncLoop.stop) time.sleep(2) try: @@ -463,6 +471,10 @@ def writeToStatusItem(self, txt): for item in self._items_state['message']: item(txt, self.get_shortname()) + def poll_device(self): + self.logger.debug("Poll new status") + asyncio.run_coroutine_threadsafe(self.update_worker(), self.asyncLoop) + def data_callback(self, status): """ Callback for data updates of the device @@ -491,7 +503,8 @@ def data_callback(self, status): posindex = -1 for gpsindex, gpspoint in enumerate(data['attributes']['positions']): - if (gpspoint['longitude'] == self.mowerGpspoints.get_last()[0]) and (gpspoint['latitude'] == self.mowerGpspoints.get_last()[1]): + if (gpspoint['longitude'] == self.mowerGpspoints.get_last()[0]) and ( + gpspoint['latitude'] == self.mowerGpspoints.get_last()[1]): posindex = gpsindex - 1 break elif gpsindex >= self.maxgpspoints: @@ -663,6 +676,12 @@ async def send_worker(self, cmd, value): self.logger.error("'{0}' not in available commands: {1}".format(cmd, commands.keys())) return + async def update_worker(self): + newstatus = await self.apiSession.get_status() + self.apiSession.action + self.data_callback(newstatus) + return + # ------------------------------------------ # Webinterface methods of the plugin # ------------------------------------------ diff --git a/husky2/sv_widgets/husky2.html b/husky2/sv_widgets/husky2.html index 1207b1338..84b35ff67 100755 --- a/husky2/sv_widgets/husky2.html +++ b/husky2/sv_widgets/husky2.html @@ -8,24 +8,28 @@ */ /** - * Displays a google maps (from https://www.smarthomeng.de/google-maps-widget-fuer-smartvisu-2-9 ) map with the position and the path of the mower - * - * @param {id=''} unique id for this widget - * @param {item(txt)=''} a gad/item with the name of the mower - * @param {item(num)=0.0} a gad/item for latitude - * @param {item(num)=0.0} a gad/item for longitude - * @param {item(list)=[]} a gad/item for gps points - * @param {text=''} the google maps key - * @param {num=19} zoom level for map - * @param {text='#3afd02'} color of mower path - */ +* Displays a google maps (from https://www.smarthomeng.de/google-maps-widget-fuer-smartvisu-2-9 ) map with the position and the path of the mower +* +* @param {id=''} unique id for this widget +* @param {item(txt)=''} a gad/item with the name of the mower +* @param {item(num)=0.0} a gad/item for latitude +* @param {item(num)=0.0} a gad/item for longitude +* @param {item(list)=[]} a gad/item for gps points +* @param {text=''} the google maps key +* @param {num=19} zoom level for map +* @param {text='#3afd02'} color of mower path +*/ {% macro map(id, gad_name, gad_lat, gad_lon, gad_points, mapskey, zoomlevel, pathcolor) %} -
-
+
+
+
+ +
{% endmacro %} \ No newline at end of file diff --git a/husky2/sv_widgets/husky2.js b/husky2/sv_widgets/husky2.js index 640c1760c..ebdd72deb 100755 --- a/husky2/sv_widgets/husky2.js +++ b/husky2/sv_widgets/husky2.js @@ -1,88 +1,65 @@ - $.widget("sv.husky2", $.sv.widget, { initSelector: 'div[data-widget="husky2.map"]', + map: null, + options: { - mapskey: '', zoomlevel: 19, pathcolor: '#3afd02', - }, + }, _create: function () { this._super(); - this._create_map(); }, _create_map: function () { - try { - this.map = new google.maps.Map(this.element[0], { - zoom: this.options.zoomlevel, - mapTypeId: 'hybrid', - center: new google.maps.LatLng(0.0, 0.0), - }); - } - catch (e) { - if (e.name == "ReferenceError") { // google maps script not loaded yet - var that = this; - // google maps script is already loading in another widget - if (window.google_maps_loading) { - window.setTimeout(function () { - that._create_map() - }, 100) - return; - } - // google maps script is not loading - window.google_maps_loading = true; - $.ajax({ - url: 'https://maps.googleapis.com/maps/api/js?key=' + this.options.mapskey + '&language=de', - dataType: "script", - complete: function () { - window.google_maps_loading = false; - that._create_map() - } - }); - return; - } - else // other exceptions should be thrown - throw e; - } - - this.marker_myself = new google.maps.Marker({ - map: this.map, - position: new google.maps.LatLng(0.0, 0.0), - icon: '', - title:'', - zIndex:99999999 - }); - - this.linePath = new google.maps.Polyline({ - path: [], - strokeColor: this.options.pathcolor, - strokeOpacity: 0.6, - strokeWeight: 2, - map: this.map - }); + this.map = new google.maps.Map(this.element[0], { + zoom: this.options.zoomlevel, + mapTypeId: 'hybrid', + center: new google.maps.LatLng(0.0, 0.0), + streetViewControl: false, + }); + this.marker_myself = new google.maps.Marker({ + map: this.map, + position: new google.maps.LatLng(0.0, 0.0), + icon: '', + title: '', + zIndex: 99999999 + }); + this.linePath = new google.maps.Polyline({ + path: [], + strokeColor: this.options.pathcolor, + strokeOpacity: 0.6, + strokeWeight: 2, + map: this.map + }); }, - _update: function(response) { - if(!this.map) { - var that = this; - window.setTimeout(function() { that._update(response) }, 100) - return; + _update: function (response) { + if (this.map === null) { + if (typeof google == 'undefined') { + var that = this; + window.setTimeout(function () { + that._update(response) + }, 500) + return; + } else { + this._create_map(); + } } this.marker_myself.setTitle(response[3]); - var pos = new google.maps.LatLng(parseFloat(response[0]),parseFloat(response[1])); + var pos = new google.maps.LatLng(parseFloat(response[0]), parseFloat(response[1])); this.map.setCenter(pos); this.marker_myself.setPosition(pos); var coord = []; - for (const point of response[2]){ - coord.push(new google.maps.LatLng(parseFloat(point[0]),parseFloat(point[1]))); + for (const point of response[2]) { + coord.push(new google.maps.LatLng(parseFloat(point[0]), parseFloat(point[1]))); } this.linePath.setPath(coord); diff --git a/iaqstick/README.md b/iaqstick/README.md deleted file mode 100755 index 8478ead8c..000000000 --- a/iaqstick/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# iaqstick - -## Requirements - -* pyusb -* udev rule - -install by -```bash -apt-get install python3-setuptools -pip3 install "pyusb>=1.0.2" -``` - -``` -echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="03eb", ATTR{idProduct}=="2013", MODE="666"' > /etc/udev/rules.d/99-iaqstick.rules -udevadm trigger -``` - -## Supported Hardware - -* Applied Sensor iAQ Stick -* Voltcraft CO-20 (by Conrad) -* others using the same reference design - -## Configuration - -### plugin.yaml - -```yaml -iaqstick: - plugin_name: iaqstick -# update_cycle: 10 -``` - -Description of the attributes: - -* __update_cycle__: interval in seconds how often the data is read from the stick (default 10) - -### items.yaml - -Attributes: -* __iaqstick_id__: used to distinguish multiple sticks -* __iaqstick_info__: used to get data from the stick - -To get the Stick-ID, start sh.py and check the log saying: "iaqstick: Vendor: AppliedSensor / Product: iAQ Stick / Stick-ID: ". -Don't bother if you are going to use a single stick anyway. - -Fields: -* __ppm__: get the air quality measured in part-per-million (ppm) - -```yaml -iAQ_Stick: - PPM: - type: num - iaqstick_id: H02004-266272 - iaqstick_info: ppm -``` diff --git a/iaqstick/__init__.py b/iaqstick/__init__.py deleted file mode 100755 index e3447ff86..000000000 --- a/iaqstick/__init__.py +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -######################################################################### -# Copyright 2013 Robert Budde robert@projekt131.de -######################################################################### -# iAQ-Stick plugin for SmartHomeNG. https://github.com/smarthomeNG// -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -#/etc/udev/rules.d/99-iaqstick.rules -#SUBSYSTEM=="usb", ATTR{idVendor}=="03eb", ATTR{idProduct}=="2013", MODE="666" -#udevadm trigger - -import logging -import usb.core -import usb.util -from time import sleep - -logger = logging.getLogger('iAQ_Stick') - -class iAQ_Stick(): - def __init__(self, smarthome, update_cycle = "10"): - self._sh = smarthome - self._update_cycle = int(update_cycle) - self._items = {} - self._devs = {} - - def read(self, dev): - in_data = bytes() - try: - while True: - ret = bytes(dev.read(0x81, 0x10, 1000)) - if len(ret) == 0: - break - in_data += ret - except Exception as e: - logger.error("iaqstick: read - {}".format(e)) - pass - return in_data - - def xfer_type1(self, dev, msg): - out_data = bytes('@{:04X}{}\n@@@@@@@@@@'.format(self._devs[dev]['type1_seq'], msg), 'utf-8') - self._devs[dev]['type1_seq'] = (self._devs[dev]['type1_seq'] + 1) & 0xFFFF - ret = dev.write(0x02, out_data[:16], 1000) - return self.read(dev).decode('iso-8859-1') - - def xfer_type2(self, dev, msg): - out_data = bytes('@', 'utf-8') + self._devs[dev]['type2_seq'].to_bytes(1, byteorder='big') + bytes('{}\n@@@@@@@@@@@@@'.format(msg), 'utf-8') - self._devs[dev]['type2_seq'] = (self._devs[dev]['type2_seq'] + 1) if (self._devs[dev]['type2_seq'] < 0xFF) else 0x67 - ret = dev.write(0x02, out_data[:16], 1000) - in_data = bytes() - return self.read(dev) - - def _init_dev(self, dev): - try: - if dev.is_kernel_driver_active(self._intf): - dev.detach_kernel_driver(self._intf) - dev.set_configuration(0x01) - usb.util.claim_interface(dev, self._intf) - dev.set_interface_altsetting(self._intf, 0x00) - vendor = usb.util.get_string(dev, dev.iManufacturer) - product = usb.util.get_string(dev,dev.iProduct ) - self._devs[dev] = {'type1_seq':0x0001, 'type2_seq':0x67} - ret = self.xfer_type1(dev, '*IDN?') - pos1 = ret.find('S/N:') + 4 - id = '{:s}-{:d}'.format(bytes.fromhex(ret[pos1:pos1+12]).decode('ascii'), int(ret[pos1+14:pos1+20], 16)) - logger.info('iaqstick: Vendor: {} / Product: {} / Stick-ID: {}'.format(vendor, product, id)) - if (id not in self._items): - logger.warning('iaqstick: no specific item for Stick-ID {} - use \'iaqstick_id\' to distinguish multiple sticks!'.format(id)) - #ret = self.xfer_type1(dev, 'KNOBPRE?') - #ret = self.xfer_type1(dev, 'WFMPRE?') - #ret = self.xfer_type1(dev, 'FLAGS?') - return id - except Exception as e: - logger.error("iaqstick: init interface failed - {}".format(e)) - return None - - def run(self): - devs = list(usb.core.find(idVendor=0x03eb, idProduct=0x2013, find_all=True)) - if devs is None: - logger.error('iaqstick: iAQ Stick not found') - return - logger.debug('iaqstick: {} iAQ Stick connected'.format(len(devs))) - self._intf = 0 - - for dev in devs: - id = self._init_dev(dev) - if id is not None: - self._devs[dev]['id'] = id - - self.alive = True - self._sh.scheduler.add('iAQ_Stick', self._update_values, prio = 5, cycle = self._update_cycle) - logger.info("iaqstick: init successful") - - def stop(self): - self.alive = False - for dev in self._devs: - try: - usb.util.release_interface(dev, self._intf) - if dev.is_kernel_driver_active(self._intf): - dev.detach_kernel_driver(self._intf) - except Exception as e: - logger.error("iaqstick: releasing interface failed - {}".format(e)) - try: - self._sh.scheduler.remove('iAQ_Stick') - except Exception as e: - logger.error("iaqstick: removing iAQ_Stick from scheduler failed - {}".format(e)) - - def _update_values(self): - logger.debug("iaqstick: updating {} sticks".format(len(self._devs))) - for dev in self._devs: - logger.debug("iaqstick: updating {}".format(self._devs[dev]['id'])) - try: - self.xfer_type1(dev, 'FLAGGET?') - meas = self.xfer_type2(dev, '*TR') - ppm = int.from_bytes(meas[2:4], byteorder='little') - logger.debug('iaqstick: ppm: {}'.format(ppm)) - #logger.debug('iaqstick: debug?: {}'.format(int.from_bytes(meas[4:6], byteorder='little'))) - #logger.debug('iaqstick: PWM: {}'.format(int.from_bytes(meas[6:7], byteorder='little'))) - #logger.debug('iaqstick: Rh: {}'.format(int.from_bytes(meas[7:8], byteorder='little')*0.01)) - #logger.debug('iaqstick: Rs: {}'.format(int.from_bytes(meas[8:12], byteorder='little'))) - id = self._devs[dev]['id'] - if id in self._items: - if 'ppm' in self._items[id]: - for item in self._items[id]['ppm']['items']: - item(ppm, 'iAQ_Stick', 'USB') - if '*' in self._items: - if 'ppm' in self._items['*']: - for item in self._items['*']['ppm']['items']: - item(ppm, 'iAQ_Stick', 'USB') - except Exception as e: - logger.error("iaqstick: update failed - {}".format(e)) - logger.error("iaqstick: Trying to recover ...") - broken_id = self._devs[dev]['id'] - del self._devs[dev] - __devs = list(usb.core.find(idVendor=0x03eb, idProduct=0x2013, find_all=True)) - for __dev in __devs: - if (__dev not in self._devs): - id = self._init_dev(__dev) - if id == broken_id: - logger.error("iaqstick: {} was ressurrected".format(id)) - self._devs[__dev]['id'] = id - else: - logger.error("iaqstick: found other yet unknown stick: {}".format(id)) - - def parse_item(self, item): - if 'iaqstick_info' in item.conf: - logger.debug("parse item: {0}".format(item)) - if 'iaqstick_id' in item.conf: - id = item.conf['iaqstick_id'] - else: - id = '*' - info_tag = item.conf['iaqstick_info'].lower() - if not id in self._items: - self._items[id] = {'ppm': {'items': [item], 'logics': []}} - else: - self._items[id]['ppm']['items'].append(item) - return None - -if __name__ == '__main__': - logging.basicConfig(level=logging.DEBUG) - myplugin = Plugin('iaqstick') - myplugin.run() - -#Application Version: 2.19.0 (Id: Form1.frm 1053 2010-06-30 11:00:09Z patrik.arven@appliedsensor.com ) -# -#Device 0: -#Name: iAQ Stick -#Firmware: 1.12p5 $Revision: 346 $ -#Protocol: 5 -#Hardware: C -#Processor: ATmega32U4 -#Serial number: S/N:48303230303415041020 -#Web address: -#Plot title: Air Quality Trend -# -#Channels: 5 -#... Channel 0:CO2/VOC level -#... Channel 1:Debug -#... Channel 2:PWM -#... Channel 3:Rh -#... Channel 4:Rs -#Knobs: 8 -#... Knob CO2/VOC level_warn1:1000 -#... Knob CO2/VOC level_warn2:1500 -#... Knob Reg_Set:151 -#... Knob Reg_P:3 -#... Knob Reg_I:10 -#... Knob Reg_D:0 -#... Knob LogInterval:0 -#... Knob ui16StartupBits:1 -#Flags: 5 -#... WARMUP=&h0000& -#... BURN-IN=&h0000& -#... RESET BASELINE=&h0000& -#... CALIBRATE HEATER=&h0000& -#... LOGGING=&h0000& -# -#@013E;;DEBUG: -#Log: -#buffer_size=&h1400; -#address_base=&h4800; -#readindex=&h0040; -#Write index=&h0000; -#nValues=&h0000; -#Records=&h0000; -#nValues (last)=&h0000; -#uint16_t g_u16_loop_cnt_100ms=&h08D4; -#;\x0A diff --git a/iaqstick/plugin.yaml b/iaqstick/plugin.yaml deleted file mode 100755 index 79aa362a9..000000000 --- a/iaqstick/plugin.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Metadata for the classic-plugin -plugin: - # Global plugin attributes - type: gateway # plugin type (gateway, interface, protocol, system, web) - description: - de: 'Unterstützung für Applied Sensor iAQ Stick und Voltcraft CO-20' - en: '' - maintainer: '? (Robert Budde )' -# tester: efgh # Who tests this plugin? -# keywords: kwd1 kwd2 # keywords, where applicable - state: deprecated # No user or tester for SmartPlugin conversion could be found -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page - -# Following entries are for Smart-Plugins: -# version: 1.3.3 # Plugin version -# sh_minversion: 1.3 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) -# multi_instance: False - classname: iAQ_Stick # class containing the plugin - -#parameters: - # Definition of parameters to be configured in etc/plugin.yaml - -#item_attributes: - # Definition of item attributes defined by this plugin - diff --git a/iaqstick/requirements.txt b/iaqstick/requirements.txt deleted file mode 100755 index 62b1668a7..000000000 --- a/iaqstick/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pyusb>=1.0.2 diff --git a/ical/__init__.py b/ical/__init__.py index 6dd1b11ac..a9ed00b92 100755 --- a/ical/__init__.py +++ b/ical/__init__.py @@ -33,7 +33,7 @@ class iCal(SmartPlugin): - PLUGIN_VERSION = "1.6.2" + PLUGIN_VERSION = "1.6.3" ALLOW_MULTIINSTANCE = False DAYS = ("MO", "TU", "WE", "TH", "FR", "SA", "SU") FREQ = ("YEARLY", "MONTHLY", "WEEKLY", "DAILY", "HOURLY", "MINUTELY", "SECONDLY") @@ -284,7 +284,8 @@ def _parse_ical(self, ical, ics, prio): self.logger.warning("problem parsing {0} no UID for event: {1}".format(ics, event)) continue if 'SUMMARY' not in event: - self.logger.warning("problem parsing {0} no SUMMARY for UID: {1}".format(ics, event['UID'])) + # Background info: Some events in google calender ICAS files have no summary: + self.logger.info("problem parsing {0} no SUMMARY for UID: {1}".format(ics, event['UID'])) continue if 'DTSTART' not in event: self.logger.warning("problem parsing {0} no DTSTART for UID: {1}".format(ics, event['UID'])) diff --git a/ical/plugin.yaml b/ical/plugin.yaml index a5cd7066f..127d86112 100755 --- a/ical/plugin.yaml +++ b/ical/plugin.yaml @@ -20,7 +20,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1352089-support-thread-zum-ical-plugin - version: 1.6.2 # Plugin version + version: 1.6.3 # Plugin version sh_minversion: 1.9.0 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: false # plugin supports multi instance diff --git a/indego4shng/README.md b/indego4shng/README.md index 005e693c4..9e23216a3 100755 --- a/indego4shng/README.md +++ b/indego4shng/README.md @@ -2,23 +2,23 @@ ## Table of Content -1. [Generell](#generell) -2. [Credits](#credits) -3. [Change Log](#changelog) **Neu** -4. [Konfiguration](#konfiguration) **Update** -5. [Web-Interface](#webinterface) **Update** -6. [Logik-Trigger](#logiktrigger) -7. [öffentlich Funktionen (API)](#api) -8. [Gartenkarte "pimpen"](#gardenmap) -9. [Nutzung der Original Bosch-Mäher-Symbole](#boschpics) -10. [Die Bosch-Api 3.0 - behind the scenes](#boschapi) +1. Generell +2. Credits +3. Change Log **Neu** +4. Konfiguration **Update** +5. Web-Interface +6. Logik-Trigger +7. öffentlich Funktionen (API) +8. Gartenkarte "pimpen" +9. Nutzung der Original Bosch-Mäher-Symbole +10. Die Bosch-Api 4.0.1 - behind the scenes ## Generell Das Indego-Plugin wurde durch ein Reverse-Engineering der aktuellen (Version 3.0) App von Bosch entwickelt. Als Basis diente das ursprüngliche Plugin von Marcov. Es werden alle Funktionen der App für den Betrieb sowie einige zusätzliche bereitgestellt. Für die Ersteinrichtung wird weiterhin die Bosch-App benötigt. -Das Plugin erhält die Version der aktuellen Bosch-API. (3.0) +Das Plugin erhält die Version der aktuellen Bosch-API. (4.0.1) ## Credits @@ -33,6 +33,12 @@ Vielen Dank an Jan Odvarko für die Entwicklung des Color-Pickers (http://jscolo ## Change Log +#### 2023-05-06 V4.0.1 +- Login via Single-Key-ID eingebaut +- Endpoit der Bosch-API wurde geändert (siehe Konfiguration) + +#### 2023-03-08 V4.0.0 +- Login via Bosch-ID eingebaut #### 2023-02-05 V3.0.2 - Anpassungen für die geänderten Daten für das Wetter (es werden nun 7 Tage statt 5 übermittelt, die Sonnenstunden je Tag wurden entfern) @@ -120,6 +126,7 @@ zum "pimpen" der Gartenkarte verwenden * `indego_credentials : XXXXXXX`: sind die Zugangsdaten für den Bosch-Server im Format base64 encoded. * `parent_item : indego`: name des übergeordneten items für alle Child-Items * `cycle : 30`: Intervall in Sekunden für das Abrufen des Mäher-Status (default = 30 Sekunden) +* `url: https://api.indego-cloud.iot.bosch-si.com/api/v1/` : Url des Bosch-Endpoints Die Zugangsdaten (indego_credentials) können nach dem Erststart des Plugins im Web-Interface erfasst und gespeichert werden @@ -136,7 +143,7 @@ Indego4shNG: indego_credentials: parent_item: indego cycle: '30' - url: https://api.indego.iot.bosch-si.com/api/v1/ + url: https://api.indego-cloud.iot.bosch-si.com/api/v1/ ``` @@ -187,7 +194,7 @@ Es können auf dieser Seite zusätzlich Vektoren eingefügt werden welche die Ga Hier kann die Location auf den Bosch-Servern gespeichert werden. Es müssen Längen/Breitengrad angegeben werden. Wenn noch keine Koordinaten in den Items gespeichert sind werden die Long/Lat von shNG vorgeschlagen. -[Sieh auch hier](#gardenmap) +Sieh auch hier: gardenmap Es können hier bis zu 4 Trigger für Stati gewählt werden. 999999 - kein Status gewählt. @@ -310,7 +317,7 @@ sh.Indego4shNG.send_command('{"state":"returnToDock"}','Logic') Die Gartenkarte wird vom Bosch-Server heruntergeladen und als Item für die Visu verwendet. Die Datei wird als Vorlage zum Erweitern unter dem angegebenen Pfad gespeichert ( vgl. ```img_pfad``` im Konfig-Teil). -Man kann die Karte als Vorlage in einem [Online-Tool](#https://editor.method.ac/) als Vorlage laden. +Man kann die Karte als Vorlage in einem Online-Tool (#https://editor.method.ac/) als Vorlage laden. Es werden dann einfach die zusätzlichen Vektoren eingezeichnet oder via "File / Import Image" hinzugeladen. Man kann die veränderte Karte auch lokal zwischenspeichern. @@ -361,18 +368,22 @@ Sobald die Dateien mit den Bildern vorhanden sind findet das Widget diese und ve Die entsprechenden Bilder für die "Großen"/"Kleinen" werden auf Grund des Mähertyps automatisch gewählt und dargestellt. -## Die Bosch-Api 3.0 - behind the scenes +## Die Bosch-Api 4.0.1 - behind the scenes Hier ist die Schnittstelle der Bosch-API kurz beschrieben und die Implementierung im Plugin dokumentiert. Der Header ist in den meisten Fällen mit der Session-ID zu füllen : ``` -headers = { - 'x-im-context-id' : SESSION-ID - } +headers = {'accept' : '*/*', + 'authorization' : 'Bearer '+ self._bearer, + 'connection' : 'Keep-Alive', + 'host' : 'api.indego-cloud.iot.bosch-si.com', + 'user-agent' : 'Indego-Connect_4.0.0.12253', + 'content-type' : 'application/json' + } ``` -@Get - steht für einen get-request in Python. Die URL lautet : "https://api.indego.iot.bosch-si.com/api/v1/" gefolgt vom entsprechenden Zugriffspunkt +@Get - steht für einen get-request in Python. Die URL lautet : "https://api.indego-cloud.iot.bosch-si.com/api/v1/" gefolgt vom entsprechenden Zugriffspunkt ``` -url = "https://api.indego.iot.bosch-si.com/api/v1/" +"alms/{}/automaticUpdate".format(alm_sn) +url = "https://api.indego-cloud.iot.bosch-si.com/api/v1/" +"alms/{}/automaticUpdate".format(alm_sn) response = requests.get(url, headers=headers) ``` diff --git a/indego4shng/README.not_convertable.md.off b/indego4shng/README.not_convertable.md.off new file mode 100755 index 000000000..ac6bc8b95 --- /dev/null +++ b/indego4shng/README.not_convertable.md.off @@ -0,0 +1,694 @@ +# Indego4shNG + +## Table of Content + +1. [Generell](#generell) +2. [Credits](#credits) +3. [Change Log](#changelog) **Neu** +4. [Konfiguration](#konfiguration) **Update** +5. [Web-Interface](#webinterface) +6. [Logik-Trigger](#logiktrigger) +7. [öffentlich Funktionen (API)](#api) +8. [Gartenkarte "pimpen"](#gardenmap) +9. [Nutzung der Original Bosch-Mäher-Symbole](#boschpics) +10. [Die Bosch-Api 4.0.1 - behind the scenes](#boschapi) + +## Generell + +Das Indego-Plugin wurde durch ein Reverse-Engineering der aktuellen (Version 3.0) App +von Bosch entwickelt. Als Basis diente das ursprüngliche Plugin von Marcov. Es werden alle Funktionen der App für den Betrieb sowie einige zusätzliche bereitgestellt. +Für die Ersteinrichtung wird weiterhin die Bosch-App benötigt. +Das Plugin erhält die Version der aktuellen Bosch-API. (4.0.1) + +## Credits + +Vielen Dank an schuma für die tolle Unterstützung während der Entwicklungsphase, +die Umsetzung vieler Teile in der Visu sowie den vielen unzähligen Tests und sehr viel Geduld. + +Vielen Dank an bmx für das Umstellen des Plugins auf Smart-Plugin. +Vielen Dank an psilo für die Erlaubnis zur Verwendung der LED-Grafiken im Web-Interface. +Vielen Dank an Marcov für die Entwicklung des ursprünglichen Plugins. +Vielen Dank an das Core-Team für die Einführung der STRUCTS, das hat die Arbeit deutlich vereinfacht. +Vielen Dank an Jan Odvarko für die Entwicklung des Color-Pickers (http://jscolor.com) unter Freigabe für Opensource mit GPLv3 + + +## Change Log +#### 2023-05-06 V4.0.1 +- Login via Single-Key-ID eingebaut +- Endpoit der Bosch-API wurde geändert (siehe Konfiguration) + +#### 2023-03-08 V4.0.0 +- Login via Bosch-ID eingebaut + +#### 2023-02-05 V3.0.2 +- Anpassungen für die geänderten Daten für das Wetter (es werden nun 7 Tage statt 5 übermittelt, die Sonnenstunden je Tag wurden entfern) + +#### 2021-05-16 V3.0.1 +- rücksetzen des Messerzählers eingebaut +- besseres Handling beim automatischen AUS/EIN-Loggen +- Einstellen des Mäher-Standorts über das Web-Interface (die Location wird durch die Bosch-App nicht richtig gesetzt, die Wetterdaten werden dann nicht mehr korrekt übermittelt.) + +#### 2019-10-28 V3.0.0 +- Kommunikation auf requests geändert +- Verwendung von vordefinierten STRUCTS für alle benötigten Items +- verbessertes Login/Session-Handling +- Umstellung auf Code64 verschlüsselte Credentials +- Integration eines Wintermodus wenn der Mäher stillgelegt ist +- Integration der Mähkalenderverwaltung +- Integration der SmartMow-Einstellungen +- Integration "Mähen nach UZSU" +- verbesserte Darstellung der Icons für das Wetter +- Gartenkarte als Item in Visu integriert +- "pimpen" der Gartenkarte mit eigenen Vektoren +- Mähspurdarstellung für die IndegoConnect 350/400 +- Aktualisierung der Mäherposition beim Mähen alle 7 Sekunden +- Darstellung der Informationen zum genutzten GSM-Netz sowie zum verwendeten Standort +- Updatefunktionen für Firmware integriert +- Integration der Sensorempfindlichkeit +- Integration von unterschiedlichen Bilder für Große/Kleine Mäher +- Alarme / Meldungen werden in einem Popup dargestellt und können gelesen/gelöscht werden. +- VISU um Batterie-Informationen erweitert +- diverse Charts für Batterie, Temperatur, Mäheffizienz, Mäh-/Ladezeiten +- Protokoll für Mäher STATI und Bosch-Kommunikation im Web-Interface +- Unterstützung für base64 codierte Credentials im Web-Interface +- Trigger für Alarme und STATI des Mähers im Web-Interface +- Mäherfarbe für die Darstellung der Kartenkarte im Web-Interface wählbar + + + + +## Requirements + +Das UZSU-Plugin wird genutzt. Das UZSU-Plugin sollte vor dem Indego4shNG-Plugin geladen sein +(Reihenfolge in der smarthome/etc/plugin.yaml) + +### benötigte Software + +* SmartVISU 2.9 oder höher (es werden Dropins verwendet) +* smarthomeNg 1.6 oder höher (es werden vordefinierte STRUCTS verwendet) +* für die Darstellung der Charts muss eine Database-plugin aktiviert sein + + +### Supported Hardware + +* #### Indego Connect 350/S+350/400/S+400, im folgenden die "Kleinen" genannt +* #### Indego Connect 800/1000/1200/1300, im folgenden die "Großen" genannt + +Die Firmware der "Kleinen" und der "Großen" liefern unterschiedliche Informationen + und stellen unterschiedliche Funktionen zur Verfügung. Hier werden kurz die Unterschiede erläutert: + +Bei den "Großen" gibt es folgende Einschränkungen: +* der Ladezustand des Akkus wird auf Grund der abfallenden Spannung berechnet (35 Volt = 100 %, 28 Volt = 0%) +Langzeitbeobachtungen haben gezeigt, dass die Mähe bei 31 Volt zurück in die Ladestation fahren. Es wird unterstellt, + dass 31 Volt noch 20 % Akkuladestand sind. +* Es werden aktuell keine Informationen zur Netznutzung bereitgestellt. +* Die Aktualisierung der Mäherposition erfolgt nur ca. alle 30 Minuten während des Mähens +* Die Sensor-Empfindlichkeit kann nicht eingestellt werden + +Bei den "Kleinen" gibt es folgende Einschränkungen: +* Es wird von Bosch keine gemähte Fläche übermittelt. Diese kann mittels des "MowTracks" aber angezeigt werden. + + +## Konfiguration + +### plugin.yaml + +folgende Einträge werden in der "./etc/plugin.yaml" benötigt. + +* `plugin_name: Indego4shNG`: fix "Indego4shNG" +* `class_path: plugins.indego4shng`: fix "plugins.indego4shng" +* `path_2_weather_pics: XXXXXXX`: ist der Pfad zu den Bilder des Wetter-Widgets. +(default ="/smartvisu/lib/weather/pics/") +* `img_pfad: XXXXXXX`: ist der Pfad unter dem die Gartenkarte gespeichert wird. +(default = "/tmp/garden.svg") +Die Datei wird nicht für die VISU benötigt. Man kann die Datei als Vorlage +zum "pimpen" der Gartenkarte verwenden +* `indego_credentials : XXXXXXX`: sind die Zugangsdaten für den Bosch-Server im Format base64 encoded. +* `parent_item : indego`: name des übergeordneten items für alle Child-Items +* `cycle : 30`: Intervall in Sekunden für das Abrufen des Mäher-Status (default = 30 Sekunden) +* `url: https://api.indego-cloud.iot.bosch-si.com/api/v1/` : Url des Bosch-Endpoints + +Die Zugangsdaten (indego_credentials) können nach dem Erststart des Plugins im Web-Interface erfasst und gespeichert werden + +!! Das parent-Item kann umbenannt werden ,es müssen dann aber alle items in der indego.html angepasst werden !! + +Beispiel: + +```yaml +Indego4shNG: + plugin_name: Indego4shNG + class_path: plugins.indego4shng + path_2_weather_pics: /smartvisu/lib/weather/pics/ + img_pfad: /tmp/garden.svg + indego_credentials: + parent_item: indego + cycle: '30' + url: https://api.indego-cloud.iot.bosch-si.com/api/v1/ +``` + + + +### items.yaml + +Es wird ledigliche folgender Eintrag für die Items benötigt. +Die restlichen Informationen werden aus der mitgelieferten Struct-Definition gelesen. +Eine entsprechende Config-Datei ist im Ordner "items" des Plugins bereits vorhanden und +muss nur in den Ordner "./smarthome/items" kopiert werden. + +```yaml +%YAML 1.1 +--- + +indego: + struct: indego4shng.child +``` + +### SmartVisu + +Die Inhalte des Ordners "./sv_widgets" müssen in den entsprechenden Ordner der VISU. +In der Regel "/var/www/html/smartvisu/dropins" kopiert werden. +Wenn das smartvisu-Plugin verwendet wird und das kopieren der Widget nicht abgeschalten ist, werden die Dateien beim Start von shNG automatisch in den Dropin-Ordner kopiert. +Ansonsten müssen die Daten manuell in das Verzeichnigs "./dropins" kopiert werden. + +Die Icons aus "indego4shng/pages/icons/" müssen in das visu-dir "dropins/icons/ws/" kopiert werden. + +Im Ordner "/pages" des plugins ist eine vorgefertigte Raumseite für die SmartVISU. (indego.html) +Diese muss in den Ordner "/pages/DeinName/" kopiert werden und die Raumnavigation entsprechend ergänzt werden. + +!!! Immer auf die Rechte achten !!! + + +## Web-Interface +Kurze Erläuterung zum Web-Interface +### erster Tab - Übersicht Indego-Items +![Webif-Tab1](./assets/webif1.jpg) + + + +### zweiter Tab - Originalgartenkarte / Settings +Hier wird die Original-Gartenkarte wie sie von Bosch übertragen wird angezeigt. +Es kann mit dem Colour-Picker die Farbe des Mähers in der Visu angepasst werden. +Die Originalkarte bleibt unverändert. Im ersten Tab wird unter dem Item indego.visu.map_2_show +die modifizierte Karte angzeigt. +Es können auf dieser Seite zusätzlich Vektoren eingefügt werden welche die Gartenkarte erweitern bzw."aufhübschen" +Hier kann die Location auf den Bosch-Servern gespeichert werden. +Es müssen Längen/Breitengrad angegeben werden. Wenn noch keine Koordinaten in den Items gespeichert sind werden +die Long/Lat von shNG vorgeschlagen. +[Sieh auch hier](#gardenmap) + + +Es können hier bis zu 4 Trigger für Stati gewählt werden. 999999 - kein Status gewählt. +Immer wenn der Status des Mähers auf den gewählten Status wechselt wird das Trigger-item +"indego.trigger.state_trigger_X:" (X = 1-4 ) gesetzt. Die Trigger können in einer Logik +verarbeitet werden. Beispiel siehe bei Logiken. +Es können bis zu 4 Texte für Meldungen erfasst werden. Wenn der Text in der Überschrift oder im Inhalt der +Meldung ist wird der Trigger "indego.trigger.alarm_trigger_X:" (X = 1-4 ) beim eintreffen der Meldung gesetzt. + +![Webif-Tab1](./assets/webif2.jpg) + + + +### dritter Tab - State-Protokoll +Hier können die einzelnen Statuswechsel des Mähers eingesehen werden. +Es erfolgt bei jedem Statuswechsel ein Eintrag, das Protokoll ist selbst rotierend und hat +maximal 500 Einträge + +![Webif-Tab1](./assets/webif3.jpg) + + + +### vierter Tab - Kommunikationsprotokoll +Hier können Protokoll-Einträge zu den einzelnen Kommunikationsanfragen mit dem Bosch-Server eingesehen werden. +Es erfolgt bei jedem Statuswechsel ein Eintrag, das Protokoll ist selbst rotierend und hat +maximal 500 Einträge +![Webif-Tab1](./assets/webif4.jpg) + + +## Logik-Trigger + +Über die Items : + +indego.trigger_state_trigger_1(2)(3)(4) + +und + +indego.trigger_alarm_trigger_1(2)(3)(4) + +können Events auf state-Wechsel und Meldungen in Logiken ausgeführt werden. +Die Trigger werden über das Web-Interface definiert. Bei den Alarmen wird ein Teil des +Textes der Alarm-Meldung oder der Überschrift angegeben. Groß- Kleinschreibung spielt keine Rolle +Wenn der Text in der Meldung bzw. der Überschrift enthalten ist wird der Trigger ausgelöst. + +Beispiel : + +``` +#!/usr/bin/env python3 +# indego2alexa.py + +text='' +try: + triggeredItem=trigger['source'] + triggerValue = trigger['value'] + + # Check the State-Items + if triggeredItem == 'indego.trigger.state_trigger_1': + if triggerValue == True: + text = 'Achtung der Indego nimmt seine Arbeit auf' + + elif triggeredItem == 'indego.trigger.state_trigger_2': + if triggerValue == True: + text = 'Der Indego hat seine Arbeit getan Danke Indego' + + elif triggeredItem == 'indego.trigger.state_trigger_3': + if triggerValue == True: + text = '' + + elif triggeredItem == 'indego.trigger.state_trigger_4': + if triggerValue == True: + text = '' + + # Now the Alarm-Items + if triggeredItem == 'indego.trigger.alarm_trigger_1': + if triggerValue == True: + text = 'Achtung der Indego benötigt Wartung' + + elif triggeredItem == 'indego.trigger.alarm_trigger_2': + if triggerValue == True: + text = 'Achtung der Indego benötigt neue Messer' + + elif triggeredItem == 'indego.trigger.alarm_trigger_3': + if triggerValue == True: + text = '' + + elif triggeredItem == 'indego.trigger.alarm_trigger_4': + if triggerValue == True: + text = '' + + if text != '': + sh.alexarc4shng.send_cmd('Kueche', 'Text2Speech', text); +except: + pass +``` + + +## öffentliche Funkionen + +Es gibt eine Funktion die z.B. über Logiken aufgerufen werden kann. +#### send_command(Payload as String) + +Man kann so z.B. den Mäher bei einsetzendem Regen der durch die Wetterstation erkannt wird +zurück in die Ladestation schicken. +Anderes Beispiel wäre beim Verlassen des Hauses den Mäher starten. +``` +#!/usr/bin/env python3 +# indego_rc.py + +sh.Indego4shNG.send_command('{"state":"returnToDock"}','Logic') +#sh.Indego4shNG.send_command('{"state":"mow"}','Logic') +#sh.Indego4shNG.send_command('{"state":"pause"}','Logic') + + + +``` + + +## Gardenkarte "pimpen" + +Die Gartenkarte wird vom Bosch-Server heruntergeladen und als Item für die Visu verwendet. +Die Datei wird als Vorlage zum Erweitern unter dem angegebenen Pfad gespeichert ( vgl. ```img_pfad``` im Konfig-Teil). + +Man kann die Karte als Vorlage in einem [Online-Tool](#https://editor.method.ac/) als Vorlage laden. +Es werden dann einfach die zusätzlichen Vektoren eingezeichnet oder via "File / Import Image" hinzugeladen. + +Man kann die veränderte Karte auch lokal zwischenspeichern. + +Am Ende wählt man im Menü die Ansicht "View" den Eintrag "Source". Hier kann man die erweiterten Vektoren +einfach in die Zwischenablage kopieren und im Web-Interface unter Tab-2 einfügen. +Der letzte Original-Eintrag der Bosch-Karte ist die Zeile mit + +``` + +``` + +Die Werte können abweichen, da hier auch die Position des Mähers sowie die ID enthalten ist. Am besten auf "circle" und den Farbwert "#FFF601" achten. + +Diese Zeile ist der gelbe Punkt (Mäher) in der Originalkarte. +Beim Verlassen der Textarea werden die neuen Vektoren sofort in ein Item gespeichert und die Gartenkarte neu gerendert. +Das Ergebnis ist in der VISU sofort sichtbar. + +## Beispiel : + +![pimp_my_map](./assets/pimp_my_map.jpg) + + +## Nutzung der Original Bosch-Mäher-Symbole + +Es werden die Bilder der Bosch 2.2.8 App verwenden. +Man kann sich die Bilder aus der "Legacy Bosch Smart Gardening"-App extrahieren. +Die APK-Datei ist im Internet zu finden. + + +Die apk-Datei mit einer Archiv-Verwaltung öffnen und dort im Pfad "/assets/www/assets" die Bilder extrahieren und in den Dropins-Ordner kopieren. +Die Bilder haben folgende Dateinamen : + +Für die "Großen" +``` +indego.png +indego-docked.png +indego-mowing.png +``` + +Für die "Kleinen" +``` +indego-s.png +indego-docked-s.png +indego-mowing-s.png +``` +Sobald die Dateien mit den Bildern vorhanden sind findet das Widget diese und verwendet sie automatisch. +Die entsprechenden Bilder für die "Großen"/"Kleinen" werden auf Grund des Mähertyps automatisch gewählt und dargestellt. + + +## Die Bosch-Api 4.0.1 - behind the scenes + +Hier ist die Schnittstelle der Bosch-API kurz beschrieben und die Implementierung im Plugin dokumentiert. +Der Header ist in den meisten Fällen mit der Session-ID zu füllen : +``` +headers = {'accept' : '*/*', + 'authorization' : 'Bearer '+ self._bearer, + 'connection' : 'Keep-Alive', + 'host' : 'api.indego-cloud.iot.bosch-si.com', + 'user-agent' : 'Indego-Connect_4.0.0.12253', + 'content-type' : 'application/json' + } +``` +@Get - steht für einen get-request in Python. Die URL lautet : "https://api.indego-cloud.iot.bosch-si.com/api/v1/" gefolgt vom entsprechenden Zugriffspunkt +``` +url = "https://api.indego-cloud.iot.bosch-si.com/api/v1/" +"alms/{}/automaticUpdate".format(alm_sn) +response = requests.get(url, headers=headers) +``` + +Über die Items : + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
plugin-Supp.API-URLPayload
ja"@DELETE("alerts/{alert_id}")"-
nein@POST("users"){}
nein@DELETE("alerts")-
nein@DELETE("alms/{alm_serial}/map")-
nein@DELETE("users/{user_id}")-
ja@GET("alms/{alm_serial}/config")-
ja@GET("alms/{alm_serial}"){"needs_service": false, "alm_firmware_version": "00647.01043", "service_counter": 159551, "bareToolnumber": "3600HA2300", "alm_name": "Indego", "alm_sn": "XXXXXXXXXX", "alm_mode": "manual"}
nein@GET("alms/{alm_serial}")-
nein@GET("pub/accessories")-
ja@GET("alerts")-
ja@GET("alms/{alm_serial}/automaticUpdate")-
ja@GET("alms/{alm_serial}/updates")-
ja@GET("alms/{alm_serial}/operatingData")-
ja@GET("alms/{alm_serial}/calendar"){}
ja@GET("alms/{alm_serial}/predictive/location"){}
ja@GET("alms/{alm_serial}/predictive/lastcutting")-
ja@GET("alms/{alm_serial}/map")-
nein@GET("alms/{alm_serial}/info"){"bareToolnumber": "3600HA2300"}
ja@GET("alms/{alm_serial}/location"){"longitude": x.xxxx, "latitude": xx.xxxxx}
ja@GET("alms/{alm_serial}/network")-
ja@GET("alms/{alm_serial}/predictive/nextcutting")-
nein@GET("alms/{alm_serial}/info")-
nein@GET("pub/accessories/{accessory_code}")-
nein@GET("alms/{alm_serial}/security"){"enabled": true, "autolock": false}
ja@GET("alms/{alm_serial}/predictive"){"enabled": false}
ja@GET("alms/{alm_serial}/predictive/schedule"){'exclusion_days': [{'slots': [{'StHr': 0, 'EnMin': 0, 'EnHr': 8, 'En': True, 'Attr': 'C', 'StMin': 0}, {'StHr': 8, 'EnMin': 0, 'EnHr': 14, 'En': True, 'Attr': 'tTD', 'StMin': 0}, {'StHr': 18, 'EnMin': 0, 'EnHr': 22, 'En': True, 'Attr': 't', 'StMin': 0}, {'StHr': 22, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 0}, {'slots': [{'StHr': 0, 'EnMin': 0, 'EnHr': 8, 'En': True, 'Attr': 'C', 'StMin': 0}, {'StHr': 8, 'EnMin': 0, 'EnHr': 12, 'En': True, 'Attr': 'tT', 'StMin': 0}, {'StHr': 18, 'EnMin': 0, 'EnHr': 22, 'En': True, 'Attr': 'tT', 'StMin': 0}, {'StHr': 22, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 1}, {'slots': [{'StHr': 0, 'EnMin': 0, 'EnHr': 8, 'En': True, 'Attr': 'C', 'StMin': 0}, {'StHr': 8, 'EnMin': 0, 'EnHr': 14, 'En': True, 'Attr': 'tTD', 'StMin': 0}, {'StHr': 16, 'EnMin': 0, 'EnHr': 22, 'En': True, 'Attr': 'tT', 'StMin': 0}, {'StHr': 22, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 2}, {'slots': [{'StHr': 0, 'EnMin': 0, 'EnHr': 8, 'En': True, 'Attr': 'C', 'StMin': 0}, {'StHr': 8, 'EnMin': 0, 'EnHr': 22, 'En': True, 'Attr': 'tTD', 'StMin': 0}, {'StHr': 22, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 3}, {'slots': [{'StHr': 0, 'EnMin': 0, 'EnHr': 8, 'En': True, 'Attr': 'C', 'StMin': 0}, {'StHr': 8, 'EnMin': 0, 'EnHr': 22, 'En': True, 'Attr': 'tTD', 'StMin': 0}, {'StHr': 22, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 4}, {'slots': [{'StHr': 0, 'EnMin': 0, 'EnHr': 8, 'En': True, 'Attr': 'C', 'StMin': 0}, {'StHr': 8, 'EnMin': 0, 'EnHr': 14, 'En': True, 'Attr': 'tTD', 'StMin': 0}, {'StHr': 16, 'EnMin': 0, 'EnHr': 22, 'En': True, 'Attr': 'tT', 'StMin': 0}, {'StHr': 22, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 5}, {'slots': [{'StHr': 0, 'EnMin': 59, 'EnHr': 23, 'En': True, 'Attr': 'C', 'StMin': 0}], 'day': 6}], 'schedule_days': [{'slots': [{'En': True, 'StHr': 14, 'EnMin': 0, 'StMin': 0, 'EnHr': 16}], 'day': 0}]}
ja@GET("alms/{alm_serial}/predictive/setup")-
ja@GET("alms/{alm_serial}/state"){"svg_xPos": 768, "runtime": {"session": {"charge": 0, "operate": 4}, "total": {"charge": 34352, "operate": 193907}}, "mowed": 89, "mowmode": 0, "xPos": 14, "yPos": 96, "svg_yPos": 792, "mapsvgcache_ts": 1573585675296, "state": 64513, "map_update_available": false}
nein@GET("pub/static/{resource_id}")-
nein@GET("pub/support/DE"){'email': 'indego.support@de.bosch.com', 'phone': '+49 711 400 40 470'}
nein@GET("users/{user_id}"){'optInApp': False, 'display_name': 'XXXXXXXXX', 'email': 'xxxxx.xxxx@xxxxxxxx.xxx', 'country': 'DE', 'language': 'de', 'optIn': False}
nein@GET("pub/video&country_code=DE&language=de&mowerType=XXXX")-
ja@GET("alms/{alm_serial}/predictive/weather")-
ja@POST("authenticate")-
nein@POST("authenticate?facebook")-
ja@DELETE("authenticate")-
nein@POST("alms/{alm_serial}/pair")-
nein@POST("alms/{alm_serial}/map")-
ja@POST("alms/{alm_serial}/requestPosition")-
ja@PUT("alms/{alm_serial}")-
ja@PUT("alerts/{alert_id}")-
nein@PUT("alerts")-
ja*@PUT("alms/{alm_serial}/config")-
ja@PUT("alms/{alm_serial}/automaticUpdate"){}
nein@PUT("alms/{alm_serial}/updates/notification/{process_id}")-
ja@PUT("alms/{alm_serial}/calendar")-
nein@PUT("alms/{alm_serial}/dateAndTime")-
ja@PUT("alms/{alm_serial}/predictive/location")-
ja@PUT("alms/{alm_serial}/updates")-
nein@PUT("alms/{alm_serial}/predictive/reset")@Query("reinitialize")
nein@PUT("alms/{alm_serial}/security")-
ja@PUT("alms/{alm_serial}/predictive")-
ja@PUT("alms/{alm_serial}/predictive/setup")-
ja@PUT("alms/{alm_serial}/state")-
nein@PUT("users/{user_id}")-
ja@POST("authenticate/check")-
nein@POST("pub/resetpassword")-
nein@DELETE("alms/{alm_serial}/pair")-
+ +'* nur bei 350/350+/400/400+ diff --git a/indego4shng/__init__.py b/indego4shng/__init__.py index 9888b1ebf..42d2e715e 100755 --- a/indego4shng/__init__.py +++ b/indego4shng/__init__.py @@ -44,9 +44,13 @@ from datetime import date import base64 +import urllib.parse +#sys.path.append('/home/smarthome/.p2/pool/plugins/org.python.pydev.core_6.5.0.201809011628/pysrc') +sys.path.append('/devtools/eclipse/plugins/org.python.pydev.core_8.0.0.202009061309/pysrc/') +import pydevd # If a package is needed, which might be not installed in the Python environment, @@ -64,7 +68,7 @@ class Indego4shNG(SmartPlugin): Main class of the Indego Plugin. Does all plugin specific stuff and provides the update functions for the items """ - PLUGIN_VERSION = '4.0.0' + PLUGIN_VERSION = '4.0.1' def __init__(self, sh, *args, **kwargs): """ @@ -166,15 +170,16 @@ def run(self): self.password = self.credentials.split(":")[1] # taken from Init of the plugin if (self.user != '' and self.password != ''): - # self._auth() deprecated - self.logged_in = self._login2Bosch() + self.login_pending = True + self.logged_in, self._bearer, self._refresh_token, self.token_expires,self.alm_sn = self._login_single_key_id(self.user, self.password) + self.login_pending = False + self.context_id = self._bearer[:10]+ '.......' # start the refresh timers self.scheduler_add('operating_data',self._get_operating_data,cycle = 300) self.scheduler_add('get_state', self._get_state, cycle = self.cycle) self.scheduler_add('alert', self.alert, cycle=300) self.scheduler_add('get_all_calendars', self._get_all_calendars, cycle=300) - #self.scheduler_add('check_login_state', self._check_login_state, cycle=130) self.scheduler_add('refresh_token', self._getrefreshToken, cycle=self.token_expires-100) self.scheduler_add('device_data', self._device_data, cycle=120) self.scheduler_add('get_weather', self._get_weather, cycle=600) @@ -199,7 +204,6 @@ def stop(self): self.scheduler_remove('get_weather') self.scheduler_remove('get_next_time') - self._delete_auth() # Log off self.logger.debug("Stop method called") self.alive = False @@ -237,6 +241,7 @@ def parse_item(self, item): return self.update_item if self.has_iattr(item.conf, 'indego_parse_2_attr'): + #pydevd.settrace("192.168.178.37", port=5678) _attr_name = item.conf['indego_attr_name'] newStruct = {} myStruct= json.loads(item()) @@ -271,6 +276,7 @@ def update_item(self, item, caller=None, source=None, dest=None): :param source: if given it represents the source :param dest: if given it represents the dest """ + #pydevd.settrace("192.168.178.37", port=5678) # Function when item is triggered by VISU if caller != self.get_shortname() and caller != 'Autotimer' and caller != 'Logic': @@ -323,6 +329,7 @@ def update_item(self, item, caller=None, source=None, dest=None): self.logger.warning("Error sending command for item '{}' from caller '{}', source '{}' and dest '{}'".format(item,caller,source,dest)) if self.has_iattr(item.conf, 'indego_function_4_all'): + #pydevd.settrace("192.168.178.37", port=5678) try: self.logger.debug("Item '{}' has attribute '{}' found with {}".format( item, 'indego_plugin_function', self.get_iattr_value(item.conf, 'indego_function_4_all'))) myFunction_Name = self.get_iattr_value(item.conf, 'indego_function_4_all') @@ -379,6 +386,7 @@ def _handle_wartung(self, item): self._set_automatic_updates() if item.property.name == self.parent_item+".wartung.messer_zaehler": + #pydevd.settrace("192.168.178.37", port=5678) if (item.property.value == True): if (self._reset_bladeCounter() == True): item(False) @@ -415,6 +423,7 @@ def _handle_parse_map(self, item): def _handle_calendar_list(self, item): if item.property.name == self.parent_item+'.calendar_list': + #pydevd.settrace("192.168.178.37", port=5678) myList = item() myCal = self._get_childitem('calendar') myNewCal = self._parse_list_2_cal(myList, myCal,'MOW') @@ -607,7 +616,7 @@ def _set_clear_message(self): myClearMsg = self._get_childitem('visu.alerts') for message in msg2clear: - myResult = self._delete_url(self.indego_url +'alerts/{}'.format(message), self.context_id, 10,auth=(self.user,self.password)) + myResult = self._delete_url(self.indego_url +'alerts/{}'.format(message), self.context_id, 10,None) self._del_message_in_dict(myClearMsg, message) self._set_childitem('visu.alerts', myClearMsg) @@ -625,11 +634,8 @@ def _check_login_state(self): if self.expiration_timestamp < actTimeStamp+575: self.logged_in = False self.login_pending = True - self._delete_auth() self.context_id = '' - self._auth() self.login_pending = False - self.logged_in = self._check_auth() self._set_childitem('online', self.logged_in) actDate = datetime.now() self.logger.info("refreshed Session-ID at : {}".format(actDate.strftime('Date: %a, %d %b %H:%M:%S %Z %Y'))) @@ -687,6 +693,7 @@ def _auto_pred_cal_update(self): def _auto_mow_cal_update(self): self.cal_update_count += 1 self.cal_update_running = True + #pydevd.settrace("192.168.178.37", port=5678) # set actual Calendar in Calendar-structure myCal = self._get_childitem('calendar') actCalendar = self._get_childitem('calendar_sel_cal') @@ -789,6 +796,7 @@ def _get_all_calendars(self): 'days' : schedule['schedule_days'] }] } + #pydevd.settrace("192.168.178.37", port=5678) my_pred_list = self._parse_cal_2_list(my_pred_cal, None) my_smMow_list = self._parse_cal_2_list(my_smMow_cal, None) @@ -808,6 +816,7 @@ def _log_communication(self, type, url, result): self._set_childitem('webif.communication_protocoll', myLog) def _fetch_url(self, url, username=None, password=None, timeout=10, body=None): + #pydevd.settrace("192.168.178.37", port=5678) try: myResult, response = self._post_url(url, self.context_id, body, timeout,auth=(username,password),nowait = True) except Exception as e: @@ -836,15 +845,16 @@ def _delete_url(self, url, contextid=None, timeout=40, auth=None,nowait = True): myCouner += 1 time.sleep(2) - headers = {'accept-encoding' : 'gzip', - 'authorization' : 'Bearer '+ self._bearer, - 'connection' : 'Keep-Alive', - 'host' : 'api.indego-cloud.iot.bosch-si.com', - 'user-agent' : 'Indego-Connect_4.0.0.12253' + headers = {'accept' : '*/*', + 'authorization' : 'Bearer '+ self._bearer, + 'connection' : 'Keep-Alive', + 'host' : 'api.indego-cloud.iot.bosch-si.com', + 'user-agent' : 'Indego-Connect_4.0.0.12253', + 'content-type' : 'application/json' } response = False try: - response = requests.delete(url, headers=headers, auth=auth) + response = requests.delete(url, headers=headers) self._log_communication('delete', url, response.status_code) except Exception as e: self.logger.warning("Problem deleting {}: {}".format(url, e)) @@ -993,30 +1003,6 @@ def _check_state_4_protocoll(self): self.position_detection = False - def _delete_auth(self): - ''' - DELETE https://api.indego.iot.bosch-si.com/api/v1/authenticate - x-im-context-id: {contextId} - ''' - headers = {'Content-Type': 'application/json', - 'Accept' : 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'x-im-context-id' : self.context_id - } - url = self.indego_url + 'authenticate' - try: - response = self._delete_url(url, self.context_id, 10,auth=None, nowait = True) - except Exception as e: - self.logger.warning("Problem logging off {0}: {1}".format(url, e)) - return False - if response == False: - return False - - if (response.status_code == 200 or response.status_code == 201): - self.logger.info("You logged off successfully") - return True - else: - self.logger.info("Log off was not successfull : {0}".format(response.status_code)) - return False def _store_calendar(self, myCal = None, myName = ""): ''' @@ -1038,54 +1024,7 @@ def _store_calendar(self, myCal = None, myName = ""): return response.status_code - - - def _check_auth(self): - ''' - GET https://api.indego.iot.bosch-si.com/api/v1/authenticate/check - Authorization: Basic bWF4Lm11c3RlckBhbnl3aGVyZS5jb206c3VwZXJzZWNyZXQ= - x-im-context-id: {contextId} - ''' - headers = {'Content-Type': 'application/json', - 'Accept' : 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'x-im-context-id' : self.context_id - } - url = self.indego_url + 'authenticate/check' - - try: - response = self._get_url(url, self.context_id, 10, auth=(self.user,self.password)) - #response = requests.get(url,auth=(self.user,self.password), headers=headers) - - - except Exception as e: - self.logger.warning("Problem checking Authentication {0}: {1}".format(url, e)) - return False - if response != False: - self.logger.info("Your are still logged in to the Bosch-Web-API") - return True - else: - self.logger.info("Your are not logged in to the Bosch-Web-API") - return False - - - - def _auth(self): - url = self.indego_url + 'authenticate' - auth_response,expiration_timestamp = self._fetch_url(url, self.user, self.password, 10,{"device":"", "os_type":"Android", "os_version":"4.0", "dvc_manuf":"unknown", "dvc_type":"unknown", "accept_tc_id": "202012"}) - if auth_response == False: - self.logger.error('AUTHENTICATION INDEGO FAILED! Plugin not working now.') - else: - self.last_login_timestamp = datetime.timestamp(datetime.now()) - self.expiration_timestamp = expiration_timestamp - self.logger.debug("String Auth: " + str(auth_response)) - self.context_id = auth_response['contextId'] - self.logger.info("context ID received :{}".format(self.context_id)) - self.user_id = auth_response['userId'] - self.logger.info("User ID received :{}".format(self.user_id)) - self.alm_sn = auth_response['alm_sn'] - self.logger.info("Serial received : {}".format(self.alm_sn)) - self._log_communication('Auth ', 'Expiration time {}'.format(expiration_timestamp), str(auth_response)) def _getrefreshToken(self): myUrl = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/token' @@ -1107,426 +1046,622 @@ def _getrefreshToken(self): myJson = json.loads (response.content.decode()) self._refresh_token = myJson['refresh_token'] self._bearer = myJson['access_token'] + self.context_id = self._bearer[:10]+ '.......' self.token_expires = myJson['expires_in'] self.last_login_timestamp = datetime.timestamp(datetime.now()) self.expiration_timestamp = self.last_login_timestamp + self.token_expires - def _login2Bosch(self): - # Standardvalues - self.login_pending = True - code_challenge = 'iGz3HXMCebCh65NomBE5BbfSTBWE40xLew2JeSrDrF4' - code_verifier = '9aOBN3dvc634eBaj7F8iUnppHeqgUTwG7_3sxYMfpcjlIt7Uuv2n2tQlMLhsd0geWMNZPoryk_bGPmeZKjzbwA' - nonce = 'LtRKgCy_l1abdbKPuf5vhA' - myClientID = '65bb8c9d-1070-4fb4-aa95-853618acc876' # that the Client-ID for the Bosch-App - - myPerfPayload ={ - "navigation": { - "type": 0, - "redirectCount": 0 - }, - "timing": { - "connectStart": 1678187315976, - "navigationStart": 1678187315876, - "loadEventEnd": 1678187317001, - "domLoading": 1678187316710, - "secureConnectionStart": 1678187315994, - "fetchStart": 1678187315958, - "domContentLoadedEventStart": 1678187316973, - "responseStart": 1678187316262, - "responseEnd": 1678187316322, - "domInteractive": 1678187316973, - "domainLookupEnd": 1678187315958, - "redirectStart": 0, - "requestStart": 1678187316010, - "unloadEventEnd": 0, - "unloadEventStart": 0, - "domComplete": 1678187317001, - "domainLookupStart": 1678187315958, - "loadEventStart": 1678187317001, - "domContentLoadedEventEnd": 1678187316977, - "redirectEnd": 0, - "connectEnd": 1678187316002 - }, - "entries": [ - { - "name": "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect%3A%2F%2Flogin&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&state=j1A8L2zQMbolEja6yqbj4w&nonce=LtRKgCy_l1abdbKPuf5vhA&scope=openid%20profile%20email%20offline_access%20https%3A%2F%2Fprodindego.onmicrosoft.com%2Findego-mobile-api%2FIndego.Mower.User&code_challenge={}&code_challenge_method=S256".format(code_challenge), - "entryType": "navigation", - "startTime": 0, - "duration": 1125.3999999999849, - "initiatorType": "navigation", - "nextHopProtocol": "http/1.1", - "workerStart": 0, - "redirectStart": 0, - "redirectEnd": 0, - "fetchStart": 82.29999999997517, - "domainLookupStart": 82.29999999997517, - "domainLookupEnd": 82.29999999997517, - "connectStart": 99.99999999999432, - "connectEnd": 126.29999999998631, - "secureConnectionStart": 117.4999999999784, - "requestStart": 133.7999999999795, - "responseStart": 385.5999999999824, - "responseEnd": 445.699999999988, - "transferSize": 66955, - "encodedBodySize": 64581, - "decodedBodySize": 155950, - "serverTiming": [], - "workerTiming": [], - "unloadEventStart": 0, - "unloadEventEnd": 0, - "domInteractive": 1097.29999999999, - "domContentLoadedEventStart": 1097.29999999999, - "domContentLoadedEventEnd": 1100.999999999999, - "domComplete": 1125.2999999999815, - "loadEventStart": 1125.3999999999849, - "loadEventEnd": 1125.3999999999849, - "type": "navigate", - "redirectCount": 0 - }, - { - "name": "https://swsasharedprodb2c.blob.core.windows.net/b2c-templates/bosch/unified.html", - "entryType": "resource", - "startTime": 1038.0999999999858, - "duration": 21.600000000006503, - "initiatorType": "xmlhttprequest", - "nextHopProtocol": "", - "workerStart": 0, - "redirectStart": 0, - "redirectEnd": 0, - "fetchStart": 1038.0999999999858, - "domainLookupStart": 0, - "domainLookupEnd": 0, - "connectStart": 0, - "connectEnd": 0, - "secureConnectionStart": 0, - "requestStart": 0, - "responseStart": 0, - "responseEnd": 1059.6999999999923, - "transferSize": 0, - "encodedBodySize": 0, - "decodedBodySize": 0, - "serverTiming": [], - "workerTiming": [] - }, - { - "name": "https://swsasharedprodb2c.blob.core.windows.net/b2c-templates/bosch/bosch-header.png", - "entryType": "resource", - "startTime": 1312.7999999999815, - "duration": 7.900000000006457, - "initiatorType": "css", - "nextHopProtocol": "", - "workerStart": 0, - "redirectStart": 0, - "redirectEnd": 0, - "fetchStart": 1312.7999999999815, - "domainLookupStart": 0, - "domainLookupEnd": 0, - "connectStart": 0, - "connectEnd": 0, - "secureConnectionStart": 0, - "requestStart": 0, - "responseStart": 0, - "responseEnd": 1320.699999999988, - "transferSize": 0, - "encodedBodySize": 0, - "decodedBodySize": 0, - "serverTiming": [], - "workerTiming": [] - } - ], - "connection": { - "onchange": None, - "effectiveType": "4g", - "rtt": 150, - "downlink": 1.6, - "saveData": False, - "downlinkMax": None, - "type": "unknown", - "ontypechange": None - } - } - - myReqPayload = { - "pageViewId":'', - "pageId":"CombinedSigninAndSignup", - "trace":[ - { - "ac":"T005", - "acST":1678187316, - "acD":7 - }, - { - "ac":"T021 - URL:https://swsasharedprodb2c.blob.core.windows.net/b2c-templates/bosch/unified.html", - "acST":1678187316, - "acD":119 - }, - { - "ac":"T019", - "acST":1678187317, - "acD":44 - }, - { - "ac":"T004", - "acST":1678187317, - "acD":19 - }, - { - "ac":"T003", - "acST":1678187317, - "acD":5 - }, - { - "ac":"T035", - "acST":1678187317, - "acD":0 + + def _login_single_key_id(self,user, pwd): + try: + # Standardvalues + code_challenge = 'iGz3HXMCebCh65NomBE5BbfSTBWE40xLew2JeSrDrF4' + code_verifier = '9aOBN3dvc634eBaj7F8iUnppHeqgUTwG7_3sxYMfpcjlIt7Uuv2n2tQlMLhsd0geWMNZPoryk_bGPmeZKjzbwA' + nonce = 'LtRKgCy_l1abdbKPuf5vhA' + myClientID = '65bb8c9d-1070-4fb4-aa95-853618acc876' # das ist die echte Client-ID + step = 0 + + myPerfPayload ={ + "navigation": { + "type": 0, + "redirectCount": 0 }, - { - "ac":"T030Online", - "acST":1678187317, - "acD":0 + "timing": { + "connectStart": 1678187315976, + "navigationStart": 1678187315876, + "loadEventEnd": 1678187317001, + "domLoading": 1678187316710, + "secureConnectionStart": 1678187315994, + "fetchStart": 1678187315958, + "domContentLoadedEventStart": 1678187316973, + "responseStart": 1678187316262, + "responseEnd": 1678187316322, + "domInteractive": 1678187316973, + "domainLookupEnd": 1678187315958, + "redirectStart": 0, + "requestStart": 1678187316010, + "unloadEventEnd": 0, + "unloadEventStart": 0, + "domComplete": 1678187317001, + "domainLookupStart": 1678187315958, + "loadEventStart": 1678187317001, + "domContentLoadedEventEnd": 1678187316977, + "redirectEnd": 0, + "connectEnd": 1678187316002 }, - { - "ac":"T002", - "acST":1678187328, - "acD":0 + "entries": [ + { + "name": "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect%3A%2F%2Flogin&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&state=j1A8L2zQMbolEja6yqbj4w&nonce={}&scope=openid%20profile%20email%20offline_access%20https%3A%2F%2Fprodindego.onmicrosoft.com%2Findego-mobile-api%2FIndego.Mower.User&code_challenge={}&code_challenge_method=S256".format(nonce,code_challenge), + "entryType": "navigation", + "startTime": 0, + "duration": 1125.3999999999849, + "initiatorType": "navigation", + "nextHopProtocol": "http/1.1", + "workerStart": 0, + "redirectStart": 0, + "redirectEnd": 0, + "fetchStart": 82.29999999997517, + "domainLookupStart": 82.29999999997517, + "domainLookupEnd": 82.29999999997517, + "connectStart": 99.99999999999432, + "connectEnd": 126.29999999998631, + "secureConnectionStart": 117.4999999999784, + "requestStart": 133.7999999999795, + "responseStart": 385.5999999999824, + "responseEnd": 445.699999999988, + "transferSize": 66955, + "encodedBodySize": 64581, + "decodedBodySize": 155950, + "serverTiming": [], + "workerTiming": [], + "unloadEventStart": 0, + "unloadEventEnd": 0, + "domInteractive": 1097.29999999999, + "domContentLoadedEventStart": 1097.29999999999, + "domContentLoadedEventEnd": 1100.999999999999, + "domComplete": 1125.2999999999815, + "loadEventStart": 1125.3999999999849, + "loadEventEnd": 1125.3999999999849, + "type": "navigate", + "redirectCount": 0 + }, + { + "name": "https://swsasharedprodb2c.blob.core.windows.net/b2c-templates/bosch/unified.html", + "entryType": "resource", + "startTime": 1038.0999999999858, + "duration": 21.600000000006503, + "initiatorType": "xmlhttprequest", + "nextHopProtocol": "", + "workerStart": 0, + "redirectStart": 0, + "redirectEnd": 0, + "fetchStart": 1038.0999999999858, + "domainLookupStart": 0, + "domainLookupEnd": 0, + "connectStart": 0, + "connectEnd": 0, + "secureConnectionStart": 0, + "requestStart": 0, + "responseStart": 0, + "responseEnd": 1059.6999999999923, + "transferSize": 0, + "encodedBodySize": 0, + "decodedBodySize": 0, + "serverTiming": [], + "workerTiming": [] + }, + { + "name": "https://swsasharedprodb2c.blob.core.windows.net/b2c-templates/bosch/bosch-header.png", + "entryType": "resource", + "startTime": 1312.7999999999815, + "duration": 7.900000000006457, + "initiatorType": "css", + "nextHopProtocol": "", + "workerStart": 0, + "redirectStart": 0, + "redirectEnd": 0, + "fetchStart": 1312.7999999999815, + "domainLookupStart": 0, + "domainLookupEnd": 0, + "connectStart": 0, + "connectEnd": 0, + "secureConnectionStart": 0, + "requestStart": 0, + "responseStart": 0, + "responseEnd": 1320.699999999988, + "transferSize": 0, + "encodedBodySize": 0, + "decodedBodySize": 0, + "serverTiming": [], + "workerTiming": [] + } + ], + "connection": { + "onchange": None, + "effectiveType": "4g", + "rtt": 150, + "downlink": 1.6, + "saveData": False, + "downlinkMax": None, + "type": "unknown", + "ontypechange": None } - ] - } - # Create a session - mySession = requests.session() - - # Collect some Cookies - - url = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect%3A%2F%2Flogin&client_id={}&response_type=code&state=j1A8L2zQMbolEja6yqbj4w&nonce=LtRKgCy_l1abdbKPuf5vhA&scope=openid%20profile%20email%20offline_access%20https%3A%2F%2Fprodindego.onmicrosoft.com%2Findego-mobile-api%2FIndego.Mower.User&code_challenge={}&code_challenge_method=S256'.format(myClientID,code_challenge) + } - myHeader = {'accept' : 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', - 'accept-encoding' : 'gzip, deflate, br', - 'accept-language' : 'en-US', - 'connection' : 'keep-alive', - 'host' : 'prodindego.b2clogin.com', - 'user-agent' : 'Mozilla/5.0 (Linux; Android 11; sdk_gphone_x86_arm) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36' - } - mySession.headers = myHeader + myReqPayload = { + "pageViewId":'', + "pageId":"CombinedSigninAndSignup", + "trace":[ + { + "ac":"T005", + "acST":1678187316, + "acD":7 + }, + { + "ac":"T021 - URL:https://swsasharedprodb2c.blob.core.windows.net/b2c-templates/bosch/unified.html", + "acST":1678187316, + "acD":119 + }, + { + "ac":"T019", + "acST":1678187317, + "acD":44 + }, + { + "ac":"T004", + "acST":1678187317, + "acD":19 + }, + { + "ac":"T003", + "acST":1678187317, + "acD":5 + }, + { + "ac":"T035", + "acST":1678187317, + "acD":0 + }, + { + "ac":"T030Online", + "acST":1678187317, + "acD":0 + }, + { + "ac":"T002", + "acST":1678187328, + "acD":0 + } + ] + } + # Create a session + mySession = requests.session() - response = mySession.get(url, allow_redirects=True ) - self._log_communication('GET ', url, response.status_code) - - myText= response.content.decode() - myText1 = myText[myText.find('"csrf"')+8:myText.find('"csrf"')+300] - myCsrf = (myText1[:myText1.find(',')-1]) + # Collect some Cookies - myText1 = myText[myText.find('nonce'):myText.find('nonce')+40] - myNonce = myText1.split('"')[1] + url = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect%3A%2F%2Flogin&client_id={}&response_type=code&state=j1A8L2zQMbolEja6yqbj4w&nonce={}&scope=openid%20profile%20email%20offline_access%20https%3A%2F%2Fprodindego.onmicrosoft.com%2Findego-mobile-api%2FIndego.Mower.User&code_challenge={}&code_challenge_method=S256'.format(myClientID,nonce,code_challenge) + loginReferer = url - myText1 = myText[myText.find('pageViewId'):myText.find('pageViewId')+60] - myPageViewID = myText1.split('"')[2] + myHeader = {'accept' : 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'accept-encoding' : 'gzip, deflate, br', + 'accept-language' : 'en-US', + 'connection' : 'keep-alive', + 'host' : 'prodindego.b2clogin.com', + 'user-agent' : 'Mozilla/5.0 (Linux; Android 11; sdk_gphone_x86_arm) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36' + } + mySession.headers = myHeader - myReqPayload['pageViewId']=myPageViewID - - mySession.headers['x-csrf-token'] = myCsrf - mySession.headers['referer'] = url - mySession.headers['origin'] = 'https://prodindego.b2clogin.com' - mySession.headers['host'] = 'prodindego.b2clogin.com' - mySession.headers['x-requested-with'] = 'XMLHttpRequest' - mySession.headers['content-length'] = str(len(json.dumps(myPerfPayload))) - mySession.headers['content-type'] = 'application/json; charset=UTF-8' - mySession.headers['accept-language'] = 'en-US,en;q=0.9' - - - myState = mySession.cookies['x-ms-cpim-trans'] - myCookie = json.loads(base64.b64decode(myState).decode()) - myNewState = '{"TID":"'+myCookie['C_ID']+'"}' - myNewState = base64.b64encode(myNewState.encode()).decode()[:-2] - #'{"TID":"8912c0e6-defb-4d58-858b-27d1cfbbe8f5"}' - #eyJUSUQiOiI4OTEyYzBlNi1kZWZiLTRkNTgtODU4Yi0yN2QxY2ZiYmU4ZjUifQ - - - myUrl = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/B2C_1A_signup_signin/client/perftrace?tx=StateProperties={}&p=B2C_1A_signup_signin'.format(myNewState) - response=mySession.post(myUrl,data=json.dumps(myPerfPayload)) - self._log_communication('GET ', myUrl, response.status_code) - - - myUrl = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/B2C_1A_signup_signin/api/CombinedSigninAndSignup/unified' - mySession.headers['accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' - mySession.headers['accept-encoding'] = 'gzip, deflate, br' - mySession.headers['upgrade-insecure-requests'] = '1' - mySession.headers['sec-fetch-mode'] = 'navigate' - mySession.headers['sec-fetch-dest'] = 'document' - mySession.headers['sec-fetch-user'] = '?1' - mySession.headers['sec-fetch-site'] = 'same-origin' - - del mySession.headers['content-length'] - del mySession.headers['content-type'] - del mySession.headers['x-requested-with'] - del mySession.headers['x-csrf-token'] - del mySession.headers['origin'] - - myParams = { - 'claimsexchange': 'BoschIDExchange', - 'csrf_token': myCsrf, - 'tx': 'StateProperties=' + myNewState, - 'p': 'B2C_1A_signup_signin', - 'diags': myReqPayload - } - # Get the redirect-URI - response = mySession.get(myUrl,allow_redirects=False,params=myParams) - self._log_communication('GET ', myUrl, response.status_code) - try: - if (response.status_code == 302): - myText = response.content.decode() - myText1 = myText[myText.find('href') + 6:] - myNewUrl = myText1.split('"')[0].replace('&','&') - else: + response = mySession.get(url, allow_redirects=True ) + self._log_communication('GET ', url, response.status_code) + myText= response.content.decode() + myText1 = myText[myText.find('"csrf"')+8:myText.find('"csrf"')+300] + myCsrf = (myText1[:myText1.find(',')-1]) + + myText1 = myText[myText.find('nonce'):myText.find('nonce')+40] + myNonce = myText1.split('"')[1] + + myText1 = myText[myText.find('pageViewId'):myText.find('pageViewId')+60] + myPageViewID = myText1.split('"')[2] + + myReqPayload['pageViewId']=myPageViewID + + mySession.headers['x-csrf-token'] = myCsrf + mySession.headers['referer'] = url + mySession.headers['origin'] = 'https://prodindego.b2clogin.com' + mySession.headers['host'] = 'prodindego.b2clogin.com' + mySession.headers['x-requested-with'] = 'XMLHttpRequest' + mySession.headers['content-length'] = str(len(json.dumps(myPerfPayload))) + mySession.headers['content-type'] = 'application/json; charset=UTF-8' + mySession.headers['accept-language'] = 'en-US,en;q=0.9' + + + myState = mySession.cookies['x-ms-cpim-trans'] + myCookie = json.loads(base64.b64decode(myState).decode()) + myNewState = '{"TID":"'+myCookie['C_ID']+'"}' + myNewState = base64.b64encode(myNewState.encode()).decode()[:-2] + #'{"TID":"8912c0e6-defb-4d58-858b-27d1cfbbe8f5"}' + #eyJUSUQiOiI4OTEyYzBlNi1kZWZiLTRkNTgtODU4Yi0yN2QxY2ZiYmU4ZjUifQ + + + myUrl = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/B2C_1A_signup_signin/client/perftrace?tx=StateProperties={}&p=B2C_1A_signup_signin'.format(myNewState) + CollectingCookie = {} + for c in mySession.cookies: + CollectingCookie[c.name] = c.value + + + response=mySession.post(myUrl,data=json.dumps(myPerfPayload),cookies=CollectingCookie) + self._log_communication('POST ', myUrl, response.status_code) + + myUrl = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/B2C_1A_signup_signin/api/CombinedSigninAndSignup/unified' + mySession.headers['accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' + mySession.headers['accept-encoding'] = 'gzip, deflate, br' + mySession.headers['upgrade-insecure-requests'] = '1' + mySession.headers['sec-fetch-mode'] = 'navigate' + mySession.headers['sec-fetch-dest'] = 'document' + mySession.headers['sec-fetch-user'] = '?1' + mySession.headers['sec-fetch-site'] = 'same-origin' + + + del mySession.headers['content-length'] + del mySession.headers['content-type'] + del mySession.headers['x-requested-with'] + del mySession.headers['x-csrf-token'] + del mySession.headers['origin'] + + myParams = { + 'claimsexchange': 'BoschIDExchange', + 'csrf_token': myCsrf, + 'tx': 'StateProperties=' + myNewState, + 'p': 'B2C_1A_signup_signin', + 'diags': myReqPayload + } + # Get the redirect-URI + + response = mySession.get(myUrl,allow_redirects=False,params=myParams) + self._log_communication('GET ', myUrl, response.status_code) + try: + if (response.status_code == 302): + myText = response.content.decode() + myText1 = myText[myText.find('href') + 6:] + myNewUrl = myText1.split('"')[0].replace('&','&') + else: + pass + except: pass - except: - pass - mySession.headers['sec-fetch-site'] = 'cross-site' - mySession.headers['host'] = 'identity.bosch.com' + mySession.headers['sec-fetch-site'] = 'cross-site' + mySession.headers['host'] = 'identity.bosch.com' - # Get the CIAMIDS - response = mySession.get(myNewUrl,allow_redirects=True) - self._log_communication('GET ', myNewUrl, response.status_code) - try: - if (response.history[0].status_code != 302): + # Get the CIAMIDS + response = mySession.get(myNewUrl,allow_redirects=True) + self._log_communication('GET ', myNewUrl, response.status_code) + try: + if (response.history[0].status_code != 302): + pass + else: + myNewUrl = response.history[0].headers['location'] + except: pass - else: - myNewUrl = response.history[0].headers['location'] - except: - pass - - # Signin to Session - response = mySession.get(myNewUrl,allow_redirects=False) - self._log_communication('GET ', myNewUrl, response.status_code) - - # Authorize -IDS - myNewUrl = response.headers['location'] - mySession.headers['host'] = 'identity-myprofile.bosch.com' - mySession.headers['upgrade-insecure-requests']='1' - cookie_obj = requests.cookies.create_cookie(domain="identity-myprofile.bosch.com",name=".AspNetCore.Identity.Application",value="CfDJ8BbTLtgL3GFMvpWDXN913TQqlMWfpnzYNGGcX0qV3_e1mcxyuYndGzcNXVwoAHCyvY3Ad_1bkYnLsg-J56IdLUNQVMguFnS_KWkPbzib4u6SQtdCZfbiIPV_ZUh4xK-Pd-LgJ61Fi4ljxbb4CewKJRAaDyOhS7KPUu68EVdzte3mEYGm2z8PeSvViW6cGgQeIIOcJ1G3f7XG_s2synfm4o6MDA49a1WnkBIk1kXBodq-vKYXZNMLHOtNGVNE2aZ_k5b9E4mGQVeuncw6SupEku9dCXgO0tRRFK0qUX-41JVrgQdz5v4c_4NB--i1U1b7LUmoZrTtkv0a5KcGPTGz9cZqV5D_Ki4p5uoQxZCmDBPbyecSe6xF3m4yGpEC6hTfrOEJR4LdX6mnppjnXMSc1Y9Pr0Lui3FGeBGuK8GyT4QXJ-pnFrLyF8dh6g2ovkeRvI8MlS5DLSLy_d0s2nOgUxVQPxDsVCxtIMJhE14tSUnC9oRDB_6YUxOqMTEJ_dFacHt-s4iLD2ClBLtA6MsDQcF5pYe4ZOt9zLMuLcoO1NqD3Ca0r00Y0qdkGFGvckp5Xqf7QndkcZxKMPE3GtfH8o6uMsFd7hs1xstxBlT2pgrp0fjjk5R8ugOzJDv-BXarCbjXTzLJtAMVYO4dzorJ7xnXAZDK4IczfXIgxZliwOnTCBvwGIx5CHZfnkYlfhS1PbOE0bwR-sqvJXCS8Jmh6BjmSPHcoKxWxJbLa_wok5HsYmOJgQhVE49WgwuBV88sFvoxpnK_pp1IRR0jFfnV4stT905lkd8hNj5D8o3aZ35sHZDuNPYEXFNUPDORoFnfHkNAP33r126a00n-fLLjaBhFa7W5PnPDaD-M-luVP7nIL-c2tlVon_XRZRC5KMzO4FuOqCeCFwsh3jTtpJk5_iUS4EpHvHT5ldZtRVShC2uzZQ63N_LWl5KZwVlWXPCaLECCZwsGfaAJz0HKDlC-vgXuWL7odJKInmIsi4BJeM9xe280pPDwD6FNUhSOAM2GZgCAW2jilScn5hA2pS1HsLD9yLV0-80Rk9UR9RmRt7USsIOf_7qFMnijAV3MZq9wNKt7ZTBDCI40dxQ1WCYSUV0") - mySession.cookies.set_cookie(cookie_obj) - response = mySession.get(myNewUrl,allow_redirects=False) - self._log_communication('GET ', myNewUrl, response.status_code) - - # Get the login page with redirect URI - returnUrl = myNewUrl - myNewUrl='https://identity-myprofile.bosch.com/ids/login?ReturnUrl='+returnUrl - response=mySession.get(myNewUrl,allow_redirects=True) - self._log_communication('GET', myNewUrl, response.status_code) - myText = response.content.decode() - # find all the needed values - RequestVerificationToken = myText[myText.find('__RequestVerificationToken'):myText.find('__RequestVerificationToken')+300].split('"')[4] - postData = { - 'meta-information' : '', - 'uEmail' : self.user, - 'uPassword' : self.password, - 'ReturnUrl' : returnUrl[36:58]+'/callback'+returnUrl[58:], - '__RequestVerificationToken' : RequestVerificationToken - } - mySession.headers['content-type'] = 'application/x-www-form-urlencoded' - mySession.headers['sec-fetch-sites'] = 'same-origin' - mySession.headers['origin'] = '' - response=mySession.post(myNewUrl,data=postData,allow_redirects=True) - self._log_communication('POST ', myNewUrl, response.status_code) - - ######################################### - mySession.headers['pragma'] = 'no-cache' - mySession.headers['request-context'] = response.history[0].headers['request-context'] - mySession.headers['host'] = 'identity.bosch.com' - myNewUrl = response.history[1].headers['location'] - - # Collect next Cookie - response = mySession.get(myNewUrl,allow_redirects=False) - self._log_communication('GET ', myNewUrl, response.status_code) - - #Get Location for autorization - myNewUrl = 'https://identity.bosch.com/callback' - response=mySession.get(myNewUrl,allow_redirects=False) - self._log_communication('GET', myNewUrl, response.status_code) - myNewUrl = response.headers['location'] - - #Get Authorize-Informations - response = mySession.get(myNewUrl,allow_redirects=False) - self._log_communication('GET ', myNewUrl, response.status_code) - - # Get the post-Fields - myText= response.content.decode() - myCode = myText[myText.find('"code"')+14:myText.find('"code"')+300].split('"')[0] - mySessionState = myText[myText.find('"session_state"')+23:myText.find('"session_state"')+300].split('"')[0] - myState = myText[myText.find('"state"')+15:myText.find('"state"')+300].split('"')[0] - - request_body = {"code" : myCode, "state" : myState, "session_state=" : mySessionState } - - mySession.headers['host'] = 'prodindego.b2clogin.com' - mySession.headers['origin'] = 'https://identity.bosch.com' - mySession.headers['content-type'] = 'application/x-www-form-urlencoded' - mySession.headers['cache-control'] = 'max-age=0' - - del mySession.headers['pragma'] - del mySession.headers['request-context'] - - myNewUrl='https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/oauth2/authresp' - response = mySession.post(myNewUrl,data=request_body,allow_redirects=False) - self._log_communication('POST ', myNewUrl, response.status_code) - myNewUrl = response.headers['location'] - - myFinalCode = myNewUrl.split("code")[1].split("=")[1] - - # Get the new Login-Page - url = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect%3A%2F%2Flogin&client_id={}&response_type=code&state=j1A8L2zQMbolEja6yqbj4w&nonce=LtRKgCy_l1abdbKPuf5vhA&scope=openid%20profile%20email%20offline_access%20https%3A%2F%2Fprodindego.onmicrosoft.com%2Findego-mobile-api%2FIndego.Mower.User&code_challenge={}&code_challenge_method=S256'.format(myClientID,code_challenge) - mySession.headers['host'] = 'prodindego.b2clogin.com' - del mySession.headers['content-type'] - del mySession.headers['origin'] - del mySession.headers['referer'] - response = mySession.get(url,allow_redirects=False) - self._log_communication('GET ', url, response.status_code) - - # Now Post for a token - mySession.close() - request_body = { - 'code' : myFinalCode, - 'grant_type' : 'authorization_code', - 'redirect_uri' : 'com.bosch.indegoconnect://login', - 'code_verifier' : code_verifier, - 'client_id' : myClientID + # Signin to Session + response = mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + + # Authorize -IDS + myNewUrl = response.headers['location'] + mySession.headers['host'] = 'identity-myprofile.bosch.com' + mySession.headers['upgrade-insecure-requests']='1' + + response = mySession.get(myNewUrl,allow_redirects=False) + myNewUrl=response.headers['location'] + + postConfirmUrl = myNewUrl[myNewUrl.find('postConfirmReturnUrl'):myNewUrl.find('postConfirmReturnUrl')+300].split('"') + # Get the login page with redirect URI + #returnUrl = myNewUrl + #myNewUrl='https://identity-myprofile.bosch.com/ids/login?ReturnUrl='+returnUrl + + step2_AuthorizeUrl = myNewUrl + response=mySession.get(myNewUrl,allow_redirects=True) + self._log_communication('GET ', myNewUrl, response.status_code) + myText = response.content.decode() + # find all the needed values + RequestVerificationToken = myText[myText.find('__RequestVerificationToken'):myText.find('__RequestVerificationToken')+300].split('"')[4] + contentReturnUrl = myText[myText.find('ReturnUrl'):myText.find('ReturnUrl')+1600].split('"')[4] + ReturnUrl =myText[myText.find('ReturnUrl'):myText.find('ReturnUrl')+300].split('"')[4] + + postConfirmUrl = myText[myText.find('postConfirmReturnUrl'):myText.find('postConfirmReturnUrl')+700].split('"') + + myNewUrl='https://identity-myprofile.bosch.com/ids/api/v1/clients/'+myText[myText.find('ciamids_'):myText.find('ciamids_')+300].split('%2')[0] + response=mySession.get(myNewUrl,allow_redirects=True) + self._log_communication('GET ', myNewUrl, response.status_code) + + myNewUrl = step2_AuthorizeUrl+'&skid=true' + mySession.headers['sec-fetch-site']='same-origin' + mySession.headers['accept']='text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' + del (mySession.headers['referer']) + # https://identity-myprofile.bosch.com + # /ids/login?ReturnUrl=%2Fids%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dcentralids_65EC204B-85B2-4EC3-8BB7-F4B0F69D77D7%26redirect_uri%3Dhttps%253A%252F%252Fidentity.bosch.com%26response_type%3Dcode%26scope%3Dopenid%2520profile%2520email%26state%3DOpenIdConnect.AuthenticationProperties%253DZIkEldcO9j64ZsZ8lxkOF43KmLm9E-R7KiQ6vyWOHRY5coi-sQOCNbtzVfTmM30G2dQ8taj9dupmlMsdfl_aeQfBTLbXNPCoPMduVcoXcUVDx-G2Wo1BhyJmZZryQWMKBGVS5akW3441ocWSmzZ3sseK4ysrm14GxCYIjaXQLw-5-jqSp5xQ3fTbCIIuiEI0zql0bnoAQW2ElbUfFxCZGg2BPRJeIBGddPQOJ_TVR0fZ_Rb2Ex5CJorqDK-GzAq_eKEcqhLwSw3jLLJeyXqHiP8lVwo%26nonce%3D638175139721725147.NTYxMTVjOTEtZGQ2MC00NWFlLWFlMzgtZGRiNWVjZGNjZTNjMTk1ODMwMGQtNTY4OS00MDY5LThiYWItZDRjMTNkZmEzZTEy%26code_challenge%3DVZhREJ7Xv0gvQw6ehTBc55P9Lh3qWX7CiW7wTYYxqY0%26code_challenge_method%3DS256%26postConfirmReturnUrl%3Dhttps%253A%252F%252Fidentity.bosch.com%252Fconnect%252Fauthorize%253Fclient_id%253Dciamids_12E7F9D5-613D-444A-ACD3-838E4D974396%2526redirect_uri%253Dhttps%25253A%25252F%25252Fprodindego.b2clogin.com%25252Fprodindego.onmicrosoft.com%25252Foauth2%25252Fauthresp%2526response_type%253Dcode%2526scope%253Dopenid%252520profile%252520email%2526response_mode%253Dform_post%2526nonce%253DTRg%25252FDjkgw7qNuS2Rh3OslA%25253D%25253D%2526state%253DStateProperties%25253DeyJTSUQiOiJ4LW1zLWNwaW0tcmM6MjU2YzJjOWYtMzlkNC00Y2E2LWFlYTctMTYwZmE4ZTY1ZWRhIiwiVElEIjoiZTgxZjU1MWUtMmM4MC00YmNjLWI4ODgtYjU2NGJlMmEwYzllIiwiVE9JRCI6ImI4MTEzNjgxLWFlZjQtNDc0Yi05YmEyLTI1Mjk0Y2FhNDhmYyJ9%26x-client-SKU%3DID_NET461%26x-client-ver%3D6.7.1.0&skid=true + + response=mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + myNewUrl = response.headers['location'] + + # Now go for the single-key-id + # Get Single-Key-Site + # 1. Step + # + # auth/connect/authorize?client_id=7ca4e64b-faf6-ce9e-937d-6639339b4dac&redirect_uri=https%3A%2F%2Fidentity-myprofile.bosch.com%2Fids%2Fsignin-oidc&response_type=code&scope=openid%20profile%20email&code_challenge=5U4qTGs6v14xAZWvC3lENuVSzuvWLJ0IodizL75YWzk&code_challenge_method=S256&nonce=638175138999064799.OTMzYTExZGMtOGVhZS00MTQ5LTk2MmYtMTA0Mzg4YmJmYmVhOWYyZjEwZDYtZDE3Ny00OTczLTk1MDEtN2FmMjVmOTZiZmJm&state=CfDJ8BbTLtgL3GFMvpWDXN913TRMZYhIvGAQNZTmV8MG88I2iNRhqMCEorJUpmP8ShwrAEBHAAVfh7FgjR4gVnk3eQVuq_P-BvSRzmMKejfb_qxh7fq_Nhp8ULWZ9lU1LZzNEj140CnHaTaLY7LwsP5rXBy-JrdnDiYpPJOYMVswdn6BDZI_EvLnHqd4JJZ0P5Itay4pC0wyfKv2plk3_EyoOMteqnUFvGvfKUeevbbUScXXLwfdNgWjej3nP3BkCW5HDu3PDAz2g4jsC8l5eDZIIcYxpm3jXOcNJC_8B_2JwY9QTjeHDyfV3JNhPUruDTOCHDI4MWuh79pV5Eo-mHrkumSHfQRuycpQS7H6H5UT59io4D9B2xJfPrl-7tBU_5toC1nah0nUkfyyxirPzI6vBTXuJPiHdXC1mjj3wDX2UbFJEFhuvHuOsAzdICLtxS1rySeRcKcFD8nFGnSvIuVodgJSR9PRpXvzmS3cgaS0zae5FYN5LsDO7tTtTPTLadfaqQd11LjNqT7EZTnvT1SpW0HGiBBwPxzr8vSZGQG-xelop7scHH8SYGL4SrUn-nxvHbBYGDIwPrAoMYsIYfzcjPLZ4_VSPFPxVZcAK-8_i_LqKbUhm1ECJlfgOn5hOZhQGTR5uqXZTBJTSIrbHcdc0XMSXZSIi56qBWXiEHwYLAOtiEbNUVjMckyUI-HnrBmfZMhpiLndgLXHhLDr7lNIsBjll_1QOhZxFukftPVwFXIrgKPXWvUz4zky9mwFk9mwQvD2Ip77WvZz5MhJrfCNaPlASLelSdJlVQVRY3-qBdg7CzxFyJs_HOZX37oXsqH02lwda5uAHU2GAtNLfkmj_WGE05qB04gLfn9Y6rf_cXix4oIltbQqf57VUt7xdBKplcQUqeGqoDOg5eHLiz_-9iHu7GHRmqt3SDVxBrZlvL9KxwHAAuUpDJ97oRD51KdZlFFYjTNDjrHHrajshD6yRqFx2a4mGsd_pI_wnS12d9oZs8ILn3Lhdz9ATpNADGoTjbf0dRG8L-hMEx1DBeVID1GztCT-WIbl57xK-2NfbGjQ3MGk5W4vNxwGcxt3L6eRgrAIgAIGlTLHVJc-nQ4RSabzOB-kfUx-mTCHJYMkawqGIrJKkfozj-8aNYoE-wXUVFB63D-xVS25r5V0ttUGehjc4eZjN9JQA6U-ZZXe4UNv5hW8XVCYd-IT83JV340pMqERBjRNYAOPUn3LrDwXwFSKyYecgXMoyZ2d7wEZ-zuqHNCnlAKkLpsR5fJuybDPYNDDdFFHz-du-G2Aq38EUSBPjoUZVRjIUHhQfOUOEicg29ReBOueI-I61pkJKdgfiI7Zezy7Uit71CiZ1kNDjLWC0JkvpiUbXAv_TUySqk62tNkDL2T8E7gj1aT250Pxg7gSmmpBxRWv_0ZltyXwRTR564egzv0BDe4mhIOX5sNGL1HjwBJidNrZ0Q2jL6qr1a2W6DFIyuQ68eZmFAiq4WDkdv928fWMedndQHjgw6t1gBnG6l-J_JINqYaw0vnDUsyWKSzErcf5LN-4_o9RjwcJ3A0iui6PpUyYpQlRlwhobOlCa7V4_4sNQXH5-dlD6lhvXEtHHdBjb9xB9MNIwAJCMkoNU3q3ln10yeFBh_W6Iy25bPthFOeIONFWhC_FslnJvAzlX_kLCieFrGpOmkgE5V9FN-I9fxDX18a3JgdG-qt3YZzgcjTwwiM7YtgRHU4Ikmo7TqaOMfdJjz-Y3FPoaSOKUv6_eVfoY22lNyOMvU-SGLec_7MfpOR0YD2Cvz9Ibo6uh0umjrRHQKEIjzeR0yBjdl68BkoLu7qE0A_tVbcUK918fK2eExs7LONzVshb0_Ruwk5u1sqeft5AWxfYBZSSfnOwzHhS1-PZuWZSF9YVZXd72aKVgvWcyAEDOnsCifsXXzaboJAzs7K00gq9Tq-o3Mlfd44jugQ5-_maYnV9oY646o7ILJ6FD1A93X1mYkR6V7Ma6hxADmoYmD3-teZo_EVmSH6w_ElnYF98-TRyhXqI7tUK10c92kqB_biWHlH25cE-KvH3MaqBkGt1PSBr7kbpX9bxAKS9vP9gYEwCqJG6Ho796cItahFicQDqL2XdxUARA6eyeQUwwz216rIKsPypst-hCyqFWVcv7IS_hzVtSzJfxPLCkmzMmD84u6OL_SMO_GVfnb0X5C33ndFqRu6_aa-6QnuHpyyOBsuWUV_JZ5GED7PajJ-K16Py_23vRp4gKXpfXCOH8E3wESB2aSCXWC5It7tgqQBpdxiavH6CcvsfN-JrBRbHgw&x-client-SKU=ID_NETSTANDARD2_0&x-client-ver=5.6.0.0' + # https://singlekey-id.com + step += 1 + del (mySession.headers['host']) + mySession.headers['user-agent'] = 'Mozilla/5.0 (Linux; Android 11; sdk_gphone_x86_arm) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36' + mySession.headers['sec-fetch-site']='cross-site' + response=mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 2. Step + # https://singlekey-id.com + # auth/log-in?ReturnUrl=%2Fauth%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3D7ca4e64b-faf6-ce9e-937d-6639339b4dac%26redirect_uri%3Dhttps%253A%252F%252Fidentity-myprofile.bosch.com%252Fids%252Fsignin-oidc%26response_type%3Dcode%26scope%3Dopenid%2520profile%2520email%26code_challenge%3D_H7Bn1EBzLmdvYxRd-ZU9moDgCCOeLhuZlr2oWbTr8Y%26code_challenge_method%3DS256%26nonce%3D638175138466544692.Y2MwNjJlMDEtMjYzZS00MjZlLThhYTQtMTg0MmFjZGMwMzgzMmNmMTI0MGUtYWEyNC00OWExLWJlNTMtMTgzZDFmMDg3NTE5%26state%3DCfDJ8BbTLtgL3GFMvpWDXN913TTi4lyCR0HNQl1e_IHHsZzeJpmbm3hvFYhV6JhmVAlez_YwxFKyT18rCdVOrh8ncg6h6Wi3zCKgovjE7Jn7k9ZuZoWDC7XldFX_3Z2IzwG8WdD4V7ZVlwFChaohZ3fMDYvVPVhzbu7K-5VdMJdSGvyD0J5qaoSL0x-W76jQz15WAMP0npoq4Eyl1rjCVLTunSiQdt1mDJQE_3W1BFj41iW-1nfNeU5Xy8du_AnxyJ-UtWAAAeIr2lwaPKdyr1Mh_G3Q0QwJLxsJY-GhcJXxsBn95cUEDlHjBHRGbgn9T9ab87ppLvyMV3YI6PCu0RWs10IkIxdPFgAVZxb2PRggc8NOGQLnKIOr_2pHKmYw73JT2afa8cPc1CIobT-OS9Xt5_qfKgL6a2dl0RGc29tIPeHqlF-tolY3LSjyEEbOYJ1rLIO9MnR-HHRgq1JpmOPZe5DAl2wRmUDw16GdqQw71NRA0FmExtfLV6vKDrYJfs6IrJEaiC2dsU1mK8WVrTippBdINKhOmTgfvW0o8NiDnHmaKMg35niVI9SKgfEDlccj9EZYw9BOy9pdsPqkQzpAFpCP_BYHHhSmu-Pcj5KAqhY3wm2iRTuMuTLHWt0PP54c9l1kQ2JEoCYt9hHYLl4_D0szQVBUpJLYZgDrWtoLLRbEEPHLnO45pc6gtHyM8j15U0N4GWeiPXZJMnwA-d_dIU5meiWCc3HeA0o-F0IQ688U-GBNXg_QKyatAk6_pGdz7BAokNTiiA9IkJewfqINtjjPkujuNEMhSGNI65GSdk1PBgGAGVgY4o4G1JXUv8VMOQgR5ULtSMfepBf6z2ZIZu7lClg3YUdEGiEyfhU-Gz164D9JYbBtx0gCExg585eXsG2YZ1JNCWqu6eobXWjKBN0IejMmdw4N8uy4EvvWK9RBZ4TCZWXqPM-q0T-REUfm4HmvQvtL-WU8IO8FM1pZaHvePi6qcpivuZWnD1I7PWDbtNSai7uuEm4tMA7NpkBMlh2MNYG5XadLoFN0rrR4TJHHuq2r-AhiGXw8KlXsgff3yZdgCjn854gVjOnnvjwdTrzCaUSsSPiAZ5yFAVYUHKqIzGKXK6MQ7vBdJzfEPwFDNe4aIxEAHogn7hHLJn37b5WgrXZhUxha1zDQcEhdTtEr161soKFRJ1njLwWvXTSCbYUUfVN6BVCIAluWi0C2RLNYmSdUji3B4l_oJ5mq_gjmfdc37e3Xd1EWZtcgFRiO0yEldcscbwsltEzelF_lnK-VImZsr1Y1BD4VahyiykFZF15SAbrhVF6sAHf28mvO38dOqzdt7_B4K2VcOnmo9gM63BNw9rU_dMGLHXub4lJISoOMqeTFN_NmMUrkv41uKUc15e0e2fWS-faC4cZ6hRibrkkCdH5MyqHc8jtMDdIKp59LwXEKskaXrNUO9EL8XR_EfboHIER8dhEUG_ZuaY4FiO64ttgrRvPCC3uokeWhXsa7gx9HsONKqGjFAxCiMDba35uRpcWpunix601ex2le-6vuGpAQ-vqcMrUOvh45sAuHCIa8PLU08zx99lqrd9ERSodPfAGFBVyNh0Y0-y6d1vKYykOj4o7REphB3LnSotJruBVpCaLS1omcA88NtMkJoboKjDBPqzaIX3d9bZhnur3yoFcnjGKoismBmLgDtJY2PT3AAaOBbBjuM_KeqNC92gO_vUkXCa6MJ7JYmlnaRJVMtTFB3Ta4iSaGy7CGkz9KZZcaWPEpTEop-yb64cmkebDCWpcY9Tzouvsvg7CsX2ONM6ejOqDXo_ZpIQrEYVg7PMgZ7NxJhRlrjUDiyYQPofugwC_zaTA1p0oruIkPEmqUwgGSVaBZ8V9WBr2e_dregnUukkjKyft1secvXqaHdV1Ob684PRs_A3-zgKEHAjrkrglRljfsTzTDuDYC3uU3nxFtXFDG42SEcaDAHCPQWwr9j9KwZWgbbohX2dZly9ukvxO1WgPpfyvzKZOK7PpjsbghdIqTu9LX8gFbDNFuPoZ91X4jMG5SnL63YbdLgo68I8c_N_8bBRz1x233HRpJn0ltrxuQULalNjw7XQn9L0iNvZxDf6ZoFgOGNrtFe7PiZTRC6uksaWbFfhXAACmWrAIsi1_6KXLQfjXRhn7ZThfSVDdWWF0M9dv9AUHLoJDbt6eXYQqCTUkBJRkDXPDGzpkCpZT09FIOBFhRt5KKWkAkjldQ5l-6imVGir7LbIPoUVuMDN3C3y8oo2Vd3oWuT3-GhZXz6TkAwlTFeAGfe0g4XtW4wRrz8TYmHw%26x-client-SKU%3DID_NETSTANDARD2_0%26x-client-ver%3D5.6.0.0' + step += 1 + myNewUrl = response.headers['location'] + mySession.headers['Host']='singlekey-id.com' + response=mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 3. Step + # https://singlekey-id.com + # auth/log-in/?ReturnUrl=%2Fauth%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3D7ca4e64b-faf6-ce9e-937d-6639339b4dac%26redirect_uri%3Dhttps%253A%252F%252Fidentity-myprofile.bosch.com%252Fids%252Fsignin-oidc%26response_type%3Dcode%26scope%3Dopenid%2520profile%2520email%26code_challenge%3DTZdCd3C1FX0t08NtCr-rCmJxOrAQiPaNYytL-1Wi9TY%26code_challenge_method%3DS256%26nonce%3D638175136336911121.MGJiYTY0ZGQtN2E4My00MTJmLTg4NjgtNDc1NzAwYTExZDE3NmJhMzExMTEtYWIxZC00MjQ4LWFlNzItODI0MWYyMjg1Y2Zj%26state%3DCfDJ8BbTLtgL3GFMvpWDXN913TQFZ0MRP7aNSvpY-IwH5nqb4gdz1W3Ohx6L_5jrLoqk-iG8_Fb6txKNub_FyGnywp_hEdKhmOrG1QvFtB9Zok6PoKNloLcq1IwoxS2eFAS6qsvHBRR84Yq9D24B1d7klbITisHcQPNTYf-bsMv5w_CcMSrgzRHUFnqFPIHREMkunq1cqUfDCmOw_gFOCzZIAyp0GDRVMvJEmc7mBGjk8nhpJLtdIy2iPn2WXcueAfN7cF8jKtf_uOmSR233K2YM38GoIRgVb_ICWHJ_tqDWs7GIbMffl9D9Q5A2Aa_fopg4vZtk53G8P4jWoX47caPYSmyKwjzAPcP327lUR8tTVFduPEgcGwFrB_U41vtytxSGIrrSQAJU-GyvULXF-BwEJ_ScceQ4udNb_yFbUa8x1YMeVNqsyMvOItv46wPCW5OAycPEfzGfmcntKg4d1XVOkjr4hzc-3oehALLrCI4RFc_-3NtuUMkdZoV93QPA1pndlGajn5CkWu5_QCCa1aUR3unKd5g3OhiQ3ngxjEFbxiHcOrOt4PwaC6Nf6qvVixTXDLWvkL7MOjsSAjLZmoPtYHh4nWK6rnWKvh5J-kn-uJxZm0yKBnm59BQVemH-5XHAIzNGuXuo7R6nxUcsFWZnMCfyxu-d4ta7tUqI2LL-Sz3Fc6qw-3sVrXb9hyxITKAn1-KYNqhWokE3rzhU1itdgQyJIQd4b4eMPES-np8YNHld0hOKCeai4WD8rph054rW049Hfph1g4VVilswqfjEt7jPvIqIlEpMjAs38i6w8GREeJr2_lbrqPN0KF6Bj9jeZV4zy5LCDKItLc-ZRiBxon4dWHYhXy1UjjvPDpDepWtUhPF6FXHyYjN4D222itkTwxOuwLQc-AlWCD09A0bTi4FWOYy2IlwDhprTT15TLfb8QJ3upE8LvPNkRUiadvM0f0M214-Y_K0s53W6oUOvMtXnjqXmYRCoBW18HL1AOfyMto2aJtEB_83sE6Qf18Y2pBMZPMb2bkMURckCBhWwAVFxC0w30HaIVPfrXcRypgFBeh3GS54jrQTC3ropVvqQVUcZXNeZNCApV7M_eLsvgkUlVVeKB0-Dgce7SNaH8AcNtf-17c6WiY3T3FypjsNTzpBgobj7Ay0HL8B8FBighQ0wJxyARRyFRLHTubgVN4Y-tV2hFlc4o2eWzLeJyEFtE48KTpvz8ydXuhYoXAj6gPWoyt-VEdxYwE8OUcswQxWX7De4VyeUi8eOxHwdE3-T3blmDWLzsCuvhUSeL-ykXd0V-T6zMmSDonVL8taIt2xO14yM40X48xCp8d4slOXtZOFuVodMeV5otdZXZmeMVWgVPSUdcuCkCDP2KljEqhtOfCpDy5vDVgJKK9axabMpI-AbzINbz9vFId5wN6crBDjW0bwrQ68gCUhRcHtTtBoLC7y65onvzTLPTslASYTAIGQojvdxxOe5j_nB0iHqcnhbxUtp_vLv2UsrLVfI06MhXfJHO8Sx3LGgxhkXDMMorPnfe2gPLHB39SwZDinwewE2hQU0G-LCqUL5B3AG-lsT-i2FimJTqca6OqPkOo-QuVr2b72iskzxyOFxK6gQhNuvpmlj_47SVcRqK8HbLGU_rAYlmucAP9BbRBKnT0pcVmYpVxCt7tWnQ5uKywZRvUvWX9RVTICz7TssZCm4JnCp8_wRKMxrcJ7hFNb4h2qdjCUm4QgU16h-3L6E1j0UlRzf3w2gPiONPWt3vOGgyn-SGM1jXpLDzWfr-dUxeVlr1Z6we1fjaDo3dDZEJrj1fEeQFb9NxH6LlZmPLDHBXYex3YzO1OxUzaigPqmsMXIuh5STg78lB8k1m2cN96b8I0ohwL0eWbDvoTaLlLudfeo9RkGQ9cMkjTvQvQ4rQEfKVU1YnBM1NijW98yr9Fq8WRuoQL01_s8jnSI7htLo4u8VVpnDC1dSvErrcoM6ob-GBVstIeHdPJV36NHevlylkabKgoZKhl5tqpbVzjrKuyrc2IKdFiavPiTsSxojpFmL8fpqGZiK6XDEe6TWmrZ8Xy8QL6vDTbVtHRvW4-2WIgMdOqP20IzTQTDfUDs9FWrvo0z4JtJ_iC0ZTP3eEk5q1DGHNmzSTkAp4qq1pjgAkiU7hOrTNTFgLkBcy7wAk0eD16DzACp7mY4Z04exIHhPbou7p_904xcptBXT4XtjrS2qneEP8P5j51W0y3kCdEqiR73L0nBpLxj26NVXPDUYxLym1trfQrVyKMiFYIS8u7KiVIlWodukE7fJ1E7gQ_dfpA%26x-client-SKU%3DID_NETSTANDARD2_0%26x-client-ver%3D5.6.0.0 + step += 1 + myNewUrl = response.headers['location'] + postConfirmUrl = myNewUrl[myNewUrl.find('ReturnUrl')+10:].split('"')[0] + response=mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 4. Step - get XSRF-Token + #https://singlekey-id.com/static/roboto-latin-400-normal-b009a76ad6afe4ebd301e36f847a29be.woff2 + step += 1 + myNewUrl='https://singlekey-id.com/favicon.ico' + response=mySession.get(myNewUrl,allow_redirects=True) + self._log_communication('GET ', myNewUrl, response.status_code) + myXRSF_Token = response.history[0].cookies.get('XSRF-TOKEN') + + # 5. Step + step += 1 + mySession.headers['x-xsrf-token']=myXRSF_Token + + myNewUrl='https://singlekey-id.com/auth/api/v1/authentication/UserExists' + myJson = { "username": user } + + mySession.headers['origin']= 'https://singlekey-id.com' + mySession.headers['content-type']= 'application/json' + response=mySession.post(myNewUrl,json=myJson,allow_redirects=False) + self._log_communication('POST ', myNewUrl, response.status_code) + + + # 6. Step + step += 1 + postConfirmUrl = urllib.parse.unquote(postConfirmUrl) + myJson = { + "username": user, + "password": pwd, + "keepMeSignedIn": False, + "returnUrl": postConfirmUrl + } + RequestVerificationToken = mySession.cookies.get('X-CSRF-FORM-TOKEN') + mySession.cookies.set('XSRF-TOKEN',myXRSF_Token) + mySession.cookies.set('X-CSRF-FORM-TOKEN',mySession.cookies.get('X-CSRF-FORM-TOKEN')) + mySession.cookies.set('.AspNetCore.Antiforgery.085ONM3l57w',mySession.cookies.get('.AspNetCore.Antiforgery.085ONM3l57w')) + + + + mySession.headers['content-type']= 'application/json' + mySession.headers['accept'] = 'application/json, text/plain, */*' + mySession.headers['sec-fetch-site'] = 'same-origin' + mySession.headers['host'] = 'singlekey-id.com' + mySession.headers['origin'] = 'https://singlekey-id.com' + mySession.headers['sec-fetch-dest'] = 'empty' + mySession.headers['sec-fetch-mode'] = 'cors' + + del mySession.headers['upgrade-insecure-requests'] + del mySession.headers['sec-fetch-user'] + + mySession.headers['requestverificationtoken'] = RequestVerificationToken + + myNewUrl='https://singlekey-id.com/auth/api/v1/authentication/login' + response=mySession.post(myNewUrl,json=myJson,allow_redirects=True) + self._log_communication('POST ', myNewUrl, response.status_code) + + + # 7. Step + step += 1 + mySession.cookies.set('idsrv.session',mySession.cookies.get('idsrv.session')) + mySession.cookies.set('.AspNetCore.Identity.Application',mySession.cookies.get('.AspNetCore.Identity.Application')) + mySession.headers['accept']='text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' + mySession.headers['host']='singlekey-id.com' + mySession.headers['sec-fetch-dest']='document' + mySession.headers['sec-fetch-site']='same-origin' + mySession.headers['sec-fetch-user']='?1' + mySession.headers['upgrade-insecure-requests']='1' + mySession.headers['sec-fetch-mode']='navigate' + del(mySession.headers['origin']) + del(mySession.headers['requestverificationtoken']) + + myNewUrl = 'https://singlekey-id.com'+postConfirmUrl + response= mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ',myNewUrl, response.status_code) + + + # 8. Step + step += 1 + myNewUrl = response.headers['location'] + mySession.headers['host']='identity-myprofile.bosch.com' + response=mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 9. Step + step += 1 + myNewUrl = 'https://identity-myprofile.bosch.com'+response.headers['location'] + response=mySession.get(myNewUrl,allow_redirects=False) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 10. Step - Authorize + step += 1 + CollectingCookie = {} + CollectingCookie['idsrv.session'] = mySession.cookies.get_dict('identity-myprofile.bosch.com','/ids')['idsrv.session'] + CollectingCookie['.AspNetCore.Identity.Application'] = mySession.cookies.get_dict('identity-myprofile.bosch.com','/ids')['.AspNetCore.Identity.Application'] + myNewUrl = 'https://identity-myprofile.bosch.com'+response.headers['location'] + response=mySession.get(myNewUrl,allow_redirects=False,cookies=CollectingCookie) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 11. Step + step += 1 + del mySession.headers['x-xsrf-token'] + del mySession.headers['content-type'] + mySession.headers['host']='identity.bosch.com' + mySession.headers['sec-fetch-site']='same-origin' + CollectingCookie = {} + CollectingCookie['styleId'] = mySession.cookies.get_dict('identity.bosch.com','/')['styleId'] + myDict = mySession.cookies.get_dict('identity.bosch.com','/') + for c in myDict: + if ('SignInMessage' in c): + CollectingCookie[c] = myDict[c] + myNewUrl = response.headers['location'] + response=mySession.get(myNewUrl,allow_redirects=False,cookies=CollectingCookie) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 12. Step + step += 1 + CollectingCookie['idsrv.external'] = mySession.cookies.get_dict('identity.bosch.com','/')['idsrv.external'] + myNewUrl = 'https://identity.bosch.com'+ response.headers['location'] + response=mySession.get(myNewUrl,allow_redirects=False,cookies=CollectingCookie) + self._log_communication('GET ', myNewUrl, response.status_code) + + # 13. Step + step += 1 + CollectingCookie={} + CollectingCookie['idsrv'] = mySession.cookies.get_dict('identity.bosch.com','/')['idsrv'] + CollectingCookie['styleId'] = mySession.cookies.get_dict('identity.bosch.com','/')['styleId'] + CollectingCookie['idsvr.session'] = mySession.cookies.get_dict('identity.bosch.com','/')['idsvr.session'] + + myNewUrl = response.headers['location'] + response=mySession.get(myNewUrl,allow_redirects=False,cookies=CollectingCookie) + self._log_communication('GET ', myNewUrl, response.status_code) + myText= response.content.decode() + myCode = myText[myText.find('"code"')+14:myText.find('"code"')+300].split('"')[0] + mySessionState = myText[myText.find('"session_state"')+23:myText.find('"session_state"')+300].split('"')[0] + myState = myText[myText.find('"state"')+15:myText.find('"state"')+300].split('"')[0] + + # 14. Step - /csp/report + step += 1 + myReferer = myNewUrl # the last URL is the referer + CollectingCookie['idsvr.clients'] = mySession.cookies.get_dict('identity.bosch.com','/')['idsvr.clients'] + myNewUrl='https://identity.bosch.com/csp/report' + + myHeaders = { + 'accept' : '*/*', + 'accept-encoding':'gzip, deflate, br', + 'accept-language' : 'en-US,en;q=0.9', + 'connection':'keep-alive', + 'content-type' : 'application/csp-report', + 'origin' : 'https://identity.bosch.com', + 'referer' : myReferer, + 'sec-fetch-dest' : 'report', + 'sec-fetch-mode' : 'no-cors', + 'sec-fetch-site' : 'same-origin', + 'user-agent' : 'Mozilla/5.0 (Linux; Android 11; sdk_gphone_x86_arm) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36' } - url = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/token' - mySession = requests.session() - mySession.headers['accept'] = 'application/json' - mySession.headers['accept-encoding'] = 'gzip' - mySession.headers['connection'] = 'Keep-Alive' - mySession.headers['content-type'] = 'application/x-www-form-urlencoded' - mySession.headers['host'] = 'prodindego.b2clogin.com' - mySession.headers['user-agent'] = 'Dalvik/2.1.0 (Linux; U; Android 11; sdk_gphone_x86_arm Build/RSR1.201013.001)' - - response = mySession.post(url,data=request_body) - self._log_communication('POST ', url, response.status_code) - myJson = json.loads (response.content.decode()) - self._refresh_token = myJson['refresh_token'] - self._bearer = myJson['access_token'] - self.token_expires = myJson['expires_in'] - - - url='https://api.indego-cloud.iot.bosch-si.com/api/v1/alms' - myHeader = {'accept-encoding' : 'gzip', - 'authorization' : 'Bearer '+ myJson['access_token'], - 'connection' : 'Keep-Alive', - 'host' : 'api.indego-cloud.iot.bosch-si.com', - 'user-agent' : 'Indego-Connect_4.0.0.12253' - } - response = requests.get(url, headers=myHeader,allow_redirects=True ) - self._log_communication('GET ', url, response.status_code) - if (response.status_code == 200): + myPayload = {"csp-report":{"document-uri": myReferer ,"referrer":"","violated-directive":"script-src","effective-directive":"script-src","original-policy":"default-src 'self'; script-src 'self' ; style-src 'self' 'unsafe-inline' ; img-src *; report-uri https://identity.bosch.com/csp/report","disposition":"enforce","blocked-uri":"eval","line-number":174,"column-number":361,"source-file":"https://identity.bosch.com/assets/scripts.2.5.0.js","status-code":0,"script-sample":""}} + response=mySession.post(myNewUrl,allow_redirects=False,cookies=CollectingCookie) + self._log_communication('POST ', myNewUrl, response.status_code) + + # 15. Step + step += 1 + myHeaders = { + 'accept' : 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + 'cache-control' : 'max-age=0', + 'accept-encoding':'gzip, deflate, br', + 'accept-language' : 'en-US,en;q=0.9', + 'connection':'keep-alive', + 'content-type' : 'application/x-www-form-urlencoded', + 'host' : 'prodindego.b2clogin.com', + 'origin' : 'https://identity.bosch.com', + 'referer' : myReferer, + 'sec-fetch-dest' : 'document', + 'sec-fetch-mode' : 'navigate', + 'sec-fetch-site' : 'cross-site', + 'user-agent' : 'Mozilla/5.0 (Linux; Android 11; sdk_gphone_x86_arm) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36' + } + request_body = { + 'code' : myCode, + 'state' : myState, + 'session_state' : mySessionState + } + myNewUrl = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/oauth2/authresp' + response=mySession.post(myNewUrl,allow_redirects=False,data=request_body,headers=myHeaders) + self._log_communication('POST ', myNewUrl, response.status_code) + + + + # 16. Step + # go end get the Token + step += 1 + + myText= response.content.decode() + myFinalCode = myText[myText.find('code%3d')+7:myText.find('"code%3"')+1700].split('"')[0] + + request_body = { + 'code' : myFinalCode, + 'grant_type' : 'authorization_code', + 'redirect_uri' : 'com.bosch.indegoconnect://login', + 'code_verifier' : code_verifier, + 'client_id' : myClientID + } + + url = 'https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/token' + mySession = requests.session() + mySession.headers['accept'] = 'application/json' + mySession.headers['accept-encoding'] = 'gzip' + mySession.headers['connection'] = 'Keep-Alive' + mySession.headers['content-type'] = 'application/x-www-form-urlencoded' + mySession.headers['host'] = 'prodindego.b2clogin.com' + mySession.headers['user-agent'] = 'Dalvik/2.1.0 (Linux; U; Android 11; sdk_gphone_x86_arm Build/RSR1.201013.001)' + + response = mySession.post(url,data=request_body) + self._log_communication('POST ', url,response.status_code) myJson = json.loads (response.content.decode()) - self.alm_sn = myJson[0]['alm_sn'] - self.login_pending = False - self.last_login_timestamp = datetime.timestamp(datetime.now()) - self.expiration_timestamp = self.last_login_timestamp + self.token_expires - return True - else: - return False + _refresh_token = myJson['refresh_token'] + _access_token = myJson['access_token'] + _token_expires = myJson['expires_in'] + + + # 17. Step + # Check-Login + step += 1 + url='https://api.indego-cloud.iot.bosch-si.com/api/v1/alms' + myHeader = {'accept-encoding' : 'gzip', + 'authorization' : 'Bearer '+ _access_token, + 'connection' : 'Keep-Alive', + 'host' : 'api.indego-cloud.iot.bosch-si.com', + 'user-agent' : 'Indego-Connect_4.0.0.12253' + } + response = requests.get(url, headers=myHeader,allow_redirects=True ) + self._log_communication('GET ', url, response.status_code) + if (response.status_code == 200): + myJson = json.loads (response.content.decode()) + _alm_sn = myJson[0]['alm_sn'] + self.last_login_timestamp = datetime.timestamp(datetime.now()) + self.expiration_timestamp = self.last_login_timestamp + _token_expires + self._log_communication('LOGIN ', 'Login to Sinlge-Key-ID successful done ', 666) + return True,_access_token,_refresh_token,_token_expires,_alm_sn + else: + return False,'','',0,'' + + except err as Exception: + self._log_communication('LOGIN ', 'something went wrong during getting Sinlge-Key-ID Login on Step : {} - {}'.format(step,err), 999) + self.logger.warning('something went wrong during getting Sinlge-Key-ID Login on Step : {} - {}'.format(step,err)) + return False,'','',0,'' + @@ -1723,6 +1858,7 @@ def _get_active_calendar(self, myCal = None): return activeCal def _parse_uzsu_2_list(self, uzsu_dict=None): + #pydevd.settrace("192.168.178.37", port=5678) weekDays = {'MO' : "0" ,'TU' : "1" ,'WE' : "2" ,'TH' : "3",'FR' : "4",'SA' : "5" ,'SU' : "6" } myCal = {} @@ -1814,6 +1950,7 @@ def _parse_cal_2_list(self, myCal = None, type=None): 'End' : myEndTime1, 'Days' : str(myDay) } + #pydevd.settrace("192.168.178.37", port=5678) if 'Attr' in slots: if slots['Attr'] == "C": # manual Exclusion Time mycolour = '#DC143C' @@ -2222,6 +2359,7 @@ def alert(self): else: actAlerts = self._get_childitem('visu.alerts') + #pydevd.settrace("192.168.178.37", port=5678) for myAlert in alert_response: if not (myAlert['alert_id'] in actAlerts): # add new alert to dict @@ -2341,6 +2479,7 @@ def _check_state_triggers(self, myStatecode): def _check_alarm_triggers(self, myAlarm): counter = 1 + #pydevd.settrace("192.168.178.37", port=5678) while counter <=4: myItemName="trigger.alarm_trigger_" + str(counter) + ".alarm" myAlarmTrigger = self._get_childitem(myItemName) @@ -2352,6 +2491,7 @@ def _check_alarm_triggers(self, myAlarm): def _get_state(self): if (self._get_childitem("wartung.wintermodus") == True or self.logged_in == False): return + #pydevd.settrace("192.168.178.37", port=5678) if (self.position_detection): self.position_count += 1 @@ -2369,6 +2509,7 @@ def _get_state(self): else: error_code = 0 self._set_childitem('stateError',error_code) + #pydevd.settrace("192.168.178.37", port=5678) state_code = states['state'] try: if not str(state_code) in str(self.states) and len(self.states) > 0: @@ -2543,6 +2684,7 @@ def _load_map(self): self.logger.debug('You have a new MAP') self._set_childitem('mapSvgCacheDate',self.shtime.now()) self._set_childitem('webif.garden_map', garden.decode("utf-8")) + #pydevd.settrace("192.168.178.37", port=5678) self._parse_map() def _parse_map(self): @@ -2557,6 +2699,7 @@ def _parse_map(self): #======================================================================= myMap = myMap.replace(">",">\n") mapArray = myMap.split('\n') + #pydevd.settrace("192.168.178.37", port=5678) # till here new # Get the Mower-Position and extract it i= 0 @@ -2686,6 +2829,7 @@ def store_state_trigger_html(self, Trigger_State_Item = None,newState=None): @cherrypy.expose def store_alarm_trigger_html(self, Trigger_Alarm_Item = None,newAlarm=None): + #pydevd.settrace("192.168.178.37", port=5678) myItemSuffix=Trigger_Alarm_Item myItem="trigger." + myItemSuffix + ".alarm" self.plugin._set_childitem(myItem,newAlarm) @@ -2701,6 +2845,7 @@ def store_credentials_html(self, encoded='', pwd = '', user= '', store_2_config= result2send={} resultParams={} + #pydevd.settrace("192.168.178.37", port=5678) myCredentials = user+':'+pwd byte_credentials = base64.b64encode(myCredentials.encode('utf-8')) encoded = byte_credentials.decode("utf-8") @@ -2721,13 +2866,15 @@ def store_credentials_html(self, encoded='', pwd = '', user= '', store_2_config= for line in new_conf.splitlines(): myFile.write(line+'\r\n') myFile.close() + #pydevd.settrace("192.168.178.37", port=5678) txt_Result.append("stored new config to filesystem") self.plugin.user = user self.plugin.password = pwd - if self.plugin.logged_in: - self.plugin._delete_auth() - self.plugin._auth() - self.plugin.logged_in = self.plugin._check_auth() + # Here the login-procedure + self.plugin.login_pending = True + self.plugin.logged_in, self.plugin._bearer, self.plugin._refresh_token, self.plugin.token_expires,self.plugin.alm_sn = self.plugin._login_single_key_id(self.plugin.user, self.plugin.password) + self.plugin.login_pending = False + if self.plugin.logged_in: txt_Result.append("logged in succesfully") else: @@ -2736,7 +2883,7 @@ def store_credentials_html(self, encoded='', pwd = '', user= '', store_2_config= myLastLogin = datetime.fromtimestamp(float(self.plugin.last_login_timestamp)).strftime('%Y-%m-%d %H:%M:%S') resultParams['logged_in']= self.plugin.logged_in resultParams['timeStamp']= myLastLogin + " / " + myExperitation_Time - resultParams['SessionID']= self.plugin.context_id + resultParams['SessionID']= self.plugin._bearer self.plugin._set_childitem('visu.refresh',True) txt_Result.append("refresh of Items initiated") @@ -2755,11 +2902,13 @@ def get_proto_html(self, proto_Name= None): @cherrypy.expose def clear_proto_html(self, proto_Name= None): + #pydevd.settrace("192.168.178.37", port=5678) self.plugin._set_childitem(proto_Name,[]) return None @cherrypy.expose def set_location_html(self, longitude=None, latitude=None): + pydevd.settrace("192.168.178.37", port=5678) self.plugin._set_childitem('webif.location_longitude',float(longitude)) self.plugin._set_childitem('webif.location_latitude',float(latitude)) myLocation = {"latitude":str(latitude),"longitude":str(longitude),"timezone":"Europe/Berlin"} @@ -2845,6 +2994,7 @@ def index(self, reload=None): myLatitude = "" myText = "" try: + #pydevd.settrace("192.168.178.37", port=5678) myLongitude = self.plugin._get_childitem('webif.location_longitude') myLatitude = self.plugin._get_childitem('webif.location_latitude') myText = 'Location from Indego-Server' diff --git a/indego4shng/plugin.yaml b/indego4shng/plugin.yaml index 8b519f87e..24f4a6ed4 100755 --- a/indego4shng/plugin.yaml +++ b/indego4shng/plugin.yaml @@ -12,7 +12,7 @@ plugin: documentation: http://smarthomeng.de/user/plugins_doc/config/indego.html # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/966612-indego-connect - version: 4.0.0 # Plugin version + version: 4.0.1 # Plugin version sh_minversion: 1.6 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/indego4shng/requirements.txt b/indego4shng/requirements.txt new file mode 100755 index 000000000..dbb901f09 --- /dev/null +++ b/indego4shng/requirements.txt @@ -0,0 +1,2 @@ +requests +urllib3 >= 1.25.8 diff --git a/indego4shng/sv_widgets/indego2_widget.html b/indego4shng/sv_widgets/indego2_widget.html index 2a99d7c71..65042530e 100755 --- a/indego4shng/sv_widgets/indego2_widget.html +++ b/indego4shng/sv_widgets/indego2_widget.html @@ -9,8 +9,8 @@ * */ {% macro status(id, item) %} - {% import "basic.html" as basic %} - {% import "indego_widget.html" as indego %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/indego_widget.html" : "indego_widget.html" as indego %} /** # Get general Informations if files are existing ----------------------- */ diff --git a/indego4shng/sv_widgets/indego_widget.html b/indego4shng/sv_widgets/indego_widget.html index 7449b5898..6064f96a5 100755 --- a/indego4shng/sv_widgets/indego_widget.html +++ b/indego4shng/sv_widgets/indego_widget.html @@ -6,7 +6,6 @@ {% macro spinner(id, item,_backend ) %} -{% import "basic.html" as basic %} @@ -33,7 +32,6 @@ {% macro alerts(id, item,_backend ) %} -{% import "basic.html" as basic %}
{% endmacro %} @@ -76,7 +74,6 @@ */ {% macro symbol(id, items, pic, val, mode,text, ignore_text) %} - {% import "basic.html" as basic %}
{% if not text is empty %}{{ text }}

{% endif %} @@ -93,7 +90,6 @@ *********************************************/ {% macro smartmow_calendar(id, item,_backend ) %} -{% import "basic.html" as basic %}
{% endmacro %} @@ -105,7 +101,6 @@ *********************************************/ {% macro mow_calendar(id, item,_backend ) %} -{% import "basic.html" as basic %}
{% endmacro %} @@ -120,7 +115,6 @@ {% macro predictive_calendar(id, item,_backend ) %} -{% import "basic.html" as basic %}
{% endmacro %} @@ -158,6 +152,5 @@ *********************************************/ {% macro mode(id, item,_backend ) %} -{% import "basic.html" as basic %}
{% endmacro %} diff --git a/indego4shng/webif/static/img/garden.svg b/indego4shng/webif/static/img/garden.svg deleted file mode 120000 index 6b11a6546..000000000 --- a/indego4shng/webif/static/img/garden.svg +++ /dev/null @@ -1 +0,0 @@ -/var/www/html/smartVISU2.9/dropins/garden.svg \ No newline at end of file diff --git a/indego4shng/webif/templates/index.html b/indego4shng/webif/templates/index.html old mode 100755 new mode 100644 index 53758c38b..ae658df37 --- a/indego4shng/webif/templates/index.html +++ b/indego4shng/webif/templates/index.html @@ -140,7 +140,7 @@
{{ _('Plugin') }}     : {% if p.aliv - Session-ID + Token {{ p.context_id }} diff --git a/influxdb/README.md b/influxdb/README.md deleted file mode 100755 index 91f05d339..000000000 --- a/influxdb/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# influxdb - -## Logging to InfluxDB over UDP or HTTP - -Logging items to the time-series database [InfluxDB](https://www.influxdata.com/time-series-platform/) - -This started as a fork of the plugin `influxdata` with the following enhancements: -- proper naming -- specify a name for the measurement instead of falling back to the item's ID -- specify additional tags or fields globally (plugin.yaml) and/or on per-item basis - -The special smarthomeNG attributes `caller`, `source` and `dest` are always logged as tags. - -Only if a measurement name is specified, the item's ID is automatically logged as well (tag `item`) - if you don't specify a measurement-name, the name will fallback to the item's ID which makes the item-tag redundant - -## Proper Logging -Please read the [Key Concepts](https://docs.influxdata.com/influxdb/v1.8/concepts/key_concepts/) and [Schema Design](https://docs.influxdata.com/influxdb/v1.8/concepts/schema_and_data_layout/) - -Especially these: -- [Encode meta data in tags](https://docs.influxdata.com/influxdb/v1.8/concepts/schema_and_data_layout/#encode-meta-data-in-tags) -- [Avoid encoding data in measurement names](https://docs.influxdata.com/influxdb/v1.8/concepts/schema_and_data_layout/#avoid-encoding-data-in-measurement-names) -- [Avoid putting more than one piece of information in one tag](https://docs.influxdata.com/influxdb/v1.8/concepts/schema_and_data_layout/#avoid-putting-more-than-one-piece-of-information-in-one-tag) - -## Setup - -### /etc/influxdb/influxdb.conf - -If you use UDP, you have to explicitly enable the UDP endpoint in influxdb. The UDP endpoint cannot be auth-protected and is bound to a specific database - -``` -[[udp]] - enabled = true - bind-address = ":8089" - database = "smarthome" - # retention-policy = "" -``` - -If you want to use HTTP access no additional configuration is needed as HTTP access to the influxdb is enabled by default. - -### plugin.yaml - -you can setup global tags and fields (JSON encoded) - -```yaml -influxdb: - plugin_name: influxdb - # host: localhost - # udp_port: 8089 - # keyword: influxdb - # value_field: value - # write_http: True - # http_port: 8086 - tags: '{"key": "value", "foo": "bar"}' - fields: '{"key": "value", "foo": "bar"}' -``` - -### items.yaml - -logging into a measurement named `root.some_item`, default tags and tags/fields as specified in plugin.yaml - -```yaml -root: - - some_item: - influxdb: 'true' -``` - -if `keyword` in plugin.yaml is set to `sqlite` this can also be used as a drop-in replacement for sqlite. - -```yaml -root: - - some_item: - sqlite: 'true' -``` - -*recommended*: logging into the measurement `temp` with an additional tag `room` -and default tags (including `item: root.dining_temp`) and tags/fields as specified in plugin.yaml - -```yaml -root: - - dining_temp: - influxdb_name: temp - influxdb_tags: '{"room": "dining"}' -``` diff --git a/influxdb/__init__.py b/influxdb/__init__.py index 4b3169572..71849f43c 100755 --- a/influxdb/__init__.py +++ b/influxdb/__init__.py @@ -26,10 +26,11 @@ import requests class InfluxDB(SmartPlugin): - PLUGIN_VERSION = "1.0.2" + PLUGIN_VERSION = "1.0.3" ALLOW_MULTIINSTANCE = False def __init__(self, smarthome): + super().__init__() self.logger = logging.getLogger(__name__) self.logger.info('Init InfluxDB') diff --git a/influxdb/plugin.yaml b/influxdb/plugin.yaml index 23c81a2aa..d72cc624f 100755 --- a/influxdb/plugin.yaml +++ b/influxdb/plugin.yaml @@ -15,7 +15,7 @@ plugin: #documentation: https://www.smarthomeng.de/user/plugins/influxdb/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1498207-support-thread-f%C3%BCr-influxdb-plugin - version: 1.0.2 # Plugin version + version: 1.0.3 # Plugin version sh_minversion: 1.1 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/influxdb/user_doc.rst b/influxdb/user_doc.rst new file mode 100644 index 000000000..e065e3350 --- /dev/null +++ b/influxdb/user_doc.rst @@ -0,0 +1,181 @@ +.. index:: Plugins; influxdb +.. index:: influxdb + +======== +influxdb +======== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/influxdb` beschrieben. + +/etc/influxdb/influxdb.conf +--------------------------- + +Wenn UDP verwendet wird, muss der UDP-Endpunkt explizit in influxdb aktiviert werden. +Der UDP-Endpunkt ist nicht auth-geschützt und ist an eine bestimmte Datenbank gebunden. + +.. code-block:: yaml + + [[udp]] + enabled = true + bind-address = ":8089" + database = "smarthome" + # retention-policy = "" + +Wenn Sie den HTTP-Zugang verwenden möchten, ist keine zusätzliche Konfiguration erforderlich, da der +HTTP-Zugriff auf die influxdb standardmäßig aktiviert ist. + +plugin.yaml +----------- + +Es können globale Tags und Felder angegeben werden: + +.. code-block:: yaml + + influxdb: + plugin_name: influxdb + # host: localhost + # udp_port: 8089 + # keyword: influxdb + # value_field: value + # write_http: True + # http_port: 8086 + tags: '{"key": "value", "foo": "bar"}' + fields: '{"key": "value", "foo": "bar"}' + +items.yaml +---------- + +Logging in eine Messung namens ``root.some_item``, Standard-Tags und +Tags/Felder wie in plugin.yaml angegeben + +.. code-block:: yaml + + root: + some_item: + influxdb: 'true' + +Wenn ``keyword`` in der plugin.yaml auf ``sqlite`` gesetzt wird, kann dies auch +als Ersatz für sqlite verwendet werden. + +.. code-block:: yaml + + root: + some_item: + sqlite: 'true' + +*empfohlen*: Loggen der Messung ``temp`` mit einem zusätzlichen +Tag ``room`` und Standard-Tags (einschließlich ``item: root.dining_temp``) und +Tags/Felder wie in plugin.yaml angegeben + +.. code-block:: yaml + + root: + dining_temp: + influxdb_name: temp + influxdb_tags: '{"room": "dining"}' + + +In InfluxDB über UDP oder HTTP loggen +===================================== + +Protokollierung von Elementen in der Zeitseriendatenbank +`InfluxDB `_ + +Dieses Plugin ist ein Fork von ``influxdata`` mit den folgenden +Erweiterungen: + +- korrekte Namensgebung +- Angabe eines Namens für die Messung statt auf die ID des Elements zurückzugreifen +- zusätzliche Tags oder Felder global (plugin.yaml) und/oder pro Element + +Die speziellen smarthomeNG Attribute ``caller``, ``source`` und ``dest`` +werden immer als Tags protokolliert. + +Nur wenn ein Messungsname angegeben wird, wird automatisch auch die ID des Elements +mitprotokolliert (Tag ``item``) - wenn Sie keinen Messungsnamen angeben, +wird der Name auf die ID des Items zurückgreifen, was den Item-Tag +überflüssig macht + +Korrektes Logging +================= + +Bitte lesen Sie die `Key Konzepte `_ +und `Schema Design `_ + +Insbesondere diese: + +- `Metadaten kodieren in Tags `_ +- `Vermeiden Sie die Kodierung von Daten in Messnamen `_ +- Vermeiden Sie mehr als eine Information in einem Tag `_ + +Daten aus dem Database Plugin transferieren +=========================================== + +Diese Anleitung wurde unter influxdb2 getestet und muss eventuell für influxdb1 adaptiert werden. + +1. Pandas und influxdb_client Module für Python installieren +2. CSV-Dump aus dem Webinterface des Datenbank-Plugins herunterladen +3. Anpassen der Zugriffsparameter im unten stehenden Skript +4. Anpassen des Pfads zur CVS-Datei +5. Ausführen des Skripts +6. Abhängig von der Größe der Datenbank ist Geduld gefragt. + + +.. code-block:: python + + from influxdb_client import InfluxDBClient + from influxdb_client.client.write_api import SYNCHRONOUS + import pandas as pd + + + # ---------------------------------------------- + ip = "localhost" + port = 8086 + token = "******************" + org = "smarthomeng" + bucket = "shng" + value_field = "value" + str_value_field = "str_value" + + csvfile = "smarthomeng_dump.csv" + # ---------------------------------------------- + + + client = InfluxDBClient(url=f"http://{ip}:{port}", token=token, org=org) + write_api = client.write_api(write_options=SYNCHRONOUS) + + df = pd.read_csv(csvfile, sep=';', header=0) + df = df.reset_index() + + num_rows = len(df.index) + last_progress_percent = -1 + + for index, row in df.iterrows(): + progress_percent = int((index/num_rows)*100) + if last_progress_percent != progress_percent: + print(f"{progress_percent}%") + last_progress_percent = progress_percent + + p = {'measurement': row['item_name'], 'time': int(row['time']) * 1000000, + 'tags': {'item': row['item_name']}, + 'fields': {value_field: row['val_num'], str_value_field: row['val_str']} + } + write_api.write(bucket=bucket, record=p) + + client.close() + + +Web Interface +============= + +Das Plugin stellt kein Web Interface zur Verfügung. diff --git a/influxdb/webif/static/img/plugin_logo.png b/influxdb/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..a605a023c Binary files /dev/null and b/influxdb/webif/static/img/plugin_logo.png differ diff --git a/influxdb2/assets/influxdb2_webif.png b/influxdb2/assets/influxdb2_webif.png new file mode 100644 index 000000000..549cbbdfb Binary files /dev/null and b/influxdb2/assets/influxdb2_webif.png differ diff --git a/influxdb2/plugin.yaml b/influxdb2/plugin.yaml index 89e12e4ea..66435a9ba 100755 --- a/influxdb2/plugin.yaml +++ b/influxdb2/plugin.yaml @@ -87,7 +87,7 @@ item_attributes: # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) influxdb2: type: str - valid_list_ci: ['', 'yes', 'init', 'true'] + valid_list_ci: ['', 'no', 'yes', 'init', 'true'] description: de: "Wenn auf 'yes' oder 'true' gesetzt, werden die Werte des Items in die Datenbank geschrieben. Wenn auf 'init' gesetzt, wird zusätzlich beim Start von SmartHomeNG der Wert des Items aus der Datenbank gelesen." en: "This attribute enables the database logging when set (just use value 'yes' or 'true'). If value 'init' is used, an item will be initalized from the database after SmartHomeNG is restarted." @@ -109,7 +109,7 @@ item_attributes: database: type: str - valid_list_ci: ['', 'yes', 'init', 'true'] + valid_list_ci: ['', 'no', 'yes', 'init', 'true'] duplicate_use: True description: de: "Wenn auf 'yes' oder 'true' gesetzt, werden die Werte des Items in die Datenbank geschrieben. Wenn auf 'init' gesetzt, wird zusätzlich beim Start von SmartHomeNG der Wert des Items aus der Datenbank gelesen." diff --git a/influxdb2/user_doc.rst b/influxdb2/user_doc.rst index e07548b0b..242df4926 100755 --- a/influxdb2/user_doc.rst +++ b/influxdb2/user_doc.rst @@ -89,8 +89,6 @@ Mit jedem Item Wert, der in einem InfluxDB Bucket abgelegt werden, werden folgen - **str_value** - enthält nicht numerische Werte, die in der Datenbank abgelegt werden sollen. - - Konfiguration ============= @@ -100,14 +98,72 @@ Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration d unter :doc:`/plugins_doc/config/influxdb2` nachzulesen. -Beispiele ---------- +Daten aus dem Database Plugin transferieren +=========================================== + +Diese Anleitung wurde unter influxdb2 getestet und muss eventuell für influxdb1 adaptiert werden. + +1. Pandas und influxdb_client Module für Python installieren +2. CSV-Dump aus dem Webinterface des Datenbank-Plugins herunterladen +3. Anpassen der Zugriffsparameter im unten stehenden Skript +4. Anpassen des Pfads zur CVS-Datei +5. Ausführen des Skripts +6. Abhängig von der Größe der Datenbank ist Geduld gefragt. + + +.. code-block:: python + + from influxdb_client import InfluxDBClient + from influxdb_client.client.write_api import SYNCHRONOUS + import pandas as pd + + + # ---------------------------------------------- + ip = "localhost" + port = 8086 + token = "******************" + org = "smarthomeng" + bucket = "shng" + value_field = "value" + str_value_field = "str_value" + + csvfile = "smarthomeng_dump.csv" + # ---------------------------------------------- + + + client = InfluxDBClient(url=f"http://{ip}:{port}", token=token, org=org) + write_api = client.write_api(write_options=SYNCHRONOUS) + + df = pd.read_csv(csvfile, sep=';', header=0) + df = df.reset_index() + + num_rows = len(df.index) + last_progress_percent = -1 + + for index, row in df.iterrows(): + progress_percent = int((index/num_rows)*100) + if last_progress_percent != progress_percent: + print(f"{progress_percent}%") + last_progress_percent = progress_percent + + p = {'measurement': row['item_name'], 'time': int(row['time']) * 1000000, + 'tags': {'item': row['item_name']}, + 'fields': {value_field: row['val_num'], str_value_field: row['val_str']} + } + write_api.write(bucket=bucket, record=p) + + client.close() -Hier können ausführlichere Beispiele und Anwendungsfälle beschrieben werden. -... Web Interface ============= -... +Das Web Interface ermöglicht das Betrachten der Items, die mit der Datenbank verbunden sind. + +.. image:: assets/influxdb2_webif.png + :height: 1610px + :width: 3304px + :scale: 25% + :alt: Web Interface + :align: center diff --git a/influxdb2/webif/templates/index.html b/influxdb2/webif/templates/index.html index 709ca1d72..99e2b47c0 100755 --- a/influxdb2/webif/templates/index.html +++ b/influxdb2/webif/templates/index.html @@ -33,23 +33,13 @@ } } - + - -/* - * The combined file was created by the DataTables downloader builder: - * https://datatables.net/download - * - * To rebuild or modify this file with the latest versions of the included - * software please visit: - * https://datatables.net/download/#dt/dt-1.10.21/fh-3.1.7/r-2.2.5 - * - * Included libraries: - * DataTables 1.10.21, FixedHeader 3.1.7, Responsive 2.2.5 - */ - - - {% endblock pluginscripts %} @@ -66,27 +73,8 @@ {% endblock headtable %} - - -{% block buttons %} -{% if 1==2 %} -
- -
-{% endif %} -{% endblock %} - - {% set tabcount = 3 %} - - {% set item_count = p._items|length %} {% if item_count==0 %} {% set start_tab = 1 %} @@ -101,7 +89,7 @@ {% set tab1title = "" ~ _("Letzte Auslesung") ~ "" %} {% block bodytab1 %}
-
{{ p._lastresultstr }}
+
{{ p._lastresultstr }}
{% endblock bodytab1 %} @@ -111,28 +99,19 @@ --> {% set tab2title = "" ~ item_count ~ " " ~ _("Items definiert") ~ "" %} {% block bodytab2 %} -
-
- - - - - - - - + +
{{ _('Item') }}{{ _('jsonread_filter') }}{{ _('Wert') }}
{% for item in p._items %} - - - - + + + + {% endfor %}
{{ item._path }}{{ p.get_iattr_value(item.conf, 'jsonread_filter') }}{{ item() }}
{{ item._path }}{{ p.get_iattr_value(item.conf, 'jsonread_filter') }}{{ item() }}
-
-
+ {% endblock bodytab2 %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Multidevice + diff --git a/luxtronic2/__init__.py b/luxtronic2/__init__.py index 13cf7b5e5..74dbb9e41 100755 --- a/luxtronic2/__init__.py +++ b/luxtronic2/__init__.py @@ -30,6 +30,25 @@ from lib.model.smartplugin import SmartPlugin +MODES = { + 0: 'Heizbetrieb', + 1: 'Keine Anforderung', + 2: 'Netz- Einschaltverzoegerung', + 3: 'SSP Zeit', + 4: 'Sperrzeit', + 5: 'Brauchwasser', + 6: 'Estrich Programm', + 7: 'Abtauen', + 8: 'Pumpenvorlauf', + 9: 'Thermische Desinfektion', + 10: 'Kuehlbetrieb', + 12: 'Schwimmbad', + 13: 'Heizen Ext.', + 14: 'Brauchwasser Ext.', + 16: 'Durchflussueberwachung', + 17: 'ZWE Betrieb' +} + class luxex(Exception): pass @@ -39,7 +58,7 @@ class LuxBase(SmartPlugin): # ATTENTION: This is NOT the SmartPlugin class of the plugin!!! - def __init__(self, host, port=8888): + def __init__(self, host, port=8888, **kwargs): self.logger = logging.getLogger(__name__) self.host = host self.port = int(port) @@ -233,7 +252,7 @@ def refresh_calculated(self): class Luxtronic2(LuxBase): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = '1.3.2' + PLUGIN_VERSION = '1.3.3' _parameter = {} _attribute = {} @@ -241,21 +260,22 @@ class Luxtronic2(LuxBase): _decoded = {} alive = True - def __init__(self, smarthome, host, port=8888, cycle=300): - LuxBase.__init__(self, host, port) - self._sh = smarthome - self._cycle = int(cycle) + def __init__(self, sh, **kwargs): + self._is_connected = False + self._cycle = self.get_parameter_value('cycle') + LuxBase.__init__(self, self.get_parameter_value('host'), self.get_parameter_value('port')) self.connect() def run(self): self.alive = True - self._sh.scheduler.add('Luxtronic2', self._refresh, cycle=self._cycle) + self.scheduler_add('Luxtronic2', self._refresh, cycle=self._cycle) def stop(self): self.alive = False + self.scheduler_remove('Luxtronic2') def _refresh(self): - if not self.is_connected: + if not self.is_connected or not self.alive: return start = time.time() if len(self._parameter) > 0: @@ -285,54 +305,8 @@ def _refresh(self): def _decode(self, identifier, value): if identifier == 119: - if value == 0: - return 'Heizbetrieb' - if value == 1: - return 'Keine Anforderung' - if value == 2: - return 'Netz- Einschaltverzoegerung' - if value == 3: - return 'SSP Zeit' - if value == 4: - return 'Sperrzeit' - if value == 5: - return 'Brauchwasser' - if value == 6: - return 'Estrich Programm' - if value == 7: - return 'Abtauen' - if value == 8: - return 'Pumpenvorlauf' - if value == 9: - return 'Thermische Desinfektion' - if value == 10: - return 'Kuehlbetrieb' - if value == 12: - return 'Schwimmbad' - if value == 13: - return 'Heizen Ext.' - if value == 14: - return 'Brauchwasser Ext.' - if value == 16: - return 'Durchflussueberwachung' - if value == 17: - return 'ZWE Betrieb' - return '???' - if identifier == 10: - return float(value) / 10 - if identifier == 11: - return float(value) / 10 - if identifier == 12: - return float(value) / 10 - if identifier == 15: - return float(value) / 10 - if identifier == 19: - return float(value) / 10 - if identifier == 20: - return float(value) / 10 - if identifier == 151: - return float(value) / 10 - if identifier == 152: + return MODES.get(value, '???') + if identifier in (10, 11, 12, 15, 19, 20, 151, 152): return float(value) / 10 return value @@ -356,11 +330,12 @@ def parse_item(self, item): return self.update_item def update_item(self, item, caller=None, source=None, dest=None): - if caller != 'Luxtronic2': + if caller != 'Luxtronic2' and self.alive: self.set_param(self.get_iattr_value(item.conf, 'lux2_p'), item()) def main(): + lux = None try: lux = LuxBase('192.168.178.25') lux.connect() @@ -389,5 +364,6 @@ def main(): if lux: lux.close() + if __name__ == "__main__": sys.exit(main()) diff --git a/luxtronic2/plugin.yaml b/luxtronic2/plugin.yaml index 1a4349c3d..e98af2d62 100755 --- a/luxtronic2/plugin.yaml +++ b/luxtronic2/plugin.yaml @@ -11,11 +11,11 @@ plugin: # documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page # support: https://knx-user-forum.de/forum/supportforen/smarthome-py - version: 1.3.2 # Plugin version + version: 1.3.3 # Plugin version sh_minversion: 1.3 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) - multi_instance: False - restartable: unknown + multi_instance: false + restartable: true classname: Luxtronic2 # class containing the plugin parameters: @@ -32,6 +32,13 @@ parameters: de: 'Gibt den Port des Gerätes an' en: 'Specifies the port of the devices' + cycle: + type: int + default: 300 + description: + de: 'Zeitintervall zur Datenabfrage' + en: 'Interval for retrieving data' + item_attributes: # Definition of item attributes defined by this plugin lux2: diff --git a/mailrcv/README.md b/mailrcv/README.md deleted file mode 100755 index 00dbacddc..000000000 --- a/mailrcv/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# mailrcv - -## Requirements - -This plugin has no requirements or dependencies. - -## Configuration - -### plugin.yaml - -```yaml -imap: - plugin_name: mailrcv - host: mail.example.com - username: smarthome - password: secret - # tls: False - # port: default - # cycle: 300 -``` - -#### Attributes - * `host`: specifies the hostname of your mail server. - * `port`: if you want to use a nonstandard port. - * `username`/`password`: login information - * `tls`: specifies if you want to use SSL/TLS. - * `cycle`: for IMAP you could specify the intervall how often the inbox is checked - -### items.yaml - -There is no item specific configuration. - -### logic.yaml - -You could assign the following keywords to a logic. The matching order is as listed. - -#### mail_subject - -If the incoming mail subject matches the value of this key the logic will be triggerd. - -#### mail_to - -If the mail is sent to specified address the logic will be triggerd. - -If gmail is used, you can trigger multiple logics with one account - just extend email address -with ['+' sign](https://gmail.googleblog.com/2008/03/2-hidden-ways-to-get-more-from-your.html) -(eg use `myaccount+logicname@gmail.com` to trigger `logicname`) - -For safety reasons, use only dedicated gmail account with this plugin and filter out messages -from unkown senders (eg create filter `from:(-my_trusted_mail@example.com)` with action archive -or delete) - - -#### mail - -A generic flag to trigger the logic on receiving a mail. - -Attention: - * You could only call one logic per mail! - * If a mail is processed by a logic it will be delteted (moved to Deleted folder). - * There is no email security. You have to use an infrastructure which provides security (e.g. own mail server which only accepts authenticated messages for the inbox). - -```yaml -sauna: - filename: sauna.py - mail_to: sauna@example.com - -mailbox: - filename: mailbox.py - mail: 'yes' -``` - -A mail to `sauna@example.com` will only trigger the logic 'sauna'. Every other mail is process by the 'mailbox' logic. - -## Usage - -If a logic is triggered by this plugin it will set the trigger `source` to the from address and the `value` contains an [email object](http://docs.python.org/2.6/library/email.message.html). - -See the [phonebook logic](https://github.com/smarthomeNG/smarthome/wiki/Phonebook) for a logic which is triggerd by IMAP. - diff --git a/mailrcv/__init__.py b/mailrcv/__init__.py index 4233e0a55..16c35e3ec 100755 --- a/mailrcv/__init__.py +++ b/mailrcv/__init__.py @@ -29,9 +29,10 @@ class IMAP(SmartPlugin): ALLOW_MULTIINSTANCE = True - PLUGIN_VERSION = "1.4.1" + PLUGIN_VERSION = "1.4.2" def __init__(self, sh, *args, **kwargs): + super().__init__() self._host = self.get_parameter_value('host') self._port = self.get_parameter_value('port') self._username = self.get_parameter_value('username') diff --git a/mailrcv/plugin.yaml b/mailrcv/plugin.yaml index 4339cc5c6..c8616ca77 100755 --- a/mailrcv/plugin.yaml +++ b/mailrcv/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page # support: https://knx-user-forum.de/forum/supportforen/smarthome-py - version: 1.4.1 # Plugin version + version: 1.4.2 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance @@ -85,5 +85,3 @@ plugin_functions: NONE logic_parameters: NONE # Definition of logic parameters defined by this plugin - - diff --git a/mailrcv/user_doc.rst b/mailrcv/user_doc.rst new file mode 100644 index 000000000..1087e2b7e --- /dev/null +++ b/mailrcv/user_doc.rst @@ -0,0 +1,91 @@ +.. index:: Plugins; mailrcv +.. index:: mailrcv + +======= +mailrcv +======= + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 400px + :height: 308px + :scale: 50 % + :align: left + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/mailrcv` beschrieben. + +plugin.yaml +----------- + +.. code:: yaml + + imap: + plugin_name: mailrcv + host: mail.example.com + username: smarthome + password: secret + # tls: False + # port: default + # cycle: 300 + +Logiken +======= + +Wenn eine Logik durch dieses Plugin ausgelöst wird, setzt es den Trigger +``source`` auf die Absenderadresse und der ``value`` enthält ein `Email +Objekt `_. + +Sie können die folgenden Schlüsselwörter einer Logik zuordnen. Die Reihenfolge der Zuordnung +ist wie aufgeführt: + +mail_subject +------------ + +Wenn der Betreff der eingehenden E-Mail mit dem Wert dieses Schlüssels übereinstimmt, +wird die Logik ausgelöst. + +mail_to +------- + +Wenn die E-Mail an die angegebene Adresse gesendet wird, wird die Logik ausgelöst. + +Wenn gmail verwendet wird, können Sie mehrere Logiken mit einem Konto auslösen - +Erweitern Sie einfach die E-Mail Adresse mit dem `+ Zeichen `__ +(z.B. benutzen Sie ``myaccount+logicname@gmail.com`` um ``logicname`` auszulösen) + +Aus Sicherheitsgründen sollten Sie nur ein spezielles gmail-Konto mit diesem Plugin verwenden +und filtern Sie Nachrichten von unbekannten Absendern herausfiltern (z.B. erstellen Sie den Filter +``from:(-my_trusted_mail@example.com)`` mit Aktion archivieren oder löschen) + +mail +---- + +Ein allgemeines Flag, um die Logik beim Empfang einer Mail auszulösen. + +.. important:: + + Es kann nur eine Logik pro Mail aufgerufen werden. Wenn eine Mail von einer Logik verarbeitet wird, wird sie gelöscht (in den Ordner "Gelöscht" verschoben). + +Es gibt keine E-Mail-Sicherheit. Sie müssen eine Infrastruktur verwenden, die Sicherheit bietet +(z.B. ein eigener Mailserver, der nur authentifizierte Nachrichten für den Posteingang akzeptiert). + +.. code-block:: yaml + + sauna: + filename: sauna.py + mail_to: sauna@example.com + + mailbox: + filename: mailbox.py + mail: 'yes' + +Eine Mail an ``sauna@example.com`` wird nur die Logik 'Sauna' auslösen. +Alle anderen Mails werden von der Logik "Mailbox" verarbeitet. + +Web Interface +============= + +Das Plugin stellt kein Web Interface zur Verfügung. diff --git a/mailsend/README.md b/mailsend/README.md deleted file mode 100755 index faff52858..000000000 --- a/mailsend/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# mailsend - -## Requirements - -This plugin has no requirements or dependencies. - -## Configuration - -### plugin.yaml - -```yaml -mail: - plugin_name: mailsend - host: mail.example.com - mail_from: mail@example.com - # tls: True - # username: False - # password: False - -``` - -#### Attributes - * `host`: specifies the hostname of your mail server. - * `port`: if you want to use a nonstandard port. - * `username`/`password`: login information - * `tls`: specifies if you want to use SSL/TLS. - * `mail_from`: for SMTP you have to specify an origin mail address. - -### items.yaml - -There is no item specific configuration. - - -## Functions - -The SMTP object provides one function (sending) and you access without specifing a method name. -`sh.mail(to, subject, message)` e.g. `sh.mail('admin@smart.home', 'Rain: Help me', 'You could send UTF-8 encoded subjects and messages')` diff --git a/mailsend/__init__.py b/mailsend/__init__.py index aad43c736..fde79ebc0 100755 --- a/mailsend/__init__.py +++ b/mailsend/__init__.py @@ -35,16 +35,12 @@ class SMTP(SmartPlugin): - PLUGIN_VERSION = "1.4.1" + PLUGIN_VERSION = "1.4.2" def __init__(self, sh): # Call init code of parent class (SmartPlugin) super().__init__() - from bin.smarthome import VERSION - if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': - self.logger = logging.getLogger(__name__) - self._tls = self.get_parameter_value('tls') self._host = self.get_parameter_value('host') self._port = self.get_parameter_value('port') @@ -53,14 +49,14 @@ def __init__(self, sh): self._password = self.get_parameter_value('password') - def send(self, to, sub, msg): - self.__call__(to, sub, msg) + def send(self, to, sub, msg, caller=None, source=None): + self.__call__(to, sub, msg, caller, source) - def __call__(self, to, sub, msg): + def __call__(self, to, sub, msg, caller=None, source=None): try: smtp = self._connect() except Exception as e: - self.logger.warning("Could not connect to {0}: {1}".format(self._host, e)) + self.logger.warning(f"Could not connect to {self._host}: {e}") return try: msg = MIMEText(msg, 'plain', 'utf-8') @@ -73,7 +69,7 @@ def __call__(self, to, sub, msg): self.logger.debug("email prepared for sending") smtp.sendmail(self._from, to, msg.as_string()) except Exception as e: - self.logger.warning("Could not send message {} to {}: {}".format(sub, to, e)) + self.logger.warning(f"Could not send message {sub} to {to} (caller={caller}): {e}") finally: try: smtp.quit() @@ -82,11 +78,15 @@ def __call__(self, to, sub, msg): pass self.logger.debug("email was sent") - def extended(self, to, sub, msg, sender_name: str, img_list: list=[], attachments: list=[]): + def extended(self, to, sub, msg, sender_name: str, img_list: list=None, attachments: list=None, caller=None, source=None): + if img_list is None: + img_list = [] + if attachments is None: + attachments = [] try: smtp = self._connect() except Exception as e: - self.logger.warning("Could not connect to {0}: {1}".format(self._host, e)) + self.logger.warning(f"Could not connect to {self._host}: {e}") return try: sender_name = Header(sender_name, 'utf-8').encode() diff --git a/mailsend/plugin.yaml b/mailsend/plugin.yaml index a24438f60..c3c94a2a3 100755 --- a/mailsend/plugin.yaml +++ b/mailsend/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page # support: https://knx-user-forum.de/forum/supportforen/smarthome-py - version: 1.4.1 # Plugin version + version: 1.4.2 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance @@ -23,7 +23,8 @@ plugin: parameters: # Definition of parameters to be configured in etc/plugin.yaml host: - type: ip + #type: ip + type: str description: de: 'Adresse des SMTP Hosts' en: 'Address of SMTP host' @@ -34,8 +35,8 @@ parameters: valid_min: 0 valid_max: 65535 description: - de: 'Port des SMTP service (bitte 25 nutzen, fals tls deaktiviert wird)' - en: 'Port used by SMTP service (use 25 if tls is set to False)' + de: 'Port des SMTP service - Alternative gebräuchliche Ports sind 2525, 465 (smtp over SSL) und 25 (nur ohne tls)' + en: 'Port used by SMTP service - Commonly used alternative ports are 2525, 465 (smtp over SSL) und 25 (only without tls)' tls: type: bool @@ -48,7 +49,7 @@ parameters: type: str default: '' description: - de: 'Absender-Adresse der eMails (mail@example.com)' + de: 'Absenderadresse der eMails (mail@example.com)' en: 'Sender address of the emails (mail@example.com)' username: @@ -82,7 +83,7 @@ plugin_functions: type: str description: de: "Empfänger der eMail (admin@smart.home)" - en: "Receipient of email (admin@smart.home)" + en: "Recipient of email (admin@smart.home)" subject: type: foo description: diff --git a/mailsend/user_doc.rst b/mailsend/user_doc.rst new file mode 100644 index 000000000..dc8436e0d --- /dev/null +++ b/mailsend/user_doc.rst @@ -0,0 +1,26 @@ +.. index:: Plugins; mailsend +.. index:: mailsend + +======== +mailsend +======== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Konfiguration +============= + +Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/mailsend` beschrieben. + + +Web Interface +============= + +Das Plugin verfügt über kein Web Interface. diff --git a/memlog/README.md b/memlog/README.md deleted file mode 100755 index 7dcf8c289..000000000 --- a/memlog/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# memlog - -This plugins can be used to create in-memory logs which can be used by items or other -plugins. - -## Requirements - -No special requirements. - -## Configuration - -### plugin.yaml - -Use the plugin configuration to configure the in-memory logs. - -``` -memlog: - plugin_name: memlog - name: alert - mappings: - - time - - thread - - level - - message -# maxlen: 50 -# items -# - first.item.now -# - second.item.thread.info -# - third.item.level -# - fourth.item.msg -``` - -This will register a in-memory log with the name "alert". This can be used to attach -to items. - -#### name attribute - -This will give the in-memory log a name which can be used when accessing them. - -#### mappings attribute - -This configures the list of values which are logged for each log message. The following -internal mappings can be used and will be automatically set - if not given explicitely -when logging data: - -* ``time`` - the timestamp of log -* ``thread`` - the thread logging data -* ``level`` - the log level (defaults to INFO) - -#### maxlen attribute - -Defines the maximum amount of log entries in the in-memory log. - -#### items attribute - -Each time an item is updated using the `memlog` configuration setting, a log entry will -be written using the list of items configured in this attribute as log values. - -If items are defined, then four items should be named, -each with the following purpose: - - * Item A - the value of this item is entered as the timestamp - * Item B - the value of this item is entered as the thread info - * Item C - the value of this item is entered as the level of log message - * Item D - the value of this item is entered as the message - -Using Items this way it is possible to set the values of those items first -and then trigger the item which has the ``memlog`` attribute. - -When the items attribute is not configured, the default mapping values will be used and the value of the item will be logged which has the ``memlog`` attribute. - -### items.yaml - -The following attributes can be used. - -#### memlog - -Defines the name of in-memory log which should be used to log the item's content to -the log. Everything is logged with 'INFO' level. - -#### Example - -Simple item logging: - -```yaml -some: - item: - type: str - memlog: alert -``` - -An update to item ``some.item`` will cause a log entry to be generated with the value of item ``some.item``. - -### logic.yaml - -#### memlog - -Configures that a message should be logged whenever the logic was triggered. It logs a -default message which can be overwritten by the `memlog_message` attribute. - -#### memlog_message - -Defines the message to be logged. It configures a string which may contain placeholders -which got replaced by using the `format()` function. - -The following placeholders or object can be used in the message string: -* `logic` - the logic object. A format of ``{logic.name}`` will include the logics name -* `plugin` - the memlog plugin instance object -* `by` - the string containing the origin of logic trigger -* `source` - the source -* `dest` - the destination - -The `logic` and `plugin` placeholders are always available, the rest depends on the -logic invocation/trigger. - -Example: - -```yaml -memlog_message: The logic {logic.name} was triggered! -``` - -## Methods - -The plugin name defined it ``etc/plugin.yaml`` can be used as callable. - -### memlog(entry) -This log the given list of elements of `entry` parameter. The list should have the same amount -of items you used in the mapping parameter (see also the default for this value). - -`sh.memlog((self._sh.now(), threading.current_thread().name, 'INFO', 'Some information'))` - -### memlog(msg) - -This log the given message in `msg` parameter with the default log level. - -### memlog(lvl, msg) - -This logs the message in ``msg`` parameter with the given log level specified in ``lvl`` -parameter. - -### Examples - -Given the following base snippet of ``etc/plugin.yaml``: - -```yaml -my_memlog: - plugin_name: memlog - name: my_personal_memlog -``` - -The following expressions (e.g. in a logic) - -```python -sh.my_memlog("DEBUG","Debug Message") -sh.my_memlog("Hello world!") # info -sh.my_memlog("WARNING","This is a warning!") -sh.my_memlog("ERROR","This is already an error!!") -sh.my_memlog("CRITICAL","This is critical, just shutdown everything!!!") -``` - -together with this definition in SmartVISU pages: - -```html -{{ status.log('log_id', 'my_personal_memlog', 10) }} -``` - -will show a screen like this: - -![Screenshot of messages displayed with SmartVISU](callable.png "Result with SmartVISU") diff --git a/memlog/__init__.py b/memlog/__init__.py index 5b2437f4a..478309b99 100755 --- a/memlog/__init__.py +++ b/memlog/__init__.py @@ -30,10 +30,10 @@ class MemLog(SmartPlugin): - PLUGIN_VERSION = '1.6.0' + PLUGIN_VERSION = '1.6.1' def __init__(self, sh, *args, **kwargs): - + super().__init__() self.name = self.get_parameter_value('name') self.mappings = self.get_parameter_value('mappings') self.items = self.get_parameter_value('items') @@ -139,4 +139,3 @@ def log(self, logvalues, level = 'INFO'): logvalues = logvalues[1:] self._log.add(log) - diff --git a/memlog/callable.png b/memlog/assets/visu_callable.png similarity index 100% rename from memlog/callable.png rename to memlog/assets/visu_callable.png diff --git a/memlog/plugin.yaml b/memlog/plugin.yaml index a27eacd94..248d5e444 100755 --- a/memlog/plugin.yaml +++ b/memlog/plugin.yaml @@ -3,15 +3,14 @@ plugin: # Global plugin attributes type: system # plugin type (gateway, interface, protocol, system, web) description: - de: 'Speichern der Logeinträge im Speicher (zur Anzeige in der VISU)' - en: 'Store log entries in memory (for display in VISU)' + de: 'Speichern der Logeinträge im Speicher (zur Anzeige in der VISU). Durch Bordmittel ersetzbar.' + en: 'Store log entries in memory (for display in VISU). Can be replaced by standard config.' maintainer: ohinckel tester: cmalo - state: ready + state: deprecated keywords: memory log # keywords, where applicable -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page - version: 1.6.0 # Plugin version + version: 1.6.1 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True @@ -40,26 +39,26 @@ parameters: # [time, thread, level, message] description: de: > - Definiert eine Liste mit kommagetrennten Werten die für jede Nachricht geloggt werden. + Definiert eine Liste mit kommagetrennten Werten die für jede Nachricht geloggt werden. Die folgenden internen Zuordnungen können benutzt werden: - + time - Zeitstempel des Eintrags thread - Thread der die Daten erzeugt level - Der log level (Standard ist INFO) message - Die Nachricht - - Die Vorgabe sollte beibehalten werden, wenn das Log in SmartVISU angezeigt werden soll, + + Die Vorgabe sollte beibehalten werden, wenn das Log in SmartVISU angezeigt werden soll, sonst werden die Werte von der SmartVISU für die Darstellung nicht korrekt interpretiert. en: > - This configures the list of values with comma separated values which are logged for each log message. + This configures the list of values with comma separated values which are logged for each log message. The following internal mappings can be used and will be automatically set if not given explicitely when logging data: - + time - the timestamp of log thread - the thread logging data level - the log level (defaults to INFO) message - the message - + The default value should not be changed. Otherwise the SmartVISU might misinterpret the values for display. maxlen: @@ -75,7 +74,7 @@ parameters: de: > Jedes mal wenn ein Item aktualisiert wird, wird ein Logeintrag geschrieben unter der Berücksichtigung der konfigurierten Items und deren Werte als Log Werte - + Wird dies nicht angegeben, dann werden die vorgegebenen Zuordnungswerte verwendet: - item_a --> now - item_b --> thread info @@ -85,7 +84,7 @@ parameters: en: > Each time an item is updated using the memlog configuration setting, a log entry will be written using the list of items configured in this attribute as log values. - + When this is not configured, the default mapping values will be used the associated item's value will be logged. e.g.: - item_a --> now @@ -100,7 +99,7 @@ item_attributes: description: de: > Bezeichnet den Namen des Logs in dem die Nachricht aufgezeichnet werden soll. - Alle´Einträge werden mit Level INFO erstellt + Alle Einträge werden mit Level INFO erstellt en: > Defines the name of in-memory log which should be used to log the item's content to the log. Everything is logged with 'INFO' level. @@ -128,36 +127,33 @@ logic_parameters: type: str description: de: > - Legt das Mitteilungsformat des Logeintrages fest. In der Zeichenkette können folgende Platzhalter enthalten + Legt das Mitteilungsformat des Logeintrages fest. In der Zeichenkette können folgende Platzhalter enthalten sein, die durch eine format Anweisung ersetzt werden: - + logic - Das Logik Objekt, z.B. logic.name für den Logiknamen plugin - Die Instanz des Memlog Plugins by - Ursprung des Logiktriggers source - Die Quelle dest - Das Ziel - Die Logik und Plugin Platzhalter sind immer verfügbar, die weiteren hängen davon ab + Die Logik und Plugin Platzhalter sind immer verfügbar, die weiteren hängen davon ab wie die Logik aufgerufen bzw. getriggert wurde - + Beispiel: memlog_message: "Die Logik {logic.name} wurde getriggert!" en: > Defines the message to be logged. It configures a string which may contain placeholders which got replaced by using the format() function. - + The following placeholders or object can be used in the message string: - + logic - the logic object, e.g. logic.name for the logic's name plugin - the memlog plugin instance object by - the string containing the origin of logic trigger source - the source dest - the destination - The logic and plugin placeholders are always available, the rest depends on the logic + The logic and plugin placeholders are always available, the rest depends on the logic invocation/trigger. - + Example: memlog_message: "The logic {logic.name} was triggered!" - - - diff --git a/memlog/user_doc.rst b/memlog/user_doc.rst new file mode 100644 index 000000000..8be5a1962 --- /dev/null +++ b/memlog/user_doc.rst @@ -0,0 +1,228 @@ +.. index:: Plugins; memlog +.. index:: memlog + +====== +memlog +====== + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Dieses Plugin stellt für Items und Plugins eine Loggingvariante +zur Verfügung, bei der die Logeinträge im Arbeitsspeicher abgelegt werden. + +.. important:: + + Das Plugin ist als "deprecated" abgekündigt. Ersatz der Funktionalität ist im nächsten Absatz beschrieben. + +Ersatz durch Bordmittel +======================= + +Details zum Memory Loghandler sind unter :doc:`Logging Handler ` +zu finden. Informationen zum Loggen bei Itemänderungen findet man unter +:doc:`log_change `. + +Beispiel Logik +-------------- + +In diesem Beispiel werden sämtliche Aufrufe des Loggers in der Logik ex_logging +in das Memorylog namens memory_info geschrieben. Während beim memlog Plugin durch +einen Eintrag in der ``etc/logic.yaml`` Datei beim Triggern einer Logik +automatisch ein Logeintrag erstellt wird, sind hier in der Logik selbst die +entsprechenden logger Methoden einzubinden. Somit können wie gewohnt auch +verschiedene Loglevel genutzt werden. + +Das Memorylog wird in der Datei ``etc/logging.yaml`` wie folgt konfiguriert: + +.. code-block:: yaml + + # etc/logging.yaml + handlers: + memory_info: + (): lib.log.ShngMemLogHandler + logname: memory_info + maxlen: 60 + level: INFO + cache: True + + loggers: + logics.ex_logging: + handlers: [memory_info] + level: INFO + +Die Logeinträge werden aus der Logik ``logics/.py`` wie folgt erstellt: + +.. code-block:: python + + # logics/ex_logging.py + sourceitem = items.return_item(trigger['source']) + logger.info(f"Logik '{logic.name}' wurde durch {trigger} getriggert. Source = {sourceitem}") + logger.debug(f"Logik '{logic.name}' (filename '{logic.filename}') wurde getriggert (DEBUG)") + +Beispiel Item +------------- + +Das Memorylog wird in der Datei ``etc/logging.yaml`` wie folgt konfiguriert: + +.. code-block:: yaml + + # etc/logging.yaml + handlers: + memory_info: + (): lib.log.ShngMemLogHandler + logname: memory_info + maxlen: 60 + level: INFO + cache: True + + loggers: + items.memory-items: + handlers: [memory_info] + level: INFO + +Nun können mehrere Items über die entsprechenden Attribute in das Memory Log +schreiben. Möchte man dabei die Möglichkeit des memlog Plugins, Mitteilungen +über ein Item zu deklarieren, nutzen, kommt das Attribut +``log_rules: "{'itemvalue': ''}"`` zum Einsatz. + +.. code-block:: yaml + + item: + type: num + log_change: memory-items + log_level: INFO + log_text: 'Wert={mvalue}, Alter={age}, Zeit={now}' + +Das Einbinden in eine SmartVISU Seite erfolgt mittels: + +.. code-block:: html + + {{ status.log('', 'memory_info', 10) }} + +Konfiguration +============= + +.. important:: + + Detaillierte Informationen zur Konfiguration des Plugins sind + unter :doc:`/plugins_doc/config/memlog` zu finden. + +plugin.yaml +----------- + +.. code-block:: yaml + + # etc/plugin.yaml + memlog: + plugin_name: memlog + name: alert + mappings: + - time + - thread + - level + - message + #maxlen: 50 + #items: + # - first.item.now + # - second.item.thread.info + # - third.item.level + # - fourth.item.msg + +Das angegebene Beispiel registriert ein Memory Log namens "alert". +Das `mappings` Attribut konfiguriert die Liste der Werte, die für jede Protokollmeldung +genutzt werden sollen. Die Werte werden dabei aus den angegebenen Items ausgelesen. + +items.yaml +---------- + +Das ``memlog`` Attribut legt den Namen des speicherinternen Logs fest, +das verwendet werden soll, um den Inhalt des Elements ins Log zu schreiben. + +.. code-block:: yaml + + some: + item: + type: str + memlog: alert + +Eine Aktualisierung des Eintrags "some.item" führt zur Erstellung eines Protokolleintrags +mit dem Wert des Eintrags ``some.item``. + +logic.yaml +---------- + +.. code-block:: yaml + + # etc/logic.yaml + ex_logging: + filename: example_logging.py + memlog: testing + memlog_message: The logic {logic.name} was triggered! + +Bei jeder Auslösung der Logik, im Beispiel example_logging.py, wird der Eintrag +im optionalen Attribut ``memlog_message`` in das entsprechende Speicherprotokoll geschrieben. + +Einsatz in Logiken +================== + +Funktionsaufruf +--------------- + +Der unter ``etc/plugin.yaml`` angegebene Pluginname kann durch () aufgerufen werden. +Dies protokolliert die angegebene Liste der Elemente des Parameters ``Eintrag``. Die Liste +sollte die gleiche Anzahl an Elementen haben, die in dem Mapping-Parameter angegeben wurde. + +.. code-block:: python + + sh.memlog((self._sh.now(), threading.current_thread().name, 'INFO', 'Some information')) + +Wird im Eintrag nur eine Mitteilung als String angegeben, werden die anderen Werte +entsprechend den Vorgaben in etc/plugin.yaml genutzt. Außerdem ist es möglich, +nur das Loglevel und die Mitteilung anzugeben, also z.B. +``sh.memlog('INFO', 'Some information')``. + +Beispiel +-------- + +In ``etc/plugin.yaml`` wird das Plugin wie folgt eingebunden: + +.. code-block:: yaml + + my_memlog: + plugin_name: memlog + name: my_personal_memlog + +Die folgenden Aufrufe können in einer Logik eingebunden werden: + +.. code-block:: python + + sh.my_memlog("DEBUG", "Debug Message") + sh.my_memlog("Hello world!") # info + sh.my_memlog("WARNING", "This is a warning!") + sh.my_memlog("ERROR", "This is already an error!!") + sh.my_memlog("CRITICAL", "This is critical, just shutdown everything!!!") + +Das Einbinden in eine SmartVISU Seite erfolgt mittels: + +.. code-block:: html + + {{ status.log('log_id', 'my_personal_memlog', 10) }} + +Das resultiert in einer Liste von Logeinträgen wie beispielsweise: + +.. image:: assets/visu_callable.png + :height: 302px + :width: 528px + :scale: 100% + :alt: Visu + :align: center + +Web Interface +============= + +Das Plugin verfügt über kein Web Interface, es kann aber in der SmartVISU genutzt werden. diff --git a/memlog/webif/static/img/plugin_logo.svg b/memlog/webif/static/img/plugin_logo.svg new file mode 100644 index 000000000..90543db16 --- /dev/null +++ b/memlog/webif/static/img/plugin_logo.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + MEMLOG + \ No newline at end of file diff --git a/memlog/webif/templates/index.html b/memlog/webif/templates/index.html deleted file mode 100755 index 0d49ce7de..000000000 --- a/memlog/webif/templates/index.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "base_plugin.html" %} - -{% set logo_frame = false %} - - -{% set update_interval = 0 %} - - -{% block pluginscripts %} - -{% endblock pluginscripts %} - - -{% block headtable %} - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Prompt 1{% if 1 == 2 %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}Prompt 4{{ _('Wert 4') }}
Prompt 2{{ _('Wert 2') }}Prompt 5-
Prompt 3-Prompt 6-
-{% endblock headtable %} - - - -{% block buttons %} -{% if 1==2 %} -
- -
-{% endif %} -{% endblock %} - - -{% set tabcount = 4 %} - - - -{% if item_count==0 %} - {% set start_tab = 2 %} -{% endif %} - - - -{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} -{% block bodytab1 %} -
- {{ _('Hier kommt der Inhalt des Webinterfaces hin.') }} -
-{% endblock bodytab1 %} - - - -{% set tab2title = "" ~ p.get_shortname() ~ " Geräte (" ~ device_count ~ ")" %} -{% block bodytab2 %} -{% endblock bodytab2 %} - - - -{% block bodytab3 %} -{% endblock bodytab3 %} - - - -{% block bodytab4 %} -{% endblock bodytab4 %} diff --git a/mieleathome/README.md b/mieleathome/README.md deleted file mode 100755 index f02766aa1..000000000 --- a/mieleathome/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# mieleathome - -## Version 1.0.0 - -Das Plugin ermöglicht den Zugriff auf die Miele@Home API. Es werden Stati abgefragt und -im Rahmen der Möglichkeiten der API können Geräte gesteuert werden. -Es wird das Pollen von Informationen sowie das Event-gestütze Empfangen von Daten unterstützt. -Für das Event-Listening wird ein Stream-request zum Miele-Server aufgebaut. Falls durch den Trennung der -Internet-Verbindung der Stream abreisst wird dies durch das Plugin erkannt und eine neuer Stream -aufgebaut. - - -## table of content - -1. [Change Log](#changelog) -2. [Aktivierung des Zugriffs für 3rd party-Apps](#activate) -3. [Einstellungen in der plugin.yaml](#plugin_yaml) -4. [Ermittln der Device-ID´s](#device_id) -5. [Items definieren](#create_items) -6. [Darstellung in der VISU](#visu) -7. [known issues](#issues) - -## ChangeLog - -### 2021-11-21 -- Version 1.0.0 -- first Commit für Tests -- Bedienen und Überwachen von Trocknern und Gefrierschränken ist implementiert -- Folgende Funktionen sind realisiert - - - Status - - programPhase - - programType - - remainingTime - - targetTemperature - - temperature - - signalInfo - - signalFailure - - signalDoor - - dryingStep - - elapsedTime - - ecoFeedback - - batteryLevel - - processAction ( start / stop / pause / start_superfreezing / stop_superfreezing / start_supercooling / stop_supercooling / PowerOn / PowerOff) - - -### Todo in Version 1.0.0 - -- Verarbeitung von "Programmen" -- Verarbeitung von "ambientLight", "light", "ventilationStep", "colors" -- Verarbeiten von "modes" - -## Aktivierung des Zugriffs für 3rd party-Apps - - -Eine App unter https://www.miele.com/f/com/en/register_api.aspx registrieren. Nach Erhalt der Freischalt-Mail die Seite aufrufen und das Client-Secret und die Client-ID kopieren und merken (speichern). -Dann einmalig über das Swagger-UI der API (https://www.miele.com/developer/swagger-ui/swagger.html) mittels Client-ID und Client-Secret über den Button "Authorize" (in grün, auf der rechten Seite) Zugriff erteilen. Wenn man Client-Id und Client-Secret eingetragen hat wird man einmalig aufgefordert mittels mail-Adresse, Passwort und Land der App-Zugriff zu erteilen. - -Die erhaltenen Daten für Client-ID und Client-Secret in der ./etc/plugin.yaml wie unten beschrieben eintragen. - -##Settings für die /etc/plugin.yaml - -

-mieleathome:
-    plugin_name: mieleathome
-    class_path: plugins.mieleathome
-    miele_cycle: 120
-    miele_client_id: ''
-    miele_client_secret: ''
-    miele_client_country: 'de-DE'
-    miele_user: ''      # email-Adress
-    miele_pwd: ''       # Miele-PWD
-
- -## Ermitteln der benötigten Device-ID´s
- -Das Plugin kann ohne item-Definitionen gestartet werden. Sofern gültige Zugangsdaten vorliegen -werden die registrierten Mielegeräte abgerufen. Die jeweiligen Device-Id´s können im WEB-IF auf dem -zweiten Tab eingesehen werden. - -## Anlegen der Items - -Es wird eine vorgefertigtes "Struct" für alle Geräte mitgeliefert. Es muss lediglich die Miele-"DeviceID" beim jweiligen Gerät -erfasst werden. Um die Miele-"DeviceID" zu ermitteln kann das Plugin ohne Items eingebunden und gestartet werden. Es werden im Web-IF -des Plugins alle registrierten Geräte mit der jeweiligen DeviceID angezeigt. -Führende Nullen der DeviceID sind zu übernehmen - -
-
-%YAML 1.1
----
-MieleDevices:
-    Freezer:
-        type: str
-        miele_deviceid: 'XXXXXXXXXXX'
-        struct: mieleathome.child
-    Dryer:
-        type: str
-        miele_deviceid: 'YYYYYYYYYYY'
-        struct: mieleathome.child        
-
-
-
- - - -## Darstellung in der VISU
- -Es gibt eine vorgefertigte miele.html im Plugin-Ordner. Hier kann man die jeweiligen Optionen herauslesen und nach -den eigenen Anforderungen anpassen und in den eigenen Seiten verwenden. - -## known issues -### Trockner : -Ein Trockner kann nur im Modus "SmartStart" gestartet werden. -Es muss der SmartGrid-Modus aktiv sein und das Gerät auf "SmartStart" eingestellt werden. -Der Trockner kann dann via API/Plugin gestartet werden bzw. es kann eine Startzeit via API/Plugin gesetzt werden diff --git a/mieleathome/assets/img.png b/mieleathome/assets/img.png deleted file mode 100644 index 6f7b59ced..000000000 Binary files a/mieleathome/assets/img.png and /dev/null differ diff --git a/mieleathome/assets/img_1.png b/mieleathome/assets/img_1.png deleted file mode 100644 index 6f7b59ced..000000000 Binary files a/mieleathome/assets/img_1.png and /dev/null differ diff --git a/mieleathome/assets/img_10.png b/mieleathome/assets/img_10.png deleted file mode 100644 index b4de2ce9c..000000000 Binary files a/mieleathome/assets/img_10.png and /dev/null differ diff --git a/mieleathome/assets/img_11.png b/mieleathome/assets/img_11.png deleted file mode 100644 index ce6742316..000000000 Binary files a/mieleathome/assets/img_11.png and /dev/null differ diff --git a/mieleathome/assets/img_12.png b/mieleathome/assets/img_12.png deleted file mode 100644 index d7e486224..000000000 Binary files a/mieleathome/assets/img_12.png and /dev/null differ diff --git a/mieleathome/assets/img_13.png b/mieleathome/assets/img_13.png deleted file mode 100644 index ec1b29b89..000000000 Binary files a/mieleathome/assets/img_13.png and /dev/null differ diff --git a/mieleathome/assets/img_14.png b/mieleathome/assets/img_14.png deleted file mode 100644 index 96cda9d45..000000000 Binary files a/mieleathome/assets/img_14.png and /dev/null differ diff --git a/mieleathome/assets/img_15.png b/mieleathome/assets/img_15.png deleted file mode 100644 index d973ec897..000000000 Binary files a/mieleathome/assets/img_15.png and /dev/null differ diff --git a/mieleathome/assets/img_16.png b/mieleathome/assets/img_16.png deleted file mode 100644 index efdffa81a..000000000 Binary files a/mieleathome/assets/img_16.png and /dev/null differ diff --git a/mieleathome/assets/img_17.png b/mieleathome/assets/img_17.png deleted file mode 100644 index efdffa81a..000000000 Binary files a/mieleathome/assets/img_17.png and /dev/null differ diff --git a/mieleathome/assets/img_18.png b/mieleathome/assets/img_18.png deleted file mode 100644 index 351d09a45..000000000 Binary files a/mieleathome/assets/img_18.png and /dev/null differ diff --git a/mieleathome/assets/img_5.png b/mieleathome/assets/img_5.png deleted file mode 100644 index f2aeba29f..000000000 Binary files a/mieleathome/assets/img_5.png and /dev/null differ diff --git a/mieleathome/assets/img_6.png b/mieleathome/assets/img_6.png deleted file mode 100644 index 6e0c424c4..000000000 Binary files a/mieleathome/assets/img_6.png and /dev/null differ diff --git a/mieleathome/assets/img_7.png b/mieleathome/assets/img_7.png deleted file mode 100644 index 48e6b09a7..000000000 Binary files a/mieleathome/assets/img_7.png and /dev/null differ diff --git a/mieleathome/assets/img_8.png b/mieleathome/assets/img_8.png deleted file mode 100644 index 22e8e80f0..000000000 Binary files a/mieleathome/assets/img_8.png and /dev/null differ diff --git a/mieleathome/assets/img_9.png b/mieleathome/assets/img_9.png deleted file mode 100644 index d9baf2fed..000000000 Binary files a/mieleathome/assets/img_9.png and /dev/null differ diff --git a/mieleathome/assets/smartvisu.png b/mieleathome/assets/smartvisu.png new file mode 100644 index 000000000..563da9f45 Binary files /dev/null and b/mieleathome/assets/smartvisu.png differ diff --git a/mieleathome/assets/img_3.png b/mieleathome/assets/webif_devices.png old mode 100644 new mode 100755 similarity index 100% rename from mieleathome/assets/img_3.png rename to mieleathome/assets/webif_devices.png diff --git a/mieleathome/assets/img_4.png b/mieleathome/assets/webif_events.png old mode 100644 new mode 100755 similarity index 100% rename from mieleathome/assets/img_4.png rename to mieleathome/assets/webif_events.png diff --git a/mieleathome/assets/img_2.png b/mieleathome/assets/webif_items.png old mode 100644 new mode 100755 similarity index 100% rename from mieleathome/assets/img_2.png rename to mieleathome/assets/webif_items.png diff --git a/mieleathome/miele.html b/mieleathome/miele.html old mode 100644 new mode 100755 diff --git a/mieleathome/user_doc.rst b/mieleathome/user_doc.rst index fcb29e588..8400f3b8a 100755 --- a/mieleathome/user_doc.rst +++ b/mieleathome/user_doc.rst @@ -1,6 +1,141 @@ -Sample Plugin <- hier den Namen des Plugins einsetzen -===================================================== +.. index:: Plugins; mieleathome +.. index:: mieleathome -Anforderungen -------------- -Wird nachgereicht +=========== +mieleathome +=========== + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Das Plugin ermöglicht den Zugriff auf die `Miele@Home API`. Es werden Stati abgefragt und +im Rahmen der Möglichkeiten der API können Geräte gesteuert werden. +Es wird das Pollen von Informationen sowie das Event-gestütze Empfangen von Daten unterstützt. +Für das Event-Listening wird ein Stream-request zum Miele-Server aufgebaut. Falls durch den Trennung der +Internet-Verbindung der Stream abreisst wird dies durch das Plugin erkannt und eine neuer Stream +aufgebaut. + +Folgende Funktionen zur Überwachung und Steuerung von Trocknern und Gefrierschränken sind implementiert: + +- Status +- programPhase +- programType +- remainingTime +- targetTemperature +- temperature +- signalInfo +- signalFailure +- signalDoor +- dryingStep +- elapsedTime +- ecoFeedback +- batteryLevel +- processAction ( start / stop / pause / start_superfreezing / stop_superfreezing / start_supercooling / stop_supercooling / PowerOn / PowerOff) + +Bei einem Trockner muss der SmartGrid-Modus aktiv sein und das Gerät auf "SmartStart" eingestellt werden. +Der Trockner kann dann via API/Plugin gestartet werden bzw. es kann eine Startzeit via API/Plugin gesetzt werden. + +Konfiguration +============= + +.. important:: + + Detaillierte Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/mieleathome` zu finden. + +plugin.yaml +----------- + +Um Zugang zu einem Mielegerät zu erhalten, ist es notwendig, zuerst eine App (smarthomeNG) unter +`Miele `_ zu registrieren. Nach Erhalt der Freischalt-Mail +die Seite aufrufen und das Client-Secret und die Client-ID kopieren/merken/speichern. + +Dann einmalig über das `Swagger-UI der API `_ mittels +Client-ID und Client-Secret über den Button "Authorize" (in grün, auf der rechten Seite) Zugriff erteilen. +Wenn man Client-Id und Client-Secret eingetragen hat, wird man einmalig aufgefordert mittels Mail-Adresse, Passwort und Land der App-Zugriff zu erteilen. + +Die erhaltenen Daten für Client-ID und Client-Secret in der ./etc/plugin.yaml wie unten beschrieben eintragen. + +.. code-block:: yaml + + # etc/plugin.yaml + mieleathome: + plugin_name: mieleathome + miele_cycle: 120 + miele_client_id: '' + miele_client_secret: '' + miele_client_country: 'de-DE' + miele_user: '' # email-address + miele_pwd: '' # Miele-PWD + +Items +----- + +Es wird eine vorgefertigtes "Struct" für alle Geräte mitgeliefert. Es muss lediglich die Miele-"DeviceID" beim jeweiligen Gerät +definiert werden. Um die Miele-"DeviceID" zu ermitteln kann das Plugin ohne Items eingebunden und gestartet werden. Es werden im Web-IF +des Plugins alle registrierten Geräte mit der jeweiligen DeviceID angezeigt. Führende Nullen der DeviceID sind zu übernehmen. + + +.. code-block:: yaml + + # items/item.yaml + MieleDevices: + Freezer: + type: str + miele_deviceid: 'XXXXXXXXXXX' + struct: mieleathome.child + Dryer: + type: str + miele_deviceid: 'YYYYYYYYYYY' + struct: mieleathome.child + +smartVISU +--------- + +Es gibt eine vorgefertigte miele.html im Plugin-Ordner. Hier kann man die jeweiligen Optionen herauslesen und nach +den eigenen Anforderungen anpassen und in den eigenen Seiten verwenden. + +.. image:: assets/smartvisu.png + :height: 939px + :width: 945px + :scale: 50% + :alt: Smartvisu + :align: center + +Web Interface +============= + +Das Web Interface listet sämtliche mit dem Plugin verbundene Items, deren Typ und Wert. + +.. image:: assets/webif_items.png + :height: 550px + :width: 942px + :scale: 100% + :alt: Webif1 + :align: center + + +Im zweiten Tab sind die Device ID, das verknüpfte Item, Gerätetyp und -modell zu sehen. +Dieser Tab kann auch dazu genutzt werden, die DeviceID auszulesen, die in weiterer Folge +in der items.yaml Datei eingetragen werden muss - siehe Konfiguration. + +.. image:: assets/webif_devices.png + :height: 264px + :width: 944px + :scale: 100% + :alt: Webif2 + :align: center + + +Der dritte Tab gibt Einblick in Device und Action Events, hier können z.B. Aktionen zur Temperatureinstellung eines Kühlschranks +angelegt oder geändert werden. + +.. image:: assets/webif_events.png + :height: 541px + :width: 944px + :scale: 100% + :alt: Webif3 + :align: center diff --git a/mieleathome/webif/static/img/plugin_logo.svg b/mieleathome/webif/static/img/plugin_logo.svg old mode 100644 new mode 100755 diff --git a/mikrotik/__init__.py b/mikrotik/__init__.py new file mode 100644 index 000000000..280e1b8f5 --- /dev/null +++ b/mikrotik/__init__.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.8 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from lib.model.smartplugin import SmartPlugin +from lib.item import Items + +from .webif import WebInterface + +from routeros_api import RouterOsApiPool +from routeros_api import exceptions + +# If a needed package is imported, which might be not installed in the Python environment, +# add it to a requirements.txt file within the plugin's directory + + +class MikrotikPlugin(SmartPlugin): + """ + Main class of the Plugin. Does all plugin specific stuff and provides + the update functions for the items + + HINT: Please have a look at the SmartPlugin class to see which + class properties and methods (class variables and class functions) + are already available! + """ + + PLUGIN_VERSION = '1.0.0' + + def __init__(self, sh): + """ + Initalizes the plugin. + + If you need the sh object at all, use the method self.get_sh() to get it. There should be almost no need for + a reference to the sh object any more. + + Plugins have to use the new way of getting parameter values: + use the SmartPlugin method get_parameter_value(parameter_name). Anywhere within the Plugin you can get + the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It + returns the value in the datatype that is defined in the metadata. + """ + + # Call init code of parent class (SmartPlugin) + super().__init__() + + self._items = [] + self._interfaces = [] + self._identity = '' + self._osversion = '' + + self._cycle = self.get_parameter_value('cycle') + self._device = { + 'host': self.get_parameter_value('hostname'), + 'port': self.get_parameter_value('port'), + 'username': self.get_parameter_value('username'), + 'password': self.get_parameter_value('password'), + 'plaintext_login': True, + 'use_ssl': True, + 'ssl_verify': False, + 'ssl_verify_hostname': False + } + + try: + self._api_pool = RouterOsApiPool(**self._device) + self._api = self._api_pool.get_api() + + except exceptions.RouterOsApiConnectionError: + self.logger.error('Failed to connect to MikroTik device') + self._init_complete = False + return + + except exceptions.RouterOsApiError as e: + self.logger.error(f'RouterOS API error: {e}') + self._init_complete = False + return + + self.init_webinterface(WebInterface) + return + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug("Run method called") + + try: + # Get the device model and serial number + deviceinfo = self._api.get_resource('/system/routerboard').get()[0] + self._model = deviceinfo['model'] + self._serial = deviceinfo['serial-number'] + + except exceptions.RouterOsApiConnectionError: + self.logger.error('Failed to connect to MikroTik device') + self._init_complete = False + return + + except exceptions.RouterOsApiError as e: + self.logger.error(f'RouterOS API error: {e}') + self._init_complete = False + return + + self.scheduler_add('poll_routeros', self.poll_device, cycle=self._cycle) + self.alive = True + # Do an initial poll without having to wait for the first scheduler cycle + self.poll_device + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.debug("Stop method called") + self.scheduler_remove('poll_routeros') + #self._api.put() + self._api_pool.disconnect() + self.alive = False + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + The plugin can, corresponding to its attribute keywords, decide what to do with + the item in future, like adding it to an internal array for future reference + :param item: The item to process. + :return: If the plugin needs to be informed of an items change you should return a call back function + like the function update_item down below. An example when this is needed is the knx plugin + where parse_item returns the update_item function when the attribute knx_send is found. + This means that when the items value is about to be updated, the call back function is called + with the item, caller, source and dest as arguments and in case of the knx plugin the value + can be sent to the knx with a knx write function within the knx plugin. + """ + + if self.has_iattr(item.conf, 'mikrotik_parameter'): + self.logger.debug(f"parse item: {item} with conf {item.conf}") + function = self.get_iattr_value(item.conf, 'mikrotik_parameter') + if not self.has_iattr(item.conf, 'mikrotik_port'): + self.logger.warning(F'Item requested function {function}, but no port specified') + else: + port = self.get_iattr_value(item.conf, 'mikrotik_port') + self.logger.debug(f"Item: {item} is parameter {function} of port {port}") + self._items.append(item) + return self.update_item + + def parse_logic(self, logic): + """ + Default plugin parse_logic method + """ + if 'xxx' in logic.conf: + # self.function(logic['name']) + pass + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Item has been updated + + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that + is managed by this plugin. + + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + if self.alive and caller != self.get_shortname(): + # code to execute if the plugin is not stopped + # and only, if the item has not been changed by this this plugin: + self.logger.debug(f"update_item was called with item {item.property.path} from caller {caller}, source {source} and dest {dest}") + + port = self.get_iattr_value(item.conf, 'mikrotik_port') + function = self.get_iattr_value(item.conf, 'mikrotik_parameter') + + if function == 'enabled': + self.set_port_enabled(port, 'true' if item() == True else 'false') + + if function == 'poe': + self.set_port_poe(port, 'true' if item() == True else 'false') + + def update_items(self): + """ + Update items after polling + + This method is called after having successfully polled a device. + It updates the configured items with the result of the device poll. + """ + # Itter though configured items + for item in self._items: + port = self.get_iattr_value(item.conf, 'mikrotik_port') + function = self.get_iattr_value(item.conf, 'mikrotik_parameter') + # Try to find port in interfaces list + interface = next((sub for sub in self._interfaces if sub['name'] == port), None) + # If found update the item with the respective switch port property + if interface: + if function == 'active': + item(True if interface['running'] == 'true' else False, self.get_shortname()) + elif function == 'enabled': + item(True if interface['disabled'] == 'false' else False, self.get_shortname()) + elif function == 'poe': + item(True if interface['poe'] == 'auto-on' else False, self.get_shortname()) + # # if the plugin is a gateway plugin which may receive updates from several external sources, + # # the source should be included when updating the the value: + # item(device_value, self.get_shortname(), source=device_source_id) + + def poll_device(self): + """ + Polls for updates of the device + + This method is only needed, if the device (hardware/interface) does not propagate + changes on it's own, but has to be polled to get the actual status. + It is called by the scheduler which is set within run() method. + """ + + try: + # Retrieve ethernet interface list + ethernet_ports = self._api.get_resource( + '/interface/ethernet').get() + + # Retrieve bridge port information for all interfaces + bridge_ports = self._api.get_resource( + '/interface/bridge/port').get() + + # Retrieve identity and RouterOS version + self._identity = self._api.get_resource( + '/system/identity').get()[0]['name'] + self._osversion = self._api.get_binary_resource( + '/system/package').get()[0]['version'].decode() + + except exceptions.RouterOsApiConnectionError: + self.logger.error(F"Failed to connect to MikroTik device {self._device['host']}") + return + + except exceptions.RouterOsApiError as e: + self.logger.error(f'RouterOS API error: {e}') + return + + # Create a dictionary of bridge ports for faster lookups + bridge_port_dict = {port['interface']: port for port in bridge_ports} + + # Parse output into a list of dictionaries + interfaces = [] + for interface in ethernet_ports: + # Get PVID for the ethernet interface from bridge port buffer + bridge_port = bridge_port_dict.get(interface['name']) + pvid = bridge_port['pvid'] if bridge_port else '-' + if (interface['name'] == 'ether18'): + {self.logger.debug(interface)} + if (interface['id'][0] == '*'): + interface['id'] = interface['id'][1:] + interface['id'] = int(interface['id'], 16) + self.logger.debug(interface) + speed = '?' + if 'speed' in interface: + speed = interface['speed'] + interfaces.append({ + 'id': interface['id'], + 'defaultname': interface['default-name'], + 'name': interface['name'], + 'running': interface['running'], + 'speed': speed, + 'disabled': interface['disabled'], + 'pvid': pvid, + 'poe': interface.get('poe-out', '-'), + 'comment': interface.get('comment', '') + }) + self._interfaces = interfaces + self.logger.debug(F"Polled {self._device['host']}, found {len(interfaces)} ports") + self.update_items() + + def str2bool(self, v): + return str(v).lower() in ("yes", "true", "on", "1") + + def get_port_status(self, port): + active = False + interface = next((sub for sub in self._interfaces if sub['name'] == port), None) + if (interface): + active = True if interface['running'] == 'true' else False + else: + self.logger.warning(F'Requested state of unknown interface {port}') + return None + return active + + def get_port_enabled(self, port): + active = False + interface = next((sub for sub in self._interfaces if sub['name'] == port), None) + if (interface): + active = False if interface['disabled'] == 'true' else True + else: + self.logger.warning(F'Requested enabled state of unknown interface {port}') + return None + return active + + def get_port_poe(self, port): + active = False + interface = next((sub for sub in self._interfaces if sub['name'] == port), None) + if (interface): + active = True if interface['poe'] == 'auto-on' else False + else: + self.logger.warning(F'Requested POE state of unknown interface {port}') + return None + return active + + def set_port_enabled(self, port, status): + """ + Enables or disables a switch port + + This method is called when a switch port active status should be changed. + + :param port: switch port to be updated + :param status: 'false' disables the port, 'true' enables it. Parameter must be a string. + """ + self.logger.debug(F"Setting active state of port {port} to {self.str2bool(status)}") + + # send the command to the device + try: + if (self.str2bool(status) == False): + self._api.get_binary_resource('/interface').call('disable', {'.id': port.encode()}) + else: + self._api.get_binary_resource('/interface').call('enable', {'.id': port.encode()}) + interface = next((sub for sub in self._interfaces if sub['name'] == port), None) + if (interface): + interface['disabled'] = 'true' if self.str2bool(status) == False else 'false' + except exceptions.RouterOsApiConnectionError: + self.logger.error('Failed to connect to MikroTik device') + return "NOK" + + except exceptions.RouterOsApiError as e: + self.logger.error(f'RouterOS API error: {e}') + return "NOK" + + return "OK" + + def set_port_poe(self, port, status): + """ + Enables or disables POE power of a switch port + + This method is called when a switch port POE status should be changed. + + :param port: switch port to be updated + :param status: 'false' disabled POE of the port, 'true' enables it. Parameter must be a string. + """ + self.logger.debug(F"Setting POE state of port {port} to {self.str2bool(status)}") + + # send the command to the device + try: + if self.str2bool(status) == True: + self._api.get_binary_resource('/interface/ethernet').call('set', {'.id': port.encode(), 'poe-out': b'auto-on'}) + else: + self._api.get_binary_resource('/interface/ethernet').call('set', {'.id': port.encode(), 'poe-out': b'off'}) + interface = next((sub for sub in self._interfaces if sub['name'] == port), None) + if (interface): + interface['poe'] = 'auto-on' if (self.str2bool(status) == True) else 'off' + except exceptions.RouterOsApiConnectionError: + self.logger.error('Failed to connect to MikroTik device') + return "NOK" + + except exceptions.RouterOsApiError as e: + self.logger.error(f'RouterOS API error: {e}') + return "NOK" + + return "OK" diff --git a/mikrotik/locale.yaml b/mikrotik/locale.yaml new file mode 100644 index 000000000..e4b0171dd --- /dev/null +++ b/mikrotik/locale.yaml @@ -0,0 +1,23 @@ +# translations for the web interface +plugin_translations: + # Translations for the plugin specially for the web interface + 'Port list': {'en': '=', 'de': 'Port Liste', 'fr': 'Liste des ports'} + + 'Default name': {'en': '=', 'de': 'Standardname', 'fr': 'Nom par défaut'} + 'Comment': {'en': '=', 'de': 'Kommentar', 'fr': 'Commentaire'} + 'Speed': {'en': '=', 'de': 'Geschwindigkeit', 'fr': 'Vitesse'} + 'Up': {'en': '=', 'de': 'Benutzt', 'fr': 'Utilisé'} + 'Enabled': {'en': '=', 'de': 'Aktiviert', 'fr': 'Activé'} + + 'POE Enabled': {'en': '=', 'de': 'POE aktiviert', 'fr': 'POE activé'} + 'Identity': {'en': '=', 'de': 'Identität', 'fr': 'Identité'} + 'API username': {'en': '=', 'de': 'API Benutzername', 'fr': "Nom de l'utilisateur API"} + 'Model': {'en': '=', 'de': 'Modell', 'fr': 'Modèle'} + 'Serial number': {'en': '=', 'de': 'Seriennummer', 'fr': 'Numéro de série'} + 'RouterOS version': {'en': '=', 'de': 'RouterOS Version', 'fr': 'Version de RouterOS'} + + # Alternative format for translations of longer texts: + 'Hier kommt der Inhalt des Webinterfaces hin.': + de: '=' + fr: "Voici viendra le contenue de l'interface web" + en: 'Here goes the content of the web interface.' diff --git a/mikrotik/plugin.yaml b/mikrotik/plugin.yaml new file mode 100755 index 000000000..7a2ec1ab6 --- /dev/null +++ b/mikrotik/plugin.yaml @@ -0,0 +1,175 @@ +# Metadata for the plugin +plugin: + # Global plugin attributes + type: interface + description: + de: "Mikrotik RouterOS Switch management" + en: "Mikrotik RouterOS switch management" + fr: "Gestion de commutateur Mikrotik basé sur RouterOS" + maintainer: Foxi352 + state: develop # change to ready when done with development + keywords: infrastructure network + version: 1.0.0 + sh_minversion: 1.9 + multi_instance: true + restartable: true + classname: MikrotikPlugin + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty) + + hostname: + type: str + mandatory: true + description: + de: "Hostname oder IP des Switches" + en: "Switch hostname or ip" + fr: "Nom d'hôte ou adresse ip du commutateur" + + port: + type: int + default: 8729 + description: + de: "API Port des Switches" + en: "Switch api port number" + fr: "Numéro de port de l'api du commutateur" + + username: + type: str + default: "admin" + description: + de: "API Benutzername" + en: "API username" + fr: "Nom d'utilisateur de l'API" + + password: + type: str + default: "" + description: + de: "API Password" + en: "API password" + fr: "Mot de passe de l'api" + + cycle: + type: int + default: 15 + description: + de: "Zeitlicher Abstand in Sekunden zwischen zwei Abfragen des Gerätes" + en: "Time in seconds between two queries of the device" + fr: "Temps en secondes entre deux requêtes de l'appareil" + +item_attributes: + mikrotik_port: + type: str + description: + de: "Name des zu adressierenden Geräteanschlusses, Beispiel: ether1" + en: "Name of the device port to be addressed, example: ether1" + fr: "Nom du port de l'appareil à adresser, exemple : ether1" + + mikrotik_parameter: + type: str + valid_list: + - "active" + - "enabled" + - "poe" + description: + de: "Parameter, auf den über den ausgewählten Anschluss zugegriffen werden soll" + en: "Parameter to be accessed on the selected port" + fr: "Paramètre à accéder sur le port sélectionné" + +item_structs: NONE + +plugin_functions: + get_port_status: + type: bool + + description: + en: "Get actual port state. True = up, False = down" + de: "Aktuellen Port Status abfragen. True = in Betrieb, False = Aus" + fr: "Obtenir l'état actuel du port. True = Utilisé, False = Éteint" + + parameters: + port: + type: str + description: + en: "Actual port name. If you renamed the switch port, use the new name you have it !" + de: "Aktueller Name des Anschlusses. Wenn Sie den Switchport umbenannt haben, verwenden Sie den neuen Namen, den Sie ihm gegeben haben!" + fr: "Nom actuel du port. Si vous avez renommé le port du commutateur, utilisez le nouveau nom que vous lui avez donné !" + + get_port_enabled: + type: bool + + description: + en: "Get port enabled state. True = enabled, False = disabled" + de: "Port Status abfragen. True = aktiviert, False = deaktiviert" + fr: "Obtenir l'état du port. True = activé, False = désactivé" + + parameters: + port: + type: str + description: + en: "Actual port name. If you renamed the switch port, use the new name you have it !" + de: "Aktueller Name des Anschlusses. Wenn Sie den Switchport umbenannt haben, verwenden Sie den neuen Namen, den Sie ihm gegeben haben!" + fr: "Nom actuel du port. Si vous avez renommé le port du commutateur, utilisez le nouveau nom que vous lui avez donné !" + + get_port_poe: + type: bool + + description: + en: "Get port POE state. True = enabled, False = disabled" + de: "Port POE Status abfragen. True = aktiviert, False = deaktiviert" + fr: "Obtenir l'état de POE du port. True = activé, False = désactivé" + + parameters: + port: + type: str + description: + en: "Actual port name. If you renamed the switch port, use the new name you have it !" + de: "Aktueller Name des Anschlusses. Wenn Sie den Switchport umbenannt haben, verwenden Sie den neuen Namen, den Sie ihm gegeben haben!" + fr: "Nom actuel du port. Si vous avez renommé le port du commutateur, utilisez le nouveau nom que vous lui avez donné !" + + set_port_enabled: + type: bool + + description: + en: "Enable or disable specific port" + de: "Anschluss aktivieren oder deaktivieren" + fr: "Activer ou désactiver un port spécifique" + + parameters: + port: + type: str + description: + en: "Actual port name. If you renamed the switch port, use the new name you have it !" + de: "Aktueller Name des Anschlusses. Wenn Sie den Switchport umbenannt haben, verwenden Sie den neuen Namen, den Sie ihm gegeben haben!" + fr: "Nom actuel du port. Si vous avez renommé le port du commutateur, utilisez le nouveau nom que vous lui avez donné !" + state: + type: bool + description: + en: "Wanted port enabled state (true = enabled, false = disabled)" + de: "Gewünschter Aktivierungsstatus (true = aktiviert, false = deaktiviert)" + fr: "État d'activation souhaité (true = activé, false = désactivé)" + + set_port_poe: + type: bool + + description: + en: "Set poe output for specific port" + de: "Poe Ausgabe für einen bestimmten Anschluss einstellen" + fr: "Définir la sortie poe pour un port spécifique" + + parameters: + port: + type: str + description: + en: "Actual port name. If you renamed the switch port, use the new name you have it !" + de: "Aktueller Name des Anschlusses. Wenn Sie den Switchport umbenannt haben, verwenden Sie den neuen Namen, den Sie ihm gegeben haben!" + fr: "Nom actuel du port. Si vous avez renommé le port du commutateur, utilisez le nouveau nom que vous lui avez donné !" + state: + type: bool + description: + en: "Wanted POE state (true = auto-on, false = off)" + de: "Gewünschter POE-Status (true = auto-on, false = off)" + fr: "État POE souhaité (true = auto-on, false = off)" + +logic_parameters: NONE diff --git a/mikrotik/requirements.txt b/mikrotik/requirements.txt new file mode 100644 index 000000000..f8c747c8d --- /dev/null +++ b/mikrotik/requirements.txt @@ -0,0 +1 @@ +RouterOS-7-api \ No newline at end of file diff --git a/mikrotik/user_doc.rst b/mikrotik/user_doc.rst new file mode 100644 index 000000000..7278cb249 --- /dev/null +++ b/mikrotik/user_doc.rst @@ -0,0 +1,26 @@ +.. index:: Plugins; mikrotik +.. index:: mikrotik + +======== +mikrotik +======== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Konfiguration +============= + +Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/mikrotik` beschrieben. + + +Web Interface +============= + +Hier werden Informationen zu relevanten Items sowie zum Interface angezeigt. diff --git a/mikrotik/webif/__init__.py b/mikrotik/webif/__init__.py new file mode 100755 index 000000000..811c2f8bf --- /dev/null +++ b/mikrotik/webif/__init__.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2023- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This file implements the web interface for the Sample plugin. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None, learn=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + + if (learn != None): + self.logger.debug(f"Found learn: {learn}") + + pagelength = self.plugin.get_parameter_value('webif_pagelength') + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=0) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + data = self.plugin._interfaces + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + return {} + + @cherrypy.expose + def set_port_enabled(self, port, status): + return self.plugin.set_port_enabled(port, status) + + @cherrypy.expose + def set_port_poe(self, port, status): + return self.plugin.set_port_poe(port, status) diff --git a/mikrotik/webif/static/img/plugin_logo.png b/mikrotik/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..e5637621d Binary files /dev/null and b/mikrotik/webif/static/img/plugin_logo.png differ diff --git a/operationlog/webif/static/img/readme.txt b/mikrotik/webif/static/img/readme.txt old mode 100755 new mode 100644 similarity index 100% rename from operationlog/webif/static/img/readme.txt rename to mikrotik/webif/static/img/readme.txt diff --git a/mikrotik/webif/templates/index.html b/mikrotik/webif/templates/index.html new file mode 100755 index 000000000..1b07305c7 --- /dev/null +++ b/mikrotik/webif/templates/index.html @@ -0,0 +1,490 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 5000 %} + + +{% set update_params = item_id %} + + +{% set buttons = true %} + + +{% set autorefresh_buttons = true %} + + +{% set reload_button = true %} + + +{% set close_button = false %} + + +{% set row_count = true %} + + +{% set initial_update = false %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Host') }}{{ p._device.host }}:{{ p._device.port }} {% if p._device.use_ssl == True %}(TLS){% endif %}{{ _('Model') }}{{ p._model }}
{{ _('Identity') }}{{ p._identity }}{{ _('Serial number') }}{{ p._serial }}
{{ _('API username') }}{{ p._device.username }}{{ _('RouterOS version') }}{{ p._osversion }}
+{% endblock headtable %} + + + +{% block buttons %} +{% if 1==2 %} +
+ +
+{% endif %} +{% endblock %} + + +{% set tabcount = 2 %} + + + +{% if item_count==0 %} + {% set start_tab = 1 %} +{% endif %} + + + +{% set tab1title = "" ~ _('Items') ~ " (" ~ item_count ~ ")" %} +{% block bodytab1 %} + + + + + + + + + + + + + + + {% for item in p._items %} + + + + + + + + + + + {% endfor %} + +
{{ _('Item') }}{{ _('Port') }}{{ _('Parameter') }}{{ _('Wert') }}
{{ item._path }}{{ p.get_iattr_value(item.conf, 'mikrotik_port') }}{{ p.get_iattr_value(item.conf, 'mikrotik_parameter') }} + {% if p.get_iattr_value(item.conf, 'mikrotik_parameter') == 'active' %} + {% if item() == True %} + + {% else %} + + {% endif %} + {% elif p.get_iattr_value(item.conf, 'mikrotik_parameter') == 'enabled' %} + {% if item() == True %} + + {% else %} + + {% endif %} + {% elif p.get_iattr_value(item.conf, 'mikrotik_parameter') == 'poe' %} + {% if item() == True %} + + {% else %} + + {% endif %} + {% else %} + {{ item() }} + {% endif %} +
+ +{% endblock bodytab1 %} + + + +{% set tab2title = "" ~ _('Port list') ~ " (" ~ len(p._interfaces) ~ ")" %} +{% block bodytab2 %} + + + + + + + + + + + + + + + + + + + + + {% for interface in p._interfaces %} + + + + + + + + + + + + + + + + {% endfor %} + +
{{ _('Id') }}{{ _('Name') }}{{ _('Default name') }}{{ _('Comment') }}{{ _('Speed') }}{{ _('PVID') }}{{ _('Up') }}{{ _('Enabled') }}{{ _('POE Enabled') }}
{{ interface.id }}{{ interface.name }}{{ interface.defaultname }} + {{ interface.comment }} + + {{ interface.speed }}{{ interface.pvid }} + {% if interface.running == 'true' %} + + {% else %} + + {% endif %} + + {% if interface.disabled == 'false' %} + + {% else %} + + {% endif %} + + {% if interface.poe == 'auto-on' %} + + {% elif interface.poe == 'off' %} + + {% else %} + - + {% endif %} +
+{% endblock bodytab2 %} diff --git a/modbus_tcp/__init__.py b/modbus_tcp/__init__.py index 53858f52a..2c79e019a 100755 --- a/modbus_tcp/__init__.py +++ b/modbus_tcp/__init__.py @@ -3,11 +3,11 @@ ######################################################################### # Copyright 2022 De Filippis Ivan # Copyright 2022 Ronny Schulz +# Copyright 2023 Bernd Meiners ######################################################################### # This file is part of SmartHomeNG. # -# Sample plugin for new plugins to run with SmartHomeNG version 1.4 and -# upwards. +# Modbus_TCP plugin for SmartHomeNG # # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -35,16 +35,7 @@ from pymodbus.payload import BinaryPayloadDecoder from pymodbus.payload import BinaryPayloadBuilder -# pymodbus library from https://github.com/riptideio/pymodbus -from pymodbus.version import version -pymodbus_baseversion = int(version.short().split('.')[0]) - -if pymodbus_baseversion > 2: - # for newer versions of pymodbus - from pymodbus.client.tcp import ModbusTcpClient -else: - # for older versions of pymodbus - from pymodbus.client.sync import ModbusTcpClient +from pymodbus.client.tcp import ModbusTcpClient AttrAddress = 'modBusAddress' AttrType = 'modBusDataType' @@ -55,14 +46,19 @@ AttrObjectType = 'modBusObjectType' AttrDirection = 'modBusDirection' + class modbus_tcp(SmartPlugin): - ALLOW_MULTIINSTANCE = True - PLUGIN_VERSION = '1.0.8' + """ + This class provides a Plugin for SmarthomeNG to read and or write to modbus + devices. + """ + + PLUGIN_VERSION = '1.0.10' def __init__(self, sh, *args, **kwargs): """ - Initializes the plugin. The parameters describe for this method are pulled from the entry in plugin.conf. - :param sh: The instance of the smarthome object, save it for later references + Initializes the Modbus TCP plugin. + The parameters are retrieved from get_parameter_value(parameter_name) """ self.logger.info('Init modbus_tcp plugin') @@ -71,8 +67,17 @@ def __init__(self, sh, *args, **kwargs): super().__init__() self._host = self.get_parameter_value('host') - self._port = int(self.get_parameter_value('port')) - self._cycle = int(self.get_parameter_value('cycle')) + self._port = self.get_parameter_value('port') + + self._cycle = self.get_parameter_value('cycle') # the frequency in seconds how often the device should be accessed + if self._cycle == 0: + self._cycle = None + self._crontab = self.get_parameter_value('crontab') # the more complex way to specify the device query frequency + if self._crontab == '': + self._crontab = None + if not (self._cycle or self._crontab): + self.logger.error(f"{self.get_fullname()}: no update cycle or crontab set. Modbus will not be queried automatically") + self._slaveUnit = int(self.get_parameter_value('slaveUnit')) self._slaveUnitRegisterDependend = False @@ -87,25 +92,30 @@ def __init__(self, sh, *args, **kwargs): self.init_webinterface(WebInterface) - return def run(self): """ Run method for the plugin """ - self._sh.scheduler.add('modbusTCP_poll_device', self.poll_device, cycle=self._cycle) + self.logger.debug(f"Plugin '{self.get_fullname()}': run method called") self.alive = True + if self._cycle or self._crontab: + # self.get_shortname() + self.scheduler_add('poll_device_' + self._host, self.poll_device, cycle=self._cycle, cron=self._crontab, prio=5) + #self.scheduler_add(self.get_shortname(), self._update_values_callback, prio=5, cycle=self._update_cycle, cron=self._update_crontab, next=shtime.now()) + self.logger.debug(f"Plugin '{self.get_fullname()}': run method finished") def stop(self): """ Stop method for the plugin """ self.alive = False - self.logger.debug("stop modbus_tcp plugin") - self.scheduler_remove('modbusTCP_poll_device') + self.logger.debug(f"Plugin '{self.get_fullname()}': stop method called") + self.scheduler_remove('poll_device_' + self._host) self._Mclient.close() self.connected = False + self.logger.debug(f"Plugin '{self.get_fullname()}': stop method finished") def parse_item(self, item): """ @@ -116,7 +126,7 @@ def parse_item(self, item): :param item: The item to process. """ if self.has_iattr(item.conf, AttrAddress): - self.logger.debug("parse item: {0}".format(item)) + self.logger.debug(f"parse item: {item}") regAddr = int(self.get_iattr_value(item.conf, AttrAddress)) objectType = 'HoldingRegister' @@ -137,7 +147,7 @@ def parse_item(self, item): if self.has_iattr(item.conf, AttrObjectType): objectType = self.get_iattr_value(item.conf, AttrObjectType) - reg = str(objectType) # dictionary key: objectType.regAddr.slaveUnit // HoldingRegister.528.1 + reg = str(objectType) # dictionary key: objectType.regAddr.slaveUnit // HoldingRegister.528.1 reg += '.' reg += str(regAddr) reg += '.' @@ -151,34 +161,36 @@ def parse_item(self, item): byteOrder = self.get_iattr_value(item.conf, AttrByteOrder) if self.has_iattr(item.conf, AttrWordOrder): wordOrder = self.get_iattr_value(item.conf, AttrWordOrder) - if byteOrder == 'Endian.Big': # Von String in Endian-Konstante "umwandeln" - byteOrder = Endian.Big + if byteOrder == 'Endian.Big': # Von String in Endian-Konstante "umwandeln" + byteOrder = Endian.BIG elif byteOrder == 'Endian.Little': - byteOrder = Endian.Little + byteOrder = Endian.LITTLE else: - byteOrder = Endian.Big + byteOrder = Endian.BIG self.logger.warning("Invalid byte order -> default(Endian.Big) is used") - if wordOrder == 'Endian.Big': # Von String in Endian-Konstante "umwandeln" - wordOrder = Endian.Big + if wordOrder == 'Endian.Big': # Von String in Endian-Konstante "umwandeln" + wordOrder = Endian.BIG elif wordOrder == 'Endian.Little': - wordOrder = Endian.Little + wordOrder = Endian.LITTLE else: - wordOrder = Endian.Big + wordOrder = Endian.BIG self.logger.warning("Invalid byte order -> default(Endian.Big) is used") - regPara = {'regAddr': regAddr, 'slaveUnit': slaveUnit, 'dataType': dataType, 'factor': factor, 'byteOrder': byteOrder, - 'wordOrder': wordOrder, 'item': item, 'value': value, 'objectType': objectType, 'dataDir': dataDirection } + regPara = {'regAddr': regAddr, 'slaveUnit': slaveUnit, 'dataType': dataType, 'factor': factor, + 'byteOrder': byteOrder, + 'wordOrder': wordOrder, 'item': item, 'value': value, 'objectType': objectType, + 'dataDir': dataDirection} if dataDirection == 'read': self._regToRead.update({reg: regPara}) - self.logger.info("parse item: {0} Attributes {1}".format(item, regPara)) + self.logger.info(f"parse item: {item} Attributes {regPara}") elif dataDirection == 'read_write': self._regToRead.update({reg: regPara}) self._regToWrite.update({reg: regPara}) - self.logger.info("parse item: {0} Attributes {1}".format(item, regPara)) + self.logger.info(f"parse item: {item} Attributes {regPara}") return self.update_item else: - self.logger.warning("Invalid data direction -> default(read) is used") - self._regToRead.update({reg: regPara}) + self.logger.warning("Invalid data direction -> default(read) is used") + self._regToRead.update({reg: regPara}) def poll_device(self): """ @@ -192,19 +204,18 @@ def poll_device(self): with self.lock: try: if self._Mclient.connect(): - self.logger.info("connected to {0}".format(str(self._Mclient))) + self.logger.info(f"connected to {str(self._Mclient)}") self.connected = True else: - self.logger.error("could not connect to {0}:{1}".format(self._host, self._port)) + self.logger.error(f"could not connect to {self._host}:{self._port}") self.connected = False return except Exception as e: - self.logger.error("connection expection: {0} {1}".format(str(self._Mclient), e)) + self.logger.error(f"connection exception: {str(self._Mclient)} {e}") self.connected = False return - startTime = datetime.now() regCount = 0 try: @@ -212,14 +223,14 @@ def poll_device(self): with self.lock: regAddr = regPara['regAddr'] value = self.__read_Registers(regPara) - #self.logger.debug("value readed: {0} type: {1}".format(value, type(value))) + # self.logger.debug(f"value read: {value} type: {type(value)}") if value is not None: item = regPara['item'] if regPara['factor'] != 1: value = value * regPara['factor'] - #self.logger.debug("value {0} multiply by: {1}".format(value, regPara['factor'])) + # self.logger.debug(f"value {value} multiply by: {regPara['factor']}") item(value, self.get_fullname()) - regCount+=1 + regCount += 1 if 'read_dt' in regPara: regPara['last_read_dt'] = regPara['read_dt'] @@ -232,13 +243,13 @@ def poll_device(self): endTime = datetime.now() duration = endTime - startTime if regCount > 0: - self._pollStatus['last_dt']=datetime.now() - self._pollStatus['regCount']=regCount - self.logger.debug("poll_device: {0} register readed requed-time: {1}".format(regCount, duration)) + self._pollStatus['last_dt'] = datetime.now() + self._pollStatus['regCount'] = regCount + self.logger.debug(f"poll_device: {regCount} register read required {duration} seconds") except Exception as e: - self.logger.error("something went wrong in the poll_device function: {0}".format(e)) + self.logger.error(f"something went wrong in the poll_device function: {e}") - # called each time an item changes. + # called each time an item changes. def update_item(self, item, caller=None, source=None, dest=None): """ Item has been updated @@ -256,22 +267,21 @@ def update_item(self, item, caller=None, source=None, dest=None): slaveUnit = self._slaveUnit dataDirection = 'read' - if caller == self.get_fullname(): - #self.logger.debug('item was changed by the plugin itself - caller:{0} source:{1} dest:{2} '.format(caller, source, dest)) + # self.logger.debug(f'item was changed by the plugin itself - caller:{caller} source:{source} dest:{dest}') return if self.has_iattr(item.conf, AttrDirection): dataDirection = self.get_iattr_value(item.conf, AttrDirection) if not dataDirection == 'read_write': - self.logger.debug('update_item:{0} Writing is not allowed - selected dataDirection:{1}'.format(item, dataDirection)) + self.logger.debug(f'update_item: {item} Writing is not allowed - selected dataDirection:{dataDirection}') return - #else: - # self.logger.debug('update_item:{0} dataDirection:{1}'.format(item, dataDirection)) + # else: + # self.logger.debug(f'update_item:{item} dataDirection: {dataDirection}') if self.has_iattr(item.conf, AttrAddress): regAddr = int(self.get_iattr_value(item.conf, AttrAddress)) else: - self.logger.warning('update_item:{0} Item has no register address'.format(item)) + self.logger.warning(f'update_item:{item} Item has no register address') return if self.has_iattr(item.conf, AttrSlaveUnit): slaveUnit = int(self.get_iattr_value(item.conf, AttrSlaveUnit)) @@ -282,7 +292,7 @@ def update_item(self, item, caller=None, source=None, dest=None): else: return - reg = str(objectType) # Dict-key: HoldingRegister.528.1 *** objectType.regAddr.slaveUnit *** + reg = str(objectType) # Dict-key: HoldingRegister.528.1 *** objectType.regAddr.slaveUnit *** reg += '.' reg += str(regAddr) reg += '.' @@ -290,18 +300,18 @@ def update_item(self, item, caller=None, source=None, dest=None): if reg in self._regToWrite: with self.lock: regPara = self._regToWrite[reg] - self.logger.debug('update_item:{0} value:{1} regToWrite:{2}'.format(item, item(), reg)) + self.logger.debug(f'update_item:{item} value:{item()} regToWrite: {reg}') try: if self._Mclient.connect(): - self.logger.info("connected to {0}".format(str(self._Mclient))) + self.logger.info(f"connected to {str(self._Mclient)}") self.connected = True else: - self.logger.error("could not connect to {0}:{1}".format(self._host, self._port)) + self.logger.error(f"could not connect to {self._host}:{self._port}") self.connected = False return except Exception as e: - self.logger.error("connection expection: {0} {1}".format(str(self._Mclient), e)) + self.logger.error(f"connection exception: {str(self._Mclient)} {e}") self.connected = False return @@ -310,7 +320,7 @@ def update_item(self, item, caller=None, source=None, dest=None): try: self.__write_Registers(regPara, item()) except Exception as e: - self.logger.error("something went wrong in the __write_Registers function: {0}".format(e)) + self.logger.error(f"something went wrong in the __write_Registers function: {e}") def __write_Registers(self, regPara, value): objectType = regPara['objectType'] @@ -319,8 +329,8 @@ def __write_Registers(self, regPara, value): bo = regPara['byteOrder'] wo = regPara['wordOrder'] dataTypeStr = regPara['dataType'] - dataType = ''.join(filter(str.isalpha, dataTypeStr)) # vom dataType die Ziffen entfernen z.B. uint16 = uint - registerCount = 0 # Anzahl der zu schreibenden Register (Words) + dataType = ''.join(filter(str.isalpha, dataTypeStr)) # vom dataType die Ziffen entfernen z.B. uint16 = uint + registerCount = 0 # Anzahl der zu schreibenden Register (Words) try: bits = int(''.join(filter(str.isdigit, dataTypeStr))) # bit-Zahl aus aus dataType z.B. uint16 = 16 @@ -328,15 +338,15 @@ def __write_Registers(self, regPara, value): bits = 16 if dataType.lower() == 'string': - registerCount = int(bits/2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount + registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount else: - registerCount = int(bits/16) + registerCount = int(bits / 16) if regPara['factor'] != 1: - #self.logger.debug("value {0} divided by: {1}".format(value, regPara['factor'])) - value = value * (1/regPara['factor']) + # self.logger.debug(f"value {value} divided by: {regPara['factor']}") + value = value * (1 / regPara['factor']) - self.logger.debug("write {0} to {1}.{2}.{3} (address.slaveUnit) dataType:{4}".format(value, objectType, address, slaveUnit, dataTypeStr)) + self.logger.debug(f"write {value} to {objectType}.{address}.{address} (address.slaveUnit) dataType:{dataTypeStr}") builder = BinaryPayloadBuilder(byteorder=bo, wordorder=wo) if dataType.lower() == 'uint': @@ -347,7 +357,7 @@ def __write_Registers(self, regPara, value): elif bits == 64: builder.add_64bit_uint(int(value)) else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") elif dataType.lower() == 'int': if bits == 16: builder.add_16bit_int(int(value)) @@ -356,28 +366,28 @@ def __write_Registers(self, regPara, value): elif bits == 64: builder.add_64bit_int(int(value)) else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") elif dataType.lower() == 'float': if bits == 32: - builder.add_32bit_float(value) - if bits == 64: - builder.add_64bit_float(value) + builder.add_32bit_float(value) + elif bits == 64: + builder.add_64bit_float(value) else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") elif dataType.lower() == 'string': builder.add_string(value) elif dataType.lower() == 'bit': if objectType == 'Coil' or objectType == 'DiscreteInput': - if not type(value) == type(True): # test is boolean - self.logger.error("Value is not boolean: {0}".format(value)) + if not isinstance(value, bool): # test is boolean + self.logger.error(f"Value is not boolean: {value}") return else: - if set(value).issubset({'0', '1'}) and bool(value): # test is bit-string '00110101' + if set(value).issubset({'0', '1'}) and bool(value): # test is bit-string '00110101' builder.add_bits(value) else: - self.logger.error("Value is not a bitstring: {0}".format(value)) + self.logger.error(f"Value is not a bitstring: {value}") else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") return None if objectType == 'Coil': @@ -386,15 +396,15 @@ def __write_Registers(self, regPara, value): registers = builder.to_registers() result = self._Mclient.write_registers(address, registers, unit=slaveUnit) elif objectType == 'DiscreteInput': - self.logger.warning("this object type cannot be written {0}:{1} slaveUnit:{2}".format(objectType, address, slaveUnit)) + self.logger.warning(f"this object type cannot be written {objectType}:{address} slaveUnit:{slaveUnit}") return elif objectType == 'InputRegister': - self.logger.warning("this object type cannot be written {0}:{1} slaveUnit:{2}".format(objectType, address, slaveUnit)) + self.logger.warning(f"this object type cannot be written {objectType}:{address} slaveUnit:{slaveUnit}") return else: return if result.isError(): - self.logger.error("write error: {0} {1}.{2}.{3} (address.slaveUnit)".format(result, objectType, address, slaveUnit)) + self.logger.error(f"write error: {result} {objectType}.{address}.{slaveUnit} (address.slaveUnit)") return None if 'write_dt' in regPara: @@ -409,9 +419,8 @@ def __write_Registers(self, regPara, value): else: regPara.update({'write_value': value}) - #regPara['write_dt'] = datetime.now() - #regPara['write_value'] = value - + # regPara['write_dt'] = datetime.now() + # regPara['write_value'] = value def __read_Registers(self, regPara): objectType = regPara['objectType'] @@ -430,41 +439,29 @@ def __read_Registers(self, regPara): bits = 16 if dataType.lower() == 'string': - registerCount = int(bits/2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount + registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount else: - registerCount = int(bits/16) + registerCount = int(bits / 16) if self.connected == False: - self.logger.error(" not connect {0}:{1}".format(self._host, self._port)) + self.logger.error(f"not connected to {self._host}:{self._port}") return None - #self.logger.debug("read {0}.{1}.{2} (address.slaveUnit) regCount:{3}".format(objectType, address, slaveUnit, registerCount)) + # self.logger.debug(f"read {objectType}.{address}.{slaveUnit} (address.slaveUnit) regCount:{registerCount}") if objectType == 'Coil': - if pymodbus_baseversion > 2: - result = self._Mclient.read_coils(address, registerCount, slave=slaveUnit) - else: - result = self._Mclient.read_coils(address, registerCount, unit=slaveUnit) + result = self._Mclient.read_coils(address, registerCount, slave=slaveUnit) elif objectType == 'DiscreteInput': - if pymodbus_baseversion > 2: - result = self._Mclient.read_discrete_inputs(address, registerCount, slave=slaveUnit) - else: - result = self._Mclient.read_discrete_inputs(address, registerCount, unit=slaveUnit) + result = self._Mclient.read_discrete_inputs(address, registerCount, slave=slaveUnit) elif objectType == 'InputRegister': - if pymodbus_baseversion > 2: - result = self._Mclient.read_input_registers(address, registerCount, slave=slaveUnit) - else: - result = self._Mclient.read_input_registers(address, registerCount, unit=slaveUnit) + result = self._Mclient.read_input_registers(address, registerCount, slave=slaveUnit) elif objectType == 'HoldingRegister': - if pymodbus_baseversion > 2: - result = self._Mclient.read_holding_registers(address, registerCount, slave=slaveUnit) - else: - result = self._Mclient.read_holding_registers(address, registerCount, unit=slaveUnit) + result = self._Mclient.read_holding_registers(address, registerCount, slave=slaveUnit) else: - self.logger.error("{0} not supported: {1}".format(AttrObjectType, objectType)) + self.logger.error(f"{AttrObjectType} not supported: {objectType}") return None if result.isError(): - self.logger.error("read error: {0} {1}.{2}.{3} (address.slaveUnit) regCount:{4}".format(result, objectType, address, slaveUnit, registerCount)) + self.logger.error(f"read error: {result} {objectType}.{address}.{slaveUnit} (address.slaveUnit) regCount:{registerCount}") return None if objectType == 'Coil': @@ -472,11 +469,11 @@ def __read_Registers(self, regPara): elif objectType == 'DiscreteInput': value = result.bits[0] elif objectType == 'InputRegister': - decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=bo,wordorder=wo) + decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=bo, wordorder=wo) else: - decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=bo,wordorder=wo) + decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=bo, wordorder=wo) - self.logger.debug("read {0}.{1}.{2} (address.slaveUnit) regCount:{3} result:{4}".format(objectType, address, slaveUnit, registerCount, result)) + self.logger.debug(f"read {objectType}.{address}.{slaveUnit} (address.slaveUnit) regCount:{registerCount} result:{result}") if dataType.lower() == 'uint': if bits == 16: @@ -486,7 +483,7 @@ def __read_Registers(self, regPara): elif bits == 64: return decoder.decode_64bit_uint() else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") elif dataType.lower() == 'int': if bits == 16: return decoder.decode_16bit_int() @@ -495,25 +492,25 @@ def __read_Registers(self, regPara): elif bits == 64: return decoder.decode_64bit_int() else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") elif dataType.lower() == 'float': if bits == 32: return decoder.decode_32bit_float() - if bits == 64: + elif bits == 64: return decoder.decode_64bit_float() else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") elif dataType.lower() == 'string': # bei string: bits = bytes !! string16 -> 16Byte ret = decoder.decode_string(bits) - return str( ret, 'ASCII') + return str(ret, 'ASCII') elif dataType.lower() == 'bit': if objectType == 'Coil' or objectType == 'DiscreteInput': - #self.logger.debug("readed bit value: {0}".format(value)) + # self.logger.debug(f"read bit value: {value}") return value else: - self.logger.debug("readed bits values: {0}".format(value.decode_bits())) + self.logger.debug(f"read bits values: {value.decode_bits()}") return decoder.decode_bits() else: - self.logger.error("Number of bits or datatype not supported : {0}".format(dataTypeStr)) + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") return None diff --git a/modbus_tcp/_pv_1_0_9/__init__.py b/modbus_tcp/_pv_1_0_9/__init__.py new file mode 100644 index 000000000..b4b32e836 --- /dev/null +++ b/modbus_tcp/_pv_1_0_9/__init__.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2022 De Filippis Ivan +# Copyright 2022 Ronny Schulz +# Copyright 2023 Bernd Meiners +######################################################################### +# This file is part of SmartHomeNG. +# +# Modbus_TCP plugin for SmartHomeNG +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from lib.model.smartplugin import * +from lib.item import Items +from datetime import datetime +import threading + +from .webif import WebInterface + +from pymodbus.constants import Endian +from pymodbus.payload import BinaryPayloadDecoder +from pymodbus.payload import BinaryPayloadBuilder + +# pymodbus library from https://github.com/riptideio/pymodbus +from pymodbus.version import version + +pymodbus_baseversion = int(version.short().split('.')[0]) + +if pymodbus_baseversion > 2: + # for newer versions of pymodbus + from pymodbus.client.tcp import ModbusTcpClient +else: + # for older versions of pymodbus + from pymodbus.client.sync import ModbusTcpClient + +AttrAddress = 'modBusAddress' +AttrType = 'modBusDataType' +AttrFactor = 'modBusFactor' +AttrByteOrder = 'modBusByteOrder' +AttrWordOrder = 'modBusWordOrder' +AttrSlaveUnit = 'modBusUnit' +AttrObjectType = 'modBusObjectType' +AttrDirection = 'modBusDirection' + + +class modbus_tcp(SmartPlugin): + """ + This class provides a Plugin for SmarthomeNG to read and or write to modbus + devices. + """ + + PLUGIN_VERSION = '1.0.9' + + def __init__(self, sh, *args, **kwargs): + """ + Initializes the Modbus TCP plugin. + The parameters are retrieved from get_parameter_value(parameter_name) + """ + + self.logger.info('Init modbus_tcp plugin') + + # Call init code of parent class (SmartPlugin) + super().__init__() + + self._host = self.get_parameter_value('host') + self._port = self.get_parameter_value('port') + + self._cycle = self.get_parameter_value('cycle') # the frequency in seconds how often the device should be accessed + if self._cycle == 0: + self._cycle = None + self._crontab = self.get_parameter_value('crontab') # the more complex way to specify the device query frequency + if self._crontab == '': + self._crontab = None + if not (self._cycle or self._crontab): + self.logger.error(f"{self.get_fullname()}: no update cycle or crontab set. Modbus will not be queried automatically") + + self._slaveUnit = int(self.get_parameter_value('slaveUnit')) + self._slaveUnitRegisterDependend = False + + self._sh = sh + self._regToRead = {} + self._regToWrite = {} + self._pollStatus = {} + self.connected = False + + self._Mclient = ModbusTcpClient(self._host, port=self._port) + self.lock = threading.Lock() + + self.init_webinterface(WebInterface) + + return + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug(f"Plugin '{self.get_fullname()}': run method called") + self.alive = True + if self._cycle or self._crontab: + # self.get_shortname() + self.scheduler_add('poll_device_' + self._host, self.poll_device, cycle=self._cycle, cron=self._crontab, prio=5) + #self.scheduler_add(self.get_shortname(), self._update_values_callback, prio=5, cycle=self._update_cycle, cron=self._update_crontab, next=shtime.now()) + self.logger.debug(f"Plugin '{self.get_fullname()}': run method finished") + + def stop(self): + """ + Stop method for the plugin + """ + self.alive = False + self.logger.debug(f"Plugin '{self.get_fullname()}': stop method called") + self.scheduler_remove('poll_device_' + self._host) + self._Mclient.close() + self.connected = False + self.logger.debug(f"Plugin '{self.get_fullname()}': stop method finished") + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + The plugin can, corresponding to its attribute keywords, decide what to do with + the item in future, like adding it to an internal array for future reference + + :param item: The item to process. + """ + if self.has_iattr(item.conf, AttrAddress): + self.logger.debug(f"parse item: {item}") + regAddr = int(self.get_iattr_value(item.conf, AttrAddress)) + + objectType = 'HoldingRegister' + value = item() + dataType = 'uint16' + factor = 1 + byteOrder = 'Endian.Big' + wordOrder = 'Endian.Big' + slaveUnit = self._slaveUnit + dataDirection = 'read' + + if self.has_iattr(item.conf, AttrType): + dataType = self.get_iattr_value(item.conf, AttrType) + if self.has_iattr(item.conf, AttrSlaveUnit): + slaveUnit = int(self.get_iattr_value(item.conf, AttrSlaveUnit)) + if (slaveUnit) != self._slaveUnit: + self._slaveUnitRegisterDependend = True + if self.has_iattr(item.conf, AttrObjectType): + objectType = self.get_iattr_value(item.conf, AttrObjectType) + + reg = str(objectType) # dictionary key: objectType.regAddr.slaveUnit // HoldingRegister.528.1 + reg += '.' + reg += str(regAddr) + reg += '.' + reg += str(slaveUnit) + + if self.has_iattr(item.conf, AttrDirection): + dataDirection = self.get_iattr_value(item.conf, AttrDirection) + if self.has_iattr(item.conf, AttrFactor): + factor = float(self.get_iattr_value(item.conf, AttrFactor)) + if self.has_iattr(item.conf, AttrByteOrder): + byteOrder = self.get_iattr_value(item.conf, AttrByteOrder) + if self.has_iattr(item.conf, AttrWordOrder): + wordOrder = self.get_iattr_value(item.conf, AttrWordOrder) + if byteOrder == 'Endian.Big': # Von String in Endian-Konstante "umwandeln" + byteOrder = Endian.Big + elif byteOrder == 'Endian.Little': + byteOrder = Endian.Little + else: + byteOrder = Endian.Big + self.logger.warning("Invalid byte order -> default(Endian.Big) is used") + if wordOrder == 'Endian.Big': # Von String in Endian-Konstante "umwandeln" + wordOrder = Endian.Big + elif wordOrder == 'Endian.Little': + wordOrder = Endian.Little + else: + wordOrder = Endian.Big + self.logger.warning("Invalid byte order -> default(Endian.Big) is used") + + regPara = {'regAddr': regAddr, 'slaveUnit': slaveUnit, 'dataType': dataType, 'factor': factor, + 'byteOrder': byteOrder, + 'wordOrder': wordOrder, 'item': item, 'value': value, 'objectType': objectType, + 'dataDir': dataDirection} + if dataDirection == 'read': + self._regToRead.update({reg: regPara}) + self.logger.info(f"parse item: {item} Attributes {regPara}") + elif dataDirection == 'read_write': + self._regToRead.update({reg: regPara}) + self._regToWrite.update({reg: regPara}) + self.logger.info(f"parse item: {item} Attributes {regPara}") + return self.update_item + else: + self.logger.warning("Invalid data direction -> default(read) is used") + self._regToRead.update({reg: regPara}) + + def poll_device(self): + """ + Polls for updates of the device + + This method is only needed, if the device (hardware/interface) does not propagate + changes on it's own, but has to be polled to get the actual status. + It is called by the scheduler which is set within run() method. + """ + + with self.lock: + try: + if self._Mclient.connect(): + self.logger.info(f"connected to {str(self._Mclient)}") + self.connected = True + else: + self.logger.error(f"could not connect to {self._host}:{self._port}") + self.connected = False + return + + except Exception as e: + self.logger.error(f"connection exception: {str(self._Mclient)} {e}") + self.connected = False + return + + startTime = datetime.now() + regCount = 0 + try: + for reg, regPara in self._regToRead.items(): + with self.lock: + regAddr = regPara['regAddr'] + value = self.__read_Registers(regPara) + # self.logger.debug(f"value read: {value} type: {type(value)}") + if value is not None: + item = regPara['item'] + if regPara['factor'] != 1: + value = value * regPara['factor'] + # self.logger.debug(f"value {value} multiply by: {regPara['factor']}") + item(value, self.get_fullname()) + regCount += 1 + + if 'read_dt' in regPara: + regPara['last_read_dt'] = regPara['read_dt'] + + if 'value' in regPara: + regPara['last_value'] = regPara['value'] + + regPara['read_dt'] = datetime.now() + regPara['value'] = value + endTime = datetime.now() + duration = endTime - startTime + if regCount > 0: + self._pollStatus['last_dt'] = datetime.now() + self._pollStatus['regCount'] = regCount + self.logger.debug(f"poll_device: {regCount} register read required {duration} seconds") + except Exception as e: + self.logger.error(f"something went wrong in the poll_device function: {e}") + + # called each time an item changes. + def update_item(self, item, caller=None, source=None, dest=None): + """ + Item has been updated + + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that + is managed by this plugin. + + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + objectType = 'HoldingRegister' + slaveUnit = self._slaveUnit + dataDirection = 'read' + + if caller == self.get_fullname(): + # self.logger.debug(f'item was changed by the plugin itself - caller:{caller} source:{source} dest:{dest}') + return + + if self.has_iattr(item.conf, AttrDirection): + dataDirection = self.get_iattr_value(item.conf, AttrDirection) + if not dataDirection == 'read_write': + self.logger.debug(f'update_item: {item} Writing is not allowed - selected dataDirection:{dataDirection}') + return + # else: + # self.logger.debug(f'update_item:{item} dataDirection: {dataDirection}') + if self.has_iattr(item.conf, AttrAddress): + regAddr = int(self.get_iattr_value(item.conf, AttrAddress)) + else: + self.logger.warning(f'update_item:{item} Item has no register address') + return + if self.has_iattr(item.conf, AttrSlaveUnit): + slaveUnit = int(self.get_iattr_value(item.conf, AttrSlaveUnit)) + if (slaveUnit) != self._slaveUnit: + self._slaveUnitRegisterDependend = True + if self.has_iattr(item.conf, AttrObjectType): + objectType = self.get_iattr_value(item.conf, AttrObjectType) + else: + return + + reg = str(objectType) # Dict-key: HoldingRegister.528.1 *** objectType.regAddr.slaveUnit *** + reg += '.' + reg += str(regAddr) + reg += '.' + reg += str(slaveUnit) + if reg in self._regToWrite: + with self.lock: + regPara = self._regToWrite[reg] + self.logger.debug(f'update_item:{item} value:{item()} regToWrite: {reg}') + try: + if self._Mclient.connect(): + self.logger.info(f"connected to {str(self._Mclient)}") + self.connected = True + else: + self.logger.error(f"could not connect to {self._host}:{self._port}") + self.connected = False + return + + except Exception as e: + self.logger.error(f"connection exception: {str(self._Mclient)} {e}") + self.connected = False + return + + startTime = datetime.now() + regCount = 0 + try: + self.__write_Registers(regPara, item()) + except Exception as e: + self.logger.error(f"something went wrong in the __write_Registers function: {e}") + + def __write_Registers(self, regPara, value): + objectType = regPara['objectType'] + address = regPara['regAddr'] + slaveUnit = regPara['slaveUnit'] + bo = regPara['byteOrder'] + wo = regPara['wordOrder'] + dataTypeStr = regPara['dataType'] + dataType = ''.join(filter(str.isalpha, dataTypeStr)) # vom dataType die Ziffen entfernen z.B. uint16 = uint + registerCount = 0 # Anzahl der zu schreibenden Register (Words) + + try: + bits = int(''.join(filter(str.isdigit, dataTypeStr))) # bit-Zahl aus aus dataType z.B. uint16 = 16 + except: + bits = 16 + + if dataType.lower() == 'string': + registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount + else: + registerCount = int(bits / 16) + + if regPara['factor'] != 1: + # self.logger.debug(f"value {value} divided by: {regPara['factor']}") + value = value * (1 / regPara['factor']) + + self.logger.debug(f"write {value} to {objectType}.{address}.{address} (address.slaveUnit) dataType:{dataTypeStr}") + builder = BinaryPayloadBuilder(byteorder=bo, wordorder=wo) + + if dataType.lower() == 'uint': + if bits == 16: + builder.add_16bit_uint(int(value)) + elif bits == 32: + builder.add_32bit_uint(int(value)) + elif bits == 64: + builder.add_64bit_uint(int(value)) + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + elif dataType.lower() == 'int': + if bits == 16: + builder.add_16bit_int(int(value)) + elif bits == 32: + builder.add_32bit_int(int(value)) + elif bits == 64: + builder.add_64bit_int(int(value)) + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + elif dataType.lower() == 'float': + if bits == 32: + builder.add_32bit_float(value) + elif bits == 64: + builder.add_64bit_float(value) + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + elif dataType.lower() == 'string': + builder.add_string(value) + elif dataType.lower() == 'bit': + if objectType == 'Coil' or objectType == 'DiscreteInput': + if not isinstance(value, bool): # test is boolean + self.logger.error(f"Value is not boolean: {value}") + return + else: + if set(value).issubset({'0', '1'}) and bool(value): # test is bit-string '00110101' + builder.add_bits(value) + else: + self.logger.error(f"Value is not a bitstring: {value}") + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + return None + + if objectType == 'Coil': + result = self._Mclient.write_coil(address, value, unit=slaveUnit) + elif objectType == 'HoldingRegister': + registers = builder.to_registers() + result = self._Mclient.write_registers(address, registers, unit=slaveUnit) + elif objectType == 'DiscreteInput': + self.logger.warning(f"this object type cannot be written {objectType}:{address} slaveUnit:{slaveUnit}") + return + elif objectType == 'InputRegister': + self.logger.warning(f"this object type cannot be written {objectType}:{address} slaveUnit:{slaveUnit}") + return + else: + return + if result.isError(): + self.logger.error(f"write error: {result} {objectType}.{address}.{slaveUnit} (address.slaveUnit)") + return None + + if 'write_dt' in regPara: + regPara['last_write_dt'] = regPara['write_dt'] + regPara['write_dt'] = datetime.now() + else: + regPara.update({'write_dt': datetime.now()}) + + if 'write_value' in regPara: + regPara['last_write_value'] = regPara['write_value'] + regPara['write_value'] = value + else: + regPara.update({'write_value': value}) + + # regPara['write_dt'] = datetime.now() + # regPara['write_value'] = value + + def __read_Registers(self, regPara): + objectType = regPara['objectType'] + dataTypeStr = regPara['dataType'] + dataType = ''.join(filter(str.isalpha, dataTypeStr)) + bo = regPara['byteOrder'] + wo = regPara['wordOrder'] + slaveUnit = regPara['slaveUnit'] + registerCount = 0 + address = regPara['regAddr'] + value = None + + try: + bits = int(''.join(filter(str.isdigit, dataTypeStr))) + except: + bits = 16 + + if dataType.lower() == 'string': + registerCount = int(bits / 2) # bei string: bits = bytes !! string16 -> 16Byte - 8 registerCount + else: + registerCount = int(bits / 16) + + if self.connected == False: + self.logger.error(f"not connected to {self._host}:{self._port}") + return None + + # self.logger.debug(f"read {objectType}.{address}.{slaveUnit} (address.slaveUnit) regCount:{registerCount}") + if objectType == 'Coil': + if pymodbus_baseversion > 2: + result = self._Mclient.read_coils(address, registerCount, slave=slaveUnit) + else: + result = self._Mclient.read_coils(address, registerCount, unit=slaveUnit) + elif objectType == 'DiscreteInput': + if pymodbus_baseversion > 2: + result = self._Mclient.read_discrete_inputs(address, registerCount, slave=slaveUnit) + else: + result = self._Mclient.read_discrete_inputs(address, registerCount, unit=slaveUnit) + elif objectType == 'InputRegister': + if pymodbus_baseversion > 2: + result = self._Mclient.read_input_registers(address, registerCount, slave=slaveUnit) + else: + result = self._Mclient.read_input_registers(address, registerCount, unit=slaveUnit) + elif objectType == 'HoldingRegister': + if pymodbus_baseversion > 2: + result = self._Mclient.read_holding_registers(address, registerCount, slave=slaveUnit) + else: + result = self._Mclient.read_holding_registers(address, registerCount, unit=slaveUnit) + else: + self.logger.error(f"{AttrObjectType} not supported: {objectType}") + return None + + if result.isError(): + self.logger.error(f"read error: {result} {objectType}.{address}.{slaveUnit} (address.slaveUnit) regCount:{registerCount}") + return None + + if objectType == 'Coil': + value = result.bits[0] + elif objectType == 'DiscreteInput': + value = result.bits[0] + elif objectType == 'InputRegister': + decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=bo, wordorder=wo) + else: + decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=bo, wordorder=wo) + + self.logger.debug(f"read {objectType}.{address}.{slaveUnit} (address.slaveUnit) regCount:{registerCount} result:{result}") + + if dataType.lower() == 'uint': + if bits == 16: + return decoder.decode_16bit_uint() + elif bits == 32: + return decoder.decode_32bit_uint() + elif bits == 64: + return decoder.decode_64bit_uint() + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + elif dataType.lower() == 'int': + if bits == 16: + return decoder.decode_16bit_int() + elif bits == 32: + return decoder.decode_32bit_int() + elif bits == 64: + return decoder.decode_64bit_int() + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + elif dataType.lower() == 'float': + if bits == 32: + return decoder.decode_32bit_float() + elif bits == 64: + return decoder.decode_64bit_float() + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + elif dataType.lower() == 'string': + # bei string: bits = bytes !! string16 -> 16Byte + ret = decoder.decode_string(bits) + return str(ret, 'ASCII') + elif dataType.lower() == 'bit': + if objectType == 'Coil' or objectType == 'DiscreteInput': + # self.logger.debug(f"read bit value: {value}") + return value + else: + self.logger.debug(f"read bits values: {value.decode_bits()}") + return decoder.decode_bits() + else: + self.logger.error(f"Number of bits or datatype not supported : {dataTypeStr}") + return None diff --git a/modbus_tcp/_pv_1_0_9/assets/tab1_readed.png b/modbus_tcp/_pv_1_0_9/assets/tab1_readed.png new file mode 100644 index 000000000..293a50df9 Binary files /dev/null and b/modbus_tcp/_pv_1_0_9/assets/tab1_readed.png differ diff --git a/modbus_tcp/_pv_1_0_9/example.yaml b/modbus_tcp/_pv_1_0_9/example.yaml new file mode 100644 index 000000000..4a85a6ff1 --- /dev/null +++ b/modbus_tcp/_pv_1_0_9/example.yaml @@ -0,0 +1,211 @@ +paradigma: + TestInputRegister: + type: num + name: TestIR + enforce_updates: True + modBusObjectType: InputRegister + modBusAddress: 9997 + modBusFactor: 0.1 + modBusDataType: int16 + TestHoldingRegister: + type: num + name: TestHR + enforce_updates: True + # modBusObjectType: HoldingRegister (default) + modBusAddress: 9998 + modBusFactor: 0.1 + modBusDataType: int16 + TestCoil0: + type: bool + name: TestC0 + enforce_updates: True + modBusObjectType: Coil + modBusAddress: 9996 #+1 + modBusDataType: bit + TestCoil1: + type: bool + name: TestC1 + enforce_updates: True + modBusObjectType: Coil + modBusAddress: 9997 #+1 + modBusDataType: bit + +#solaredge_SE6000: +Photovoltaik: + C_Version: + type: str + name: C_Version + modBusAddress: 40044 + modBusDataType: string16 + AC_Leistung_sf: + type: num + name: I_AC_Power_SF # SF- Skalierungsfaktor sunspec (z.B. -3) + enforce_updates: True + modBusAddress: 40084 + modBusDataType: int16 + AC_Leistung: + type: num + name: I_AC_Power # Die Leistung ergit sich aus dem erhaltenen Registerwert und dem Skalierungsfaktor (AC_Leistung_sf) + enforce_updates: True + eval: value*10**sh.Photovoltaik.AC_Leistung_sf() # Berechnung der Leistung (erhaltene Registerwert * 10^Skalierungsfaktor) + modBusAddress: 40083 + modBusFactor: 0.001 + DC_Leistung_sf: + type: num + name: I_DC_Power_SF + enforce_updates: True + modBusAddress: 40101 + modBusDataType: int16 + DC_Leistung: + type: num + name: I_DC_Power + eval: value*10**sh.Photovoltaik.DC_Leistung_sf() # erhaltene Registerwert * 10^Skalierungsfaktor + enforce_updates: True + modBusAddress: 40100 + modBusFactor: 0.001 + Inverter_Temperatur_sf: + type: num + name: I_Temp_Sink_SF + enforce_updates: True + modBusAddress: 40106 + modBusDataType: int16 + Inverter_Temperatur: + type: num + name: I_Temp_Sink + database: yes + database_maxage: 365 + eval: value*10**sh.Photovoltaik.Inverter_Temperatur_sf() # erhaltene Registerwert * 10^Skalierungsfaktor + modBusAddress: 40103 + modBusDataType: int16 + Zaehler_Strom_sf: + type: num + name: M_AC_Current_SF + enforce_updates: True + modBusAddress: 40194 + modBusDataType: int16 + Zaehler_Strom: + type: num + name: M_AC_Current + enforce_updates: True + database: yes + database_maxage: 365 + eval: value*10**sh.Photovoltaik.Zaehler_Strom_sf() # erhaltene Registerwert * 10^Skalierungsfaktor + modBusAddress: 40190 + Zaehler_Spannung_sf: + type: num + name: M_AC_Voltage_SF + enforce_updates: True + modBusAddress: 40203 + modBusDataType: int16 + Zaehler_Spannung: + type: num + name: M_AC_Voltage_L_N + enforce_updates: True + database: yes + database_maxage: 365 + eval: value*10**sh.Photovoltaik.Zaehler_Spannung_sf() # erhaltene Registerwert * 10^Skalierungsfaktor + modBusAddress: 40195 + Zaehler_Frequenz_sf: + type: num + name: M_AC_Freq_SF + enforce_updates: True + modBusAddress: 40205 + modBusDataType: int16 + Zaehler_Frequenz: + type: num + name: M_AC_Freq + enforce_updates: True + database: yes + database_maxage: 365 + eval: value*10**sh.Photovoltaik.Zaehler_Frequenz_sf() # erhaltene Registerwert * 10^Skalierungsfaktor + modBusAddress: 40204 + Zaehler_Leistung_sf: + type: num + name: M_AC_Power_SF + enforce_updates: True + modBusAddress: 40210 + modBusDataType: int16 + Zaehler_Leistung: + type: num + name: M_AC_Power + enforce_updates: True + eval: value*10**sh.Photovoltaik.Zaehler_Leistung_sf() # erhaltene Registerwert * 10^Skalierungsfaktor + modBusAddress: 40206 + modBusDataType: int16 + modBusFactor: 0.001 + Status: # 0 Autark, 1 Export, 2 Import, + type: num + eval_trigger: Photovoltaik.Zaehler_Leistung + eval: 1 if sh.Photovoltaik.Zaehler_Leistung() > 0.025 else 2 if sh.Photovoltaik.Zaehler_Leistung() < -0.025 else 0 + StatusText: + type: str + eval_trigger: Photovoltaik.Zaehler_Leistung + eval: "'Exportieren' if sh.Photovoltaik.Zaehler_Leistung() > 0.025 else 'Importieren' if sh.Photovoltaik.Zaehler_Leistung() < -0.025 else ''" + Speicher_Leistung: + type: num + name: S_Power + enforce_updates: True + modBusAddress: 59764 + modBusDataType: float32 + modBusFactor: 0.001 + Speicher_Energie: + type: num + name: S_Available_Energy + modBusAddress: 59776 + modBusDataType: float32 + modBusFactor: 0.001 + Speicher_SOE: + type: num + name: S_SOE + modBusAddress: 59780 + modBusDataType: float32 + Speicher_Status: + type: num + name: S_Status + modBusAddress: 59782 + modBusDataType: uint32 + Text: + type: str + eval_trigger: Photovoltaik.Speicher_Status + eval: sh..lookup()[value] + lookup: + type: dict + initial_value: "{0: 'Aus', 1: 'Standby', 2: 'Init', 3: 'Laden', 4: 'Entladen', 5: 'Fehler', 6: 'Leerlauf'}" + +Siemens_LOGO: + AI1: + type: num + name: AI1 + modBusObjectType: InputRegister + modBusAddress: 0 + #modBusFactor: 0.1 + #modBusDataType: int16 + AM1: + type: num + name: AM1 + modBusObjectType: HoldingRegister + modBusAddress: 528 + modBusDirection: read_write + #modBusFactor: 1 + #modBusDataType: int16 + M1: + type: bool + name: M1 + modBusObjectType: Coil + modBusAddress: 8256 + modBusDataType: bit + modBusDirection: read_write + I1: + type: bool + name: I1 + modBusObjectType: DiscreteInput + modBusAddress: 0 + modBusDataType: bit + VM0: + type: num + name: VM0 + modBusObjectType: HoldingRegister + modBusAddress: 0 + modBusDirection: read_write + modBusFactor: 0.01 + #modBusDataType: int16 diff --git a/modbus_tcp/_pv_1_0_9/plugin.yaml b/modbus_tcp/_pv_1_0_9/plugin.yaml new file mode 100644 index 000000000..83bdf2cd2 --- /dev/null +++ b/modbus_tcp/_pv_1_0_9/plugin.yaml @@ -0,0 +1,134 @@ +# Metadata for the Smart-Plugin +plugin: + # Global plugin attributes + type: gateway + description: + de: 'Plugin zur Geräte-Anbindung über ModBus an SmartHomeNG' + en: 'Plugin to connect via modbus with SmartHomeNG' + maintainer: ivande + tester: NONE # Who tests this plugin? + state: ready + keywords: modbus_tcp modbus smartmeter inverter heatpump + #documentation: http://smarthomeng.de/user/plugins/modbus_tcp/user_doc.html + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1154368-einbindung-von-modbus-tcp + version: 1.0.9 # Plugin version + sh_minversion: 1.8 # minimum shNG version to use this plugin + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + py_minversion: 3.6 + # py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) + multi_instance: True # plugin supports multi instance + restartable: unknown + classname: modbus_tcp # class containing the plugin + +parameters: + host: + type: ipv4 + description: + de: 'IP Adresse des Modbus-Geraetes' + en: 'IP address from the modbus-device' + + port: + type: int + valid_min: 0 + valid_max: 65535 + description: + de: 'modbus Port' + en: 'modbus port' + + cycle: + type: int + default: 300 + valid_min: 0 + description: + de: 'Update Zyklus in Sekunden. Wenn der Wert 0 ist, wird keine Abfrage über cycle ausgeführt' + en: 'Update cycle in seconds. If value is 0 then noch query will be made by means of cycle' + + crontab: + type: str + description: + de: 'Update mit Festlegung via Crontab' + en: 'Update by means of a crontab' + + slaveUnit: + type: num + default: 1 + description: + de: 'Slave-Addresse der zu lesenden Modbus-Einheit' + en: 'slave-address of the Modbus-Unit to read' + +item_attributes: + modBusObjectType: + type: str + default: 'HoldingRegister' + description: + de: 'Auswahl welcher Objekt-Type gelesen werden soll' + en: 'Selection of which object type should be read ' + valid_list: + - 'Coil' + - 'DiscreteInput' + - 'InputRegister' + - 'HoldingRegister' + + modBusAddress: + type: num + description: + de: 'Register Adresse welche gelesen werden soll' + en: 'register address to read' + + modBusUnit: + type: num + default: 1 # als default wird die slaveUnit aus der plugin-Konfig verwendet + description: + de: 'Slave-Addresse der zu lesenden Modbus-Einheit (Unit aus der plugin-Konfig wird überschrieben)' + en: 'slave-address of the Modbus-Unit to read - (Value from plugin config will be overwritten)' + + modBusDataType: + type: str + default: 'uint16' + description: + de: 'Datentyp vom zu lesenden Register (bit, int16 uint16 int32 uint32 float32 string16 stringNN)' + en: 'datatype from register to read (bit, int16 uint16 int32 uint32 float32 string16 stringNN)' + + modBusDirection: + type: str + default: 'read' + description: + de: 'Datenrichtung' + en: 'data direction' + valid_list: + - 'read' + - 'read_write' + - 'write' + + modBusByteOrder: + type: str + default: 'Endian.Big' + description: + de: 'Endian.Big oder Endian.Little' + en: 'Endian.Big or Endian.Little' + valid_list: + - 'Endian.Big' + - 'Endian.Little' + + modBusWordOrder: + type: str + default: 'Endian.Big' + description: + de: 'Endian.Big oder Endian.Little' + en: 'Endian.Big or Endian.Little' + valid_list: + - 'Endian.Big' + - 'Endian.Little' + + modBusFactor: + type: num + default: 1 + description: + de: 'Faktor mit dem der gelesene Register-Wert multipliziert wird' + en: 'Factor by which the read register value is multiplied' + +item_structs: NONE + +plugin_functions: NONE + +logic_parameters: NONE diff --git a/modbus_tcp/_pv_1_0_9/requirements.txt b/modbus_tcp/_pv_1_0_9/requirements.txt new file mode 100644 index 000000000..04c18ec78 --- /dev/null +++ b/modbus_tcp/_pv_1_0_9/requirements.txt @@ -0,0 +1,2 @@ +pymodbus>=2.3,<3.0;python_version<'3.8' +pymodbus>=3.0.2;python_version>="3.8" diff --git a/modbus_tcp/_pv_1_0_9/user_doc.rst b/modbus_tcp/_pv_1_0_9/user_doc.rst new file mode 100644 index 000000000..22dd37d2e --- /dev/null +++ b/modbus_tcp/_pv_1_0_9/user_doc.rst @@ -0,0 +1,201 @@ +.. index:: modbus_tcp plugin +.. index:: Plugins; modbus_tcp + + +========== +modbus_tcp +========== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +SmarthomeNG plugin, zum Lesen von Register über ModBusTCP + +Anforderungen +============= + +* Python > 3.6 +* pymodbus >= 2.5.3 +* SmarthomeNG >= 1.8.0 + +pymodbus +-------- + +das Paket sollte automatisch von SH installiert werden. + +pymodbus - manuelle Installation: +--------------------------------- + +.. code:: shell-session + + python3 -m pip install pymodbus --user --upgrade + +Konfiguration +============= + +plugin.yaml +----------- + +.. code-block:: yaml + + solaredge: + plugin_name: modbus_tcp + instance: solaredge + host: 192.168.0.50 + port: 502 + cycle: 60 + plugin_enabled: true + + logoMB: + plugin_name: modbus_tcp + instance: logomb + host: 192.168.0.80 + port: 502 + cycle: 20 + plugin_enabled: true + +* 'instance' = Name der Instanz, sollen mehrer Geräte angesprochen werden (Multiinstanz) + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +items.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +logic.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +Beispiel: In der Datei example.yaml sind ein paar Items für einen Solaredge-Wechselrichter SE6000 angelegt. + +Funktionen +~~~~~~~~~~ + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Beispiele +--------- +Beispiel für SH-Item's + +siehe auch example.yaml + +.. code-block:: yaml + + mydevice: + AI1: + type: num + name: AI1 + modBusObjectType: InputRegister #(optional) default: HoldingRegister + modBusAddress: 0 + modBusDataType: int16 #(optional) default: uint16 + #modBusByteOrder: 'Endian.Little' #(optional) default: 'Endian.Big' + #modBusWordOrder: 'Endian.Little' #(optional) default: 'Endian.Big' + modBusDirection: 'read_write' #(optional) default: 'read' + modBusUnit: '71' #(optional) default: slaveUnit aus der Plugin-Konfig + #modBusFactor: 0.1 #(optional) default: 1 + #modBusDirection: read_write #(optional) default: 'read' + AM1: + type: num + name: AM1 + modBusObjectType: HoldingRegister + modBusAddress: 528 + modBusDirection: read_write + #modBusFactor: 1 + #modBusDataType: int16 + M1: + type: bool + name: M1 + modBusObjectType: Coil + modBusAddress: 8256 + modBusDataType: bit + modBusDirection: read_write + I1: + type: bool + name: I1 + modBusObjectType: DiscreteInput + modBusAddress: 0 + modBusDataType: bit + VM0: + type: num + name: VM0 + modBusObjectType: HoldingRegister + modBusAddress: 0 + modBusDirection: read_write + modBusFactor: 0.01 + + geraetename: + type: str + #modBusObjectType: HoldingRegister #(optional) default: HoldingRegister + modBusAddress: 40030 + modBusDataType: 'string16' #(optional) default: uint16 + #modBusFactor: '1000' #(optional) default: 1 + modBusByteOrder: 'Endian.Little' #(optional) default: 'Endian.Big' + modBusWordOrder: 'Endian.Little' #(optional) default: 'Endian.Big' + modBusDirection: 'read_write' #(optional) default: 'read' + modBusUnit: '71' #(optional) default: slaveUnit aus der Plugin-Konfig + temperatur: + type: num + modBusAddress: 40052 + modBusDataType: 'float32 #(optional) default: uint16 + #modBusFactor: '1' #(optional) default: 1 + modBusByteOrder: 'Endian.Little' #(optional) default: 'Endian.Big' + modBusWordOrder: 'Endian.Little' #(optional) default: 'Endian.Big' + modBusUnit: '71' #(optional) default: slaveUnit aus der Plugin-Konfig + + # Multiinstanz: + # Jedes Attribut mit der @ ergänzen. Der Name der Instance muss in der Plugin Konfiguration festgelegt werden. + M1: + type: bool + name: M1 + modBusObjectType@logomb: Coil + modBusAddress@logomb: 8256 + modBusDataType@logomb: bit + modBusDirection@logomb: read_write + + +Changelog +--------- +V1.0.9 + +V1.0.8 Neuere Pymodbus Versionen können nun verwendet werden. + Di minimale Version für Pymodbus ist jetzt 2.5.3 + +V1.0.7 Verbindung offen halten und lock nutzen um Thread Sicherheit zu erreichen (CaeruleusAqua and bmxp) + Fehler behoben: nicht deklarierte Variable "TypeStr" und "bitstr" + +V1.0.6 schreiben von Register (HoldingRegister, Coil) + +V1.0.5 kleine Fehler behoben + +V1.0.4 ObjectType hinzugefügt (HoldingRegister, InputRegister, DiscreteInput, Coil) + Multiinstanz hinzugefügt + Verbindung schliessen nach Abruf aller Register + +V1.0.3 slaveUnit - Fehler behoben (_regToRead-key (adress.unit)) + Bug Web Interface (Anzeige der Adresse) + example.yaml verbessert + +V1.0.2 slaveUnit zu Items hinzugefügt + +V1.0.1 slaveUnit zu plugin-Paramter hinzugefügt + +V1.0.0 Initial plugin version + + +Web Interface +============= + +Das Plugin kann aus dem Admin Interface aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden +Zeile das Icon in der Spalte **Web Interface** anklicken. + +.. image:: assets/tab1_readed.png + :class: screenshot diff --git a/operationlog/webif/__init__.py b/modbus_tcp/_pv_1_0_9/webif/__init__.py old mode 100755 new mode 100644 similarity index 94% rename from operationlog/webif/__init__.py rename to modbus_tcp/_pv_1_0_9/webif/__init__.py index 3b1553256..486e5cb2a --- a/operationlog/webif/__init__.py +++ b/modbus_tcp/_pv_1_0_9/webif/__init__.py @@ -72,8 +72,9 @@ def index(self, reload=None): """ tmpl = self.tplenv.get_template('index.html') # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin, items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']))) - + return tmpl.render(p=self.plugin, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=0) @cherrypy.expose def get_data_html(self, dataSet=None): diff --git a/modbus_tcp/_pv_1_0_9/webif/static/img/lamp_green.png b/modbus_tcp/_pv_1_0_9/webif/static/img/lamp_green.png new file mode 100644 index 000000000..fb130568b Binary files /dev/null and b/modbus_tcp/_pv_1_0_9/webif/static/img/lamp_green.png differ diff --git a/modbus_tcp/_pv_1_0_9/webif/static/img/lamp_red.png b/modbus_tcp/_pv_1_0_9/webif/static/img/lamp_red.png new file mode 100644 index 000000000..00fc04c90 Binary files /dev/null and b/modbus_tcp/_pv_1_0_9/webif/static/img/lamp_red.png differ diff --git a/modbus_tcp/_pv_1_0_9/webif/static/img/plugin_logo.png b/modbus_tcp/_pv_1_0_9/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..4f849e35c Binary files /dev/null and b/modbus_tcp/_pv_1_0_9/webif/static/img/plugin_logo.png differ diff --git a/modbus_tcp/_pv_1_0_9/webif/templates/index.html b/modbus_tcp/_pv_1_0_9/webif/templates/index.html new file mode 100644 index 000000000..75651666b --- /dev/null +++ b/modbus_tcp/_pv_1_0_9/webif/templates/index.html @@ -0,0 +1,198 @@ +{% extends "base_plugin.html" %} +{% block pluginscripts %} + +{% endblock pluginscripts %} +{% set logo_frame = false %} +{% set use_bodytabs = false %} +{% set tabcount = 1 %} + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + {% if 'last_dt' in p._pollStatus %} + + + + {% endif %} + + + +
+ {% if p.connected %} + {{ _('connectwd') }} + {% else %} + {{ _('not connected') }} + {% endif %} + {{ _('Connected') }} + {{ _('Host : Port') }}{{ p._host }} : {{ p._port }}
{{ _('') }}{{ _('global slave unit') }}{{ p._slaveUnit }}
{{ _('') }}{{ _('pol_device / cycle-time [seconds]') }}{{ p._cycle }}
{{ _('') }}{{ _('last_read (readed registers)') }}{{ p._pollStatus.last_dt.strftime('%d.%m.%Y %H:%M:%S %Z') }} ({{ p._pollStatus.regCount }})
+{% endblock %} + + +{% block buttons %} +{% if 1==2 %} +
+ +
+{% endif %} +{% endblock %} + + +{% set tabcount = 2 %} + + +{% if item_count==0 %} + {% set start_tab = 1 %} +{% endif %} + +{% set tab1title = "registers to read (" ~ p._regToRead|length ~ ")" %} +{% block bodytab1 %} + +
+
+ + + + + + {% if p._slaveUnitRegisterDependend %} + + {% endif %} + + + + + + + + + + + {% for item in p._regToRead %} + + + + {% if p._slaveUnitRegisterDependend %} + + {% endif %} + + + + + {% if 'read_dt' in p._regToRead[item] %} + + {% else %} + + {% endif %} + {% if 'last_read_dt' in p._regToRead[item] %} + + {% else %} + + {% endif %} + + + + {% endfor %} + +
{{ _('ObjectType') }} {{ _('Address') }} {{ _('Unit') }} {{ _('Value') }} {{ _('Item') }} {{ _('DataType') }} {{ _('last_read') }} {{ _('prev_read') }} {{ _('prev_value') }}
{{ p._regToRead[item].objectType }}{{ p._regToRead[item].regAddr }}{{ p._regToRead[item].slaveUnit }}{{ p._regToRead[item].value }}{{ p._regToRead[item].item }}{{ p._regToRead[item].dataType }}{{ p._regToRead[item].read_dt.strftime('%d.%m.%Y %H:%M:%S %Z') }}{{ p._regToRead[item].last_read_dt.strftime('%d.%m.%Y %H:%M:%S %Z') }}{{ p._regToRead[item].last_value }}
+
+
+{% endblock %} + +{% set tab2title = "registers to write (" ~ p._regToWrite|length ~ ")" %} +{% block bodytab2 %} + +
+
+ + + + + + {% if p._slaveUnitRegisterDependend %} + + {% endif %} + + + + + + + + + + + {% for item in p._regToWrite %} + + + + {% if p._slaveUnitRegisterDependend %} + + {% endif %} + {% if 'write_value' in p._regToWrite[item] %} + + {% else %} + + {% endif %} + + + + {% if 'write_dt' in p._regToWrite[item] %} + + {% else %} + + {% endif %} + {% if 'last_write_dt' in p._regToWrite[item] %} + + {% else %} + + {% endif %} + + + + {% endfor %} + +
{{ _('ObjectType') }} {{ _('Address') }} {{ _('Unit') }} {{ _('Value') }} {{ _('Item') }} {{ _('DataType') }} {{ _('last_write') }} {{ _('prev_write') }} {{ _('prev_value') }}
{{ p._regToWrite[item].objectType }}{{ p._regToWrite[item].regAddr }}{{ p._regToWrite[item].slaveUnit }}{{ p._regToWrite[item].write_value }}{{ p._regToWrite[item].item }}{{ p._regToWrite[item].dataType }}{{ p._regToWrite[item].write_dt.strftime('%d.%m.%Y %H:%M:%S %Z') }}{{ p._regToWrite[item].last_write_dt.strftime('%d.%m.%Y %H:%M:%S %Z') }}{{ p._regToWrite[item].last_write_value }}
+
+
+{% endblock %} + + + + diff --git a/modbus_tcp/plugin.yaml b/modbus_tcp/plugin.yaml index f4ef01a3c..b2a137869 100755 --- a/modbus_tcp/plugin.yaml +++ b/modbus_tcp/plugin.yaml @@ -5,15 +5,17 @@ plugin: description: de: 'Plugin zur Geräte-Anbindung über ModBus an SmartHomeNG' en: 'Plugin to connect via modbus with SmartHomeNG' - maintainer: ivande - tester: 'n/a' + maintainer: ivande, Cannon + tester: NONE # Who tests this plugin? state: ready - keywords: modbus_tcp - #documentation: https://github.com/smarthomeNG/plugins/blob/develop/modbus_tcp/user_doc.rst + keywords: modbus_tcp modbus smartmeter inverter heatpump + #documentation: http://smarthomeng.de/user/plugins/modbus_tcp/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1154368-einbindung-von-modbus-tcp - version: 1.0.8 # Plugin version + version: 1.0.10 # Plugin version sh_minversion: 1.8 # minimum shNG version to use this plugin + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) py_minversion: 3.6 + # py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance restartable: unknown classname: modbus_tcp # class containing the plugin @@ -26,17 +28,26 @@ parameters: en: 'IP address from the modbus-device' port: - type: num + type: int + valid_min: 0 + valid_max: 65535 description: de: 'modbus Port' en: 'modbus port' cycle: - type: num + type: int default: 300 + valid_min: 0 + description: + de: 'Update Zyklus in Sekunden. Wenn der Wert 0 ist, wird keine Abfrage über cycle ausgeführt' + en: 'Update cycle in seconds. If value is 0 then noch query will be made by means of cycle' + + crontab: + type: str description: - de: 'Update Zyklus in Sekunden' - en: 'Update cycle in seconds' + de: 'Update mit Festlegung via Crontab' + en: 'Update by means of a crontab' slaveUnit: type: num @@ -53,10 +64,10 @@ item_attributes: de: 'Auswahl welcher Objekt-Type gelesen werden soll' en: 'Selection of which object type should be read ' valid_list: - - 'Coil' - - 'DiscreteInput' - - 'InputRegister' - - 'HoldingRegister' + - 'Coil' + - 'DiscreteInput' + - 'InputRegister' + - 'HoldingRegister' modBusAddress: type: num @@ -85,9 +96,9 @@ item_attributes: de: 'Datenrichtung' en: 'data direction' valid_list: - - 'read' - - 'read_write' - - 'write' + - 'read' + - 'read_write' + - 'write' modBusByteOrder: type: str @@ -96,8 +107,8 @@ item_attributes: de: 'Endian.Big oder Endian.Little' en: 'Endian.Big or Endian.Little' valid_list: - - 'Endian.Big' - - 'Endian.Little' + - 'Endian.Big' + - 'Endian.Little' modBusWordOrder: type: str @@ -106,14 +117,14 @@ item_attributes: de: 'Endian.Big oder Endian.Little' en: 'Endian.Big or Endian.Little' valid_list: - - 'Endian.Big' - - 'Endian.Little' + - 'Endian.Big' + - 'Endian.Little' modBusFactor: type: num default: 1 description: - de: 'Faktor mit welchem der gelesene Register-Wert multipliziert wird' + de: 'Faktor mit dem der gelesene Register-Wert multipliziert wird' en: 'Factor by which the read register value is multiplied' item_structs: NONE diff --git a/modbus_tcp/requirements.txt b/modbus_tcp/requirements.txt index 04c18ec78..df27c8584 100755 --- a/modbus_tcp/requirements.txt +++ b/modbus_tcp/requirements.txt @@ -1,2 +1 @@ -pymodbus>=2.3,<3.0;python_version<'3.8' -pymodbus>=3.0.2;python_version>="3.8" +pymodbus>=3.5.2;python_version>='3.8' diff --git a/modbus_tcp/user_doc.rst b/modbus_tcp/user_doc.rst index f111ab9ff..5323449c5 100755 --- a/modbus_tcp/user_doc.rst +++ b/modbus_tcp/user_doc.rst @@ -6,30 +6,39 @@ modbus_tcp ========== +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + SmarthomeNG plugin, zum Lesen von Register über ModBusTCP Anforderungen -------------- +============= + * Python > 3.6 * pymodbus >= 2.5.3 * SmarthomeNG >= 1.8.0 pymodbus -~~~~~~~~ +-------- + das Paket sollte automatisch von SH installiert werden. pymodbus - manuelle Installation: -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +--------------------------------- .. code:: shell-session - pip install pymodbus + python3 -m pip install pymodbus --user --upgrade Konfiguration -------------- +============= plugin.yaml -~~~~~~~~~~~ +----------- .. code-block:: yaml @@ -55,13 +64,13 @@ Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wur items.yaml -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. logic.yaml -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. @@ -155,9 +164,14 @@ siehe auch example.yaml Changelog --------- -V1.0.8 work with newer versions of pymodbus too, minimum pymodbus now 2.5.3 +V1.0.10 Mindestversion für pymodbus ist nun 3.5.2 + +V1.0.9 + +V1.0.8 Neuere Pymodbus Versionen können nun verwendet werden. + Di minimale Version für Pymodbus ist jetzt 2.5.3 -V1.0.7 keep connection open and use locking to ensure thread safety (CaeruleusAqua and bmxp) +V1.0.7 Verbindung offen halten und lock nutzen um Thread Sicherheit zu erreichen (CaeruleusAqua and bmxp) Fehler behoben: nicht deklarierte Variable "TypeStr" und "bitstr" V1.0.6 schreiben von Register (HoldingRegister, Coil) @@ -180,7 +194,7 @@ V1.0.0 Initial plugin version Web Interface -------------- +============= Das Plugin kann aus dem Admin Interface aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden Zeile das Icon in der Spalte **Web Interface** anklicken. diff --git a/mqtt/__init__.py b/mqtt/__init__.py index 188125ca3..b208bd17f 100755 --- a/mqtt/__init__.py +++ b/mqtt/__init__.py @@ -131,6 +131,9 @@ def parse_item(self, item): if self.has_iattr(item.conf, 'mqtt_topic_in') or self.has_iattr(item.conf, 'mqtt_topic_out'): self.logger.debug("parsing item: {0}".format(item.id())) + if item.property.type == 'foo': + self.logger.warning(f"item {item.path()} has item type foo, which will not be processed by the MQTT system") + # check if mqtt module has been initialized successfully if not self.mod_mqtt: self.logger.warning("MQTT module is not initialized, not parsing item '{}'".format(item.path())) diff --git a/mqtt/plugin.yaml b/mqtt/plugin.yaml index 43ab484a5..d038ff2b3 100755 --- a/mqtt/plugin.yaml +++ b/mqtt/plugin.yaml @@ -14,7 +14,7 @@ plugin: version: 2.0.5 # Plugin version sh_minversion: 1.7 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) - multi_instance: True # plugin supports multi instance + multi_instance: True # since the plugin connects to the mqtt module, multi instance makes no sense restartable: unknown classname: Mqtt2 # class containing the plugin diff --git a/mvg_live/README.md b/mvg_live/README.md index b8b9b21bb..4bfead4d6 100755 --- a/mvg_live/README.md +++ b/mvg_live/README.md @@ -4,12 +4,13 @@ This plugin requires lib PyMVGLive. You can install this lib with: ``` -sudo pip3 install PyMVGLive --upgrade +sudo pip3 install mvg --upgrade ``` -This plugin provides functionality to query the data of www.mvg-live.de via the python package PyMVGLive. +This plugin provides functionality to query the data of www.mvv-muenchen.de via the python package "mvg" (pip install mvg). + Take care to not run it too often. My example below is manually triggered by a select action in the -smartVISU 2.9 select widget or a refresh button. +smartVISU 2.9 select widget including a refresh button. Forum thread to the plugin: https://knx-user-forum.de/forum/supportforen/smarthome-py/1108867-neues-plugin-mvg_live @@ -66,27 +67,39 @@ MVGWatch: ### mvg.py +For the images I am using https://commons.wikimedia.org/wiki/M%C3%BCnchen_U-Bahn?uselang=de and https://commons.wikimedia.org/wiki/Category:Line_numbers_of_Munich_S-Bahn +and put them in the dropins folder of smartVISU (/dropins/icons/myglive/Muenchen_%s.svg.png). %s is e.g. S8 or U3 and inputted from the departure array. ```html -results = sh.mvg_live.get_station_departures(sh.travel_info.mvg_station.search(), entries=15, bus=False, tram=False) +import logging +from datetime import datetime +from lib.shtime import Shtime +logger = logging.getLogger('mvg_info logics') +now = Shtime.get_instance().now() + +results = sh.mvg_live.get_station_departures(sh.general.travel_info.mvg_station.search()) html_string = '' i = 1 -for result in results: - dir_info = '' - line_string = '' - line_string += '' % ( - result['productsymbolurl'], result['product'], result['linesymbolurl'], result['linename']) - line_string += '' % result['destination'] - line_string += '' % result['time'] - html_string += line_string - i = i + 1 +for result in results: + if result['type'] in ["U-Bahn","S-Bahn"]: + result['linesymbolurl'] = 'https:///smartVISU/dropins/icons/mvglive/Muenchen_%s.svg.png'%result['line'] + dir_info = '' + line_string = '' + line_string += '' % (result['linesymbolurl'],result['linesymbolurl']) #' % result['destination'] + line_string += '' % (datetime.fromtimestamp(result['time']).strftime("%H:%M"),calculated_delay) #calculated_delay) + html_string += line_string + i = i + 1 if i == 7: break html_string += '
%s%s' - line_string += '%s ' - line_string += '
%i
%s%s + line_string += '' + line_string += '%s ' + calculated_delay = "" + if int(datetime.fromtimestamp(result['time']-result['planned']).strftime("%M")) > 0: + calculated_delay = "(+%i)"%int(datetime.fromtimestamp(result['time']-result['planned']).strftime("%M")) + line_string += '
%s %s
' -sh.travel_info.mvg_station.search.result(html_string) +sh.general.travel_info.mvg_station.search.result(html_string) ``` ### smartVISU integration (Requires smartVISU 2.9, as select widget is used) diff --git a/mvg_live/__init__.py b/mvg_live/__init__.py index 1637b2092..6ac5c092c 100755 --- a/mvg_live/__init__.py +++ b/mvg_live/__init__.py @@ -22,20 +22,18 @@ ######################################################################### import logging -import MVGLive - +from mvg import MvgApi from lib.model.smartplugin import SmartPlugin class MVG_Live(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.5.1" + PLUGIN_VERSION = "1.6.0" def __init__(self, sh, *args, **kwargs): """ Initializes the plugin """ self.logger = logging.getLogger(__name__) - self._mvg_live = MVGLive.MVGLive() def run(self): self.alive = True @@ -43,5 +41,15 @@ def run(self): def stop(self): self.alive = False - def get_station_departures(self, station, timeoffset=0, entries=10, ubahn=True, tram=True, bus=True, sbahn=True): - return self._mvg_live.getlivedata(station, timeoffset, entries, ubahn, tram, bus, sbahn) + def get_station(self, station): + mvg_station = MvgApi.station(station) + if mvg_station: + return mvg_station + + def get_station_departures(self, station): + mvg_station = self.get_station(station) + if mvg_station: + mvgapi = MvgApi(mvg_station['id']) + return mvgapi.departures() + else: + logger.error("Station %s does not exist."%station) \ No newline at end of file diff --git a/mvg_live/mvg.PNG b/mvg_live/mvg.PNG index 8c0c279a5..45aa946e2 100755 Binary files a/mvg_live/mvg.PNG and b/mvg_live/mvg.PNG differ diff --git a/mvg_live/plugin.yaml b/mvg_live/plugin.yaml index 771c844f1..764df8bed 100755 --- a/mvg_live/plugin.yaml +++ b/mvg_live/plugin.yaml @@ -12,9 +12,9 @@ plugin: documentation: http://smarthomeng.de/user/plugins_doc/config/mvg_live.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1108867-neues-plugin-mvg_live - version: 1.5.1 # Plugin version + version: 1.6.0 # Plugin version sh_minversion: 1.5 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance restartable: unknown classname: MVG_Live # class containing the plugin @@ -46,38 +46,3 @@ plugin_functions: de: "Name der Haltestelle. Schauen Sie auf der MVG live Webseite nach gültigen Namen." en: "Name of the stop or station. Visit the MVG live web site to find valid names." - timeoffset: - type: int - description: - de: "Keine Verbindungen in weniger als dieser Zeitdauer anzeigen. Sinnvoll, wenn man einige Minuten von der Haltstelle entfernt ist. Default: 0" - en: "Do not display connections departing sooner than this number of minutes. Useful if you are a couple of minutes away from the stop. Default: 0" - - entries: - type: int - description: - de: "Anzahl der Einträge, die geholt werden sollen. Default: 10" - en: "Number of entries to retrieve. Default: 10." - - ubahn: - type: bool - description: - de: "Falls 'False', keine U-Bahn Abfahrtzeiten anzeigen. Default: True." - en: "If 'False', do not display U-Bahn (subway) departures. Default: True." - - tram: - type: bool - description: - de: "Falls 'False', keine Tram Abfahrtzeiten anzeigen. Default: True." - en: "If 'False', do not display tram departures. Default: True." - - bus: - type: bool - description: - de: "Falls 'False', keine Bus Abfahrtzeiten anzeigen. Default: True." - en: "If 'False', do not display bus departures. Default: True." - - sbahn: - type: bool - description: - de: "Falls 'False', keine S-Bahn Abfahrtzeiten anzeigen. Default: True." - en: "If 'False', do not display S-Bahn (suburban train) departures. Default: True." diff --git a/mvg_live/requirements.txt b/mvg_live/requirements.txt index d960bd8d2..96920ca09 100755 --- a/mvg_live/requirements.txt +++ b/mvg_live/requirements.txt @@ -1 +1 @@ -PyMVGLive>=1.1.4 \ No newline at end of file +mvg>=1.1.2 \ No newline at end of file diff --git a/neato/__init__.py b/neato/__init__.py index 976e572d6..8bcab3b96 100755 --- a/neato/__init__.py +++ b/neato/__init__.py @@ -30,10 +30,10 @@ class Neato(SmartPlugin): - PLUGIN_VERSION = '1.6.8' + PLUGIN_VERSION = '1.6.9' robot = 'None' - def __init__(self, sh, *args, **kwargs): + def __init__(self, sh): """ Initalizes the plugin. @@ -62,7 +62,6 @@ def clientIDHash(self): def setClientIDHash(self, hash): return self.robot.setClientIDHash(hash) - def run(self): self.logger.debug("Run method called") self.scheduler_add('poll_device', self.poll_device, prio=5, cycle=self._cycle) @@ -140,6 +139,9 @@ def start_robot(self, boundary_id=None, map_id=None): response = self.robot.robot_command("start", boundary_id, map_id) return self.check_command_response(response) + def get_known_mapId(self): + self.logger.info(f"MapID is {self.robot.mapId}") + return self.robot.mapId # returns boundaryIds (clean zones) for given mapID # returns True on success and False otherwise @@ -151,7 +153,6 @@ def dismiss_current_alert(self): response = self.robot.robot_command("dismiss_current_alert") return self.check_command_response(response) - # enable cleaning schedule # returns True on success and False otherwise def enable_schedule(self): diff --git a/neato/assets/webif1.jpg b/neato/assets/webif1.jpg index 3a45465a7..d7c7e0c7e 100755 Binary files a/neato/assets/webif1.jpg and b/neato/assets/webif1.jpg differ diff --git a/neato/assets/webif2.jpg b/neato/assets/webif2.jpg new file mode 100644 index 000000000..f75a94df9 Binary files /dev/null and b/neato/assets/webif2.jpg differ diff --git a/neato/locale.yaml b/neato/locale.yaml index 2650ea3af..0898b7bc6 100755 --- a/neato/locale.yaml +++ b/neato/locale.yaml @@ -6,21 +6,21 @@ plugin_translations: # Alternative format for translations of longer texts: 'Vorwerk Explanation': - de: 'Schritt fuer Schritt Anleitung zur Generierung eines Oauth2 Tokens fuer Vorwerk API, kompatibel zur MyKobold APP.' + de: 'Schritt für Schritt Anleitung zur Generierung eines Oauth2 Tokens für Vorwerk API, kompatibel zur MyKobold APP.' en: 'Step by Step manual for generating an OAuth2 token for Vorwerk API, compatible with new MyKobold APP.' 'Anzahl Roboter': de: '=' en: 'Number Robots' - 'Alarme loeschen': + 'Alarme löschen': de: '=' en: 'Reset alarms' - 'Alarme geloescht': + 'Alarme gelöscht': de: '=' en: 'Alarms cleared' - 'Loescht Fehlermeldungen, wie z.B. Staubbehaelter leeren.': + 'Löscht Fehlermeldungen, wie z.B. Staubbehälter leeren.': de: '=' en: 'Resets error messages like for example dustbin full' - '1) Vorwerk account Email ueberpruefen': + '1) Vorwerk account Email überprüfen': de: '=' en: 'Doublecheck Vorwerk account email' '6) Token in plugin.yaml kopieren': @@ -35,7 +35,7 @@ plugin_translations: '3) Vorwerk Code anfragen': de: '=' en: 'Request Vowerk code' - '2) Diese oder andere gueltige ClientID eingeben': + '2) Diese oder andere gültige ClientID eingeben': de: '=' en: 'Use this or insert other valid ClientID' 'Code anfragen': @@ -59,16 +59,16 @@ plugin_translations: 'Hier Map ID eintragen': de: '=' en: 'Insert map ID here' - 'Schreibt die verfuegbaren Raeume ins Logfile': + 'Schreibt die verfügbaren Raeume ins Logfile': de: '=' en: 'Writes available rooms into logfile' - 'Liste Raeume': + 'Liste Räume': de: '=' en: 'List rooms' - 'Alarme im Backend loeschen': + 'Alarme im Backend löschen': de: '=' en: 'Reset alarms in backend' - 'Verfuegbare Boundary IDs (Raeume) ins Log schreiben': + 'Verfügbare Boundary IDs (Räume) ins Log schreiben': de: '=' en: 'Write available boundary IDs (rooms) into logfile' diff --git a/neato/plugin.yaml b/neato/plugin.yaml index 0d77baf71..75c9ba37c 100755 --- a/neato/plugin.yaml +++ b/neato/plugin.yaml @@ -12,7 +12,7 @@ plugin: documentation: https://github.com/smarthomeng/plugins/blob/develop/neato/README.md support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1417295-support-thread-plugin-neato - version: 1.6.8 # Plugin version + version: 1.6.9 # Plugin version sh_minversion: 1.8.0 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance @@ -98,9 +98,53 @@ item_attributes: - command_startAvailable - clean_room -item_structs: NONE - # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) - #item_structs: NONE +item_structs: + Neato: + Name: + type: str + neato_attribute: 'name' + visu_acl: ro + State: + type: str + value: 'offline' + neato_attribute: 'state' + visu_acl: ro + StateAction: + type: str + neato_attribute: 'state_action' + visu_acl: ro + Command: + type: num + neato_attribute: 'command' + visu_acl: rw + IsDocked: + value: False + type: bool + neato_attribute: 'is_docked' + visu_acl: ro + IsScheduleEnabled: + value: False + type: bool + neato_attribute: 'is_schedule_enabled' + visu_acl: rw + IsCharging: + value: False + type: bool + neato_attribute: 'is_charging' + visu_acl: ro + ChargePercentage: + type: num + neato_attribute: 'charge_percentage' + visu_acl: ro + GoToBaseAvailable: + type: bool + value: False + neato_attribute: 'command_goToBaseAvailable' + visu_acl: ro + Alert: + type: str + neato_attribute: 'alert' + visu_acl: ro plugin_functions: enable_schedule: diff --git a/neato/robot.py b/neato/robot.py index ed68aa0df..050875895 100755 --- a/neato/robot.py +++ b/neato/robot.py @@ -44,6 +44,7 @@ def __init__(self, email, password, vendor, token = ''): self.navigationMode = '' self.spotWidth = '' self.spotHeight = '' + self.mapId = 'unknown' # Meta self.name = "" @@ -149,7 +150,7 @@ def robot_command(self, command, arg1 = None, arg2 = None): responseJson = start_cleaning_response.json() self.logger.debug("Debug: send command response: {0}".format(start_cleaning_response.text)) if log_message: - self.logger.info("Requested Info: {0}".format(start_cleaning_response.text)) + self.logger.warning("INFO: Requested Info: {0}".format(start_cleaning_response.text)) if 'result' in responseJson: if str(responseJson['result']) == 'ok': @@ -158,13 +159,13 @@ def robot_command(self, command, arg1 = None, arg2 = None): self.logger.warning(f"Command returned {str(responseJson['result'])}: Retry starting with non-persistent-map") return self.robot_command(command = 'start_non-persistent-map') else: - self.logger.error("Sending command {command} failed. Result: {0}".format(str(responseJson['result']) )) - self.logger.error("Debug: send command response: {0}".format(start_cleaning_response.text)) + self.logger.error(f"Sending command {command} failed. Result: {str(responseJson['result'])}") + self.logger.error(f"Debug: send command response: {start_cleaning_response.text}") else: if 'message' in responseJson: - self.logger.error("Sending command {command} failed. Message: {0}".format(str(responseJson['message']))) + self.logger.error(f"Sending command {command} failed. Message: {str(responseJson['message'])}") if 'error' in responseJson: - self.logger.error("Sending command {command} failed. Error: {0}".format(str(responseJson['error']))) + self.logger.error(f"Sending command {command} failed. Error: {str(responseJson['error'])}") # - NOT on Charge BASE return start_cleaning_response @@ -201,7 +202,12 @@ def update_robot(self): 'Date': self.__get_current_date(), 'Accept': 'application/vnd.neato.nucleo.v1', 'Authorization': 'NEATOAPP ' + h.hexdigest()}, timeout=self._timeout, verify=self._verifySSL ) - + except requests.exceptions.ConnectionError as e: + self.logger.warning("Robot: This test works!: %s" % str(e)) + return 'error' + except requests.exceptions.Timeout as e: + self.logger.warning("Robot: Timeout exception during cloud state request: %s" % str(e)) + return 'error' except Exception as e: self.logger.error("Robot: Exception during cloud state request: %s" % str(e)) return 'error' @@ -286,6 +292,8 @@ def update_robot(self): self.navigationMode = response['cleaning']['navigationMode'] self.spotWidth = response['cleaning']['spotWidth'] self.spotHeight = response['cleaning']['spotHeight'] + if 'mapId' in response['cleaning']: + self.mapId = response['cleaning']['mapId'] return response @@ -362,7 +370,7 @@ def __get_current_date(self): try: locale.setlocale(locale.LC_TIME, 'en_US.utf8') except locale.Error as e: - self.logger.error("Robot: Locale setting Error. Please install locale en_US.utf8: "+e) + self.logger.error("Robot: Locale setting error. Please install locale en_US.utf8: "+e) return None date = time.strftime('%a, %d %b %Y %H:%M:%S', time.gmtime()) + ' GMT' locale.setlocale(locale.LC_TIME, saved_locale) diff --git a/neato/user_doc.rst b/neato/user_doc.rst index e15714cfe..f557bbc16 100755 --- a/neato/user_doc.rst +++ b/neato/user_doc.rst @@ -21,21 +21,143 @@ Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/co Anforderungen ============= -- locale en_US.utf8 must be installed (sudo dpkg-reconfigure locales) +1) Es muss auf dem SmarthomeNG Rechner en_US.utf8 installiert sein (sudo dpkg-reconfigure locales) -Supported Hardware -================== +Unterstützte Hardware +===================== -=============== ========= ====== -Robot Supported Tested -=============== ========= ====== -Neato Botvac D3 yes no -Neato Botvac D4 yes no -Neato Botvac D5 yes yes -Neato Botvac D6 yes no -Neato Botvac D7 yes no -Vorwerk VR300 yes yes -=============== ========= ====== +=============== ============= ========== +Roboter Unterstützt Getestet +=============== ============= ========== +Neato Botvac D3 ja nein +Neato Botvac D4 ja nein +Neato Botvac D5 ja ja +Neato Botvac D6 ja nein +Neato Botvac D7 ja nein +Vorwerk VR300 ja ja +=============== ============= ========== + +Authentifizierung +================= + +Das Plugin unterstützt zwei verschiedene Arten der Authentifizierung mit dem Neato oder Vorwerk Backend: + +a) Authentifizierung über Emailadresse des Nutzerkontos und zugehöriges Passwort. Nutzbar für Neato und alte Vorwerk API + +.. code-block:: yaml + + Neato: + plugin_name: neato + account_email: 'your_neato_account_email' + account_pass: 'your_neato_account_password!' + robot_vendor: 'neato or vorwerk' + +b) Oauth2 Authentifizierung über Emailadresse des Nutzerkontos und Token. Nutzbar nur für Vorwerk mit dem aktuellen MyKobol APP Interface + +.. code-block:: yaml + + Neato: + plugin_name: neato + account_email: 'your_neato_account_email' + token: 'HEX_ASCII_TOKEN' + robot_vendor: 'vorwerk' + +Der Token kann hier kompfortabel über die Schritt für Schritt Anleitung des Plugin Webinterfaces generiert werden, siehe Vorwerk OAuth2 Tab. + +Wenn eine Nutzung des Webinterfaces nicht möglich ist, kann ein Token auch manuell generiert werden. Hierzu: + +a) Neato plugin aktivieren und Emailadresse des Vorwerk Nutzerkontos konfigurieren. + +b) Plugin Logging auf Level INFO stellen (in logger.yaml oder via Admin Interface) + +c) Plugin Funktion request_oauth2_code ausführen. Hierbei wird ein Code bei Vorwerk angefragt, welcher an die oben angegebene Emaildresse gesendet wird. + +d) Nach Erhalt des Codes die Plugin Funktion request_oauth2_token(code) ausführen, wobei als Argument der per Email erhaltene Code übergeben wird. + +e) Im Logfile nach dem generierten ASCII Token im Hexadezimalformat suchen + +f) Das Hex ASCII Token in der plugin.yaml angeben. + + + +Unterstützte Plugin Attribute +============================= + +Folgende Item Attribute (neato_attribute) werden vom Plugin unterstützt: + +=========================== ========== ===================== +Attribut Itemtyp Lesend/Schreibend +=========================== ========== ===================== +name str r +state str r +state_action str r +command num w +is_docked bool r +is_schedule_enabled bool r +is_charging bool r +charge_percentage num r +command_goToBaseAvailable bool r +alert str r +clean_room str w +=========================== ========== ===================== + + +Roboter Status +-------------- + +Das String Item für den Roboterstatus (state) kann folgende Zustände einnehmen: + +======================= ==== +Roboterstatus (state) +======================= ==== +invalid +idle +busy +paused +error +======================= ==== + + +Das Num Item für den Roboterzustand (state_action) kann folgende Zustände einnehmen: + +========================================= ========= +Roboterzustand (state_action) dezimal +========================================= ========= +Invalid 0 +House Cleaning 1 +Spot Cleaning 2 +Manual Cleaning 3 +Docking 4 +User Menu Active 5 +Suspended Cleaning 6 +Updating 7 +Copying Logs 8 +Recovering Location 9 +IEC Test 10 +Map cleaning 11 +Exploring map (creating a persistent map) 12 +Acquiring Persistent Map IDs 13 +Creating & Uploading Map 14 +Suspended Exploration 15 +========================================= ========= + +Roboterbefehle +=============== + +Das Num Item für die Roboterbefehle (command) kann folgende Zustände einnehmen: + +============================= ========= +Befehl (command) dezimal +============================= ========= +Start cleaning 61 +Stop cleaning 62 +Pause cleaning 63 +Resume cleaning 64 +Find the robot 65 +Send to base 66 +Enable schedule 67 +Disable schedule 68 +============================= ========= Web Interface @@ -70,26 +192,42 @@ Im ersten Tab Vorwerk OAuth2 findet sich direkt die Schritt für Schritt Anleitu .. image:: assets/webif1.jpg :class: screenshot -Changelog +Im zweiten Tab Einstellungen findet sich zwei Optionen zum Löschen von gemeldeten Alarmmeldungen (z.B. Roboter Behälter leeren) und zum Auslesen aller bekannter RaumIDs (BoundardyIDs) zur +Einzelraumreinigung. Die bekannten Räume werden dazu in das Plugin Logfile geschrieben. Übergeben werden muss hierzu die Vorwerk MapID. Hierzu einmal manuell eine Einzelraumreinigung via Vorwerk/Neato +App anstoßen. Das Plugin extrahiert anschließend automatisch den Namen der MapID und schlägt diese als Eingabe im Eingabefeld des Webinterfaces vor. + +.. image:: assets/webif2.jpg + :class: screenshot + + +SmartVisu +========= + +Beispiele --------- -V1.6.8 added decoding of command availability status, e.g. "start" command available - If start command with persistent map is rejected due to "not_on_charge_base" error, retry start with non-persistent map. -V1.6.6 added option to clear errors/alarms in neato/vorwerk backend via plugin's webif +Beispiele für Integrationen in smartVisu: + +.. code-block:: html + +

{{ basic.button('RobotButton_Start', 'Neato.Robot.Command', 'Start', '', '61', 'midi') }}

+

{{ basic.button('RobotButton_Stop', 'Neato.Robot.Command', 'Stop', '', '62', 'midi') }}

+

{{ basic.button('RobotButton_Pause', 'Neato.Robot.Command', 'Pause', '', '63', 'midi') }}

+

{{ basic.button('RobotButton_Resume', 'Neato.Robot.Command', 'Resume', '', '64', 'midi') }}

+

{{ basic.button('RobotButton_Find', 'Neato.Robot.Command', 'Find', '', '65', 'midi') }}

-V1.6.5 added new function start_robot(boundary_id=None, map_id=None) to enable single room cleaning - added new function get_map_boundaries_robot(map_id=None) to request available map boundaries (rooms) for a given map - added new function dismiss_current_alert() to reset current alerts +

Name: {{ basic.value('RobotName', 'Neato.Robot.Name') }}

+ /** Get the robots name (str)*/ -V 1.6.4 fixed readout for docking state and go to base availability - combined all neato attribues into one +

Cleaning status: {{ basic.value('RobotState', 'Neato.Robot.State') }}

+ /** Get the robots cleaning status (str) */ -V 1.6.3 changed attribute charge_percentage from string to integer - added alert text output, e.g. dustbin full - Write obtained OAuth2 token obtained via web interface directly to config plugin.yaml +

Cleaning status action: {{ basic.value('RobotStateAction', 'Neato.Robot.StateAction') }}

+ /** Get the robots cleaning status action (str). Only when it's busy */ -V 1.6.2 Added webinterface +

Docking status: {{ basic.value('RobotDockingStatus', 'Neato.Robot.IsDocked') }}

+ /** Get the robots docking status (bool) */ -V 1.6.1 Added new Vorwerk Oauth2 based authentication feature (compatible with myKobold APP) +

Battery status: {{ basic.value('RobotBatteryState', 'Neato.Robot.ChargePercentage') }}

+ /** Get the robots battery charge status (num) */ -V 1.6.0 Initial working version diff --git a/neato/webif/__init__.py b/neato/webif/__init__.py index fd604010b..f5c81c9f1 100755 --- a/neato/webif/__init__.py +++ b/neato/webif/__init__.py @@ -113,8 +113,8 @@ def index(self, reload=None, action=None, email=None, hashInput=None, code=None, self.plugin.dismiss_current_alert() resetAlarmsSuccessfull = True elif action =="listAvailableMaps": - self.logger.warning("List all available maps via webinterface") boundaryListSuccessfull = self.plugin.get_map_boundaries(map_id=mapIDInput) + self.logger.warning(f"Request all available maps via webinterface successfull: {boundaryListSuccessfull }") else: self.logger.error("Unknown command received via webinterface") diff --git a/neato/webif/templates/index.html b/neato/webif/templates/index.html index 564762495..a41236fcd 100755 --- a/neato/webif/templates/index.html +++ b/neato/webif/templates/index.html @@ -157,7 +157,7 @@ {% if codeRequestSuccessfull %}

-{% endblock bodytab2 %} \ No newline at end of file +{% endblock bodytab2 %} diff --git a/uzsu/__init__.py b/uzsu/__init__.py index 7c4ae7a00..84c496ee6 100755 --- a/uzsu/__init__.py +++ b/uzsu/__init__.py @@ -74,9 +74,8 @@ # {'value':0, 'active':True, 'rrule':'FREQ=DAILY;INTERVAL=2;COUNT=5', 'time': '17:30'} # ]}) -import logging import functools -from lib.model.smartplugin import * +from lib.model.smartplugin import SmartPlugin from lib.item import Items from lib.shtime import Shtime @@ -87,11 +86,9 @@ from dateutil.tz import tzutc from unittest import mock from collections import OrderedDict -import copy import html import json from .webif import WebInterface -from scipy import interpolate ITEM_TAG = ['uzsu_item'] @@ -104,7 +101,7 @@ class UZSU(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.6.5" # item buffer for all uzsu enabled items + PLUGIN_VERSION = "2.0.0" # item buffer for all uzsu enabled items def __init__(self, smarthome): """ @@ -127,7 +124,7 @@ def __init__(self, smarthome): self._update_count = {'todo': 0, 'done': 0} self._itpl = {} self.init_webinterface(WebInterface) - self.logger.info("Init with timezone {}".format(self._timezone)) + self.logger.info(f'Init with timezone {self._timezone}') def run(self): """ @@ -137,20 +134,20 @@ def run(self): self.logger.debug("run method called") self.alive = True self.scheduler_add('uzsu_sunupdate', self._update_all_suns, - value={'caller': 'scheduler'}, cron=self._suncalculation_cron) + value={'caller': 'Scheduler:UZSU'}, cron=self._suncalculation_cron) self.logger.info("Adding sun update schedule for midnight") for item in self._items: self._add_dicts(item) self._items[item]['interpolation']['itemtype'] = self._add_type(item) self._lastvalues[item] = None - self._webdata['items'][item.id()].update({'lastvalue': '-'}) + self._webdata['items'][item.property.path].update({'lastvalue': '-'}) self._update_item(item, 'UZSU Plugin', 'run') - cond1 = self._items[item].get('active') and self._items[item]['active'] is True + cond1 = self._items[item].get('active') and self._items[item]['active'] cond2 = self._items[item].get('list') if cond1 and cond2: - self._update_count['todo'] = self._update_count.get('todo') + 1 - self.logger.debug("Going to update {} items from {}".format(self._update_count['todo'], list(self._items.keys()))) + self._update_count['todo'] = self._update_count.get('todo', 0) + 1 + self.logger.debug(f'Going to update {self._update_count["todo"]} items from {list(self._items.keys())}') for item in self._items: cond1 = self._items[item].get('active') is True @@ -159,20 +156,19 @@ def run(self): try: self._items[item].pop('lastvalue') self._update_item(item, 'UZSU Plugin', 'lastvalue removed') - self.logger.debug("Item '{}': removed lastvalue dict entry as it is deprecated.".format(item)) + self.logger.debug(f'Item "{item}": removed lastvalue dict entry as it is deprecated.') except Exception: pass self._check_rruleandplanned(item) if cond1 and cond2: self._schedule(item, caller='run') elif cond1 and not cond2: - self.logger.warning("Item '{}' is active but has no entries.".format(item)) + self.logger.warning(f'Item "{item}" is active but has no entries.') self._planned.update({item: None}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) else: - self.logger.debug("Not scheduling item {}, cond1 {}, cond2 {}".format(item, cond1, cond2)) - self.logger.info('Dry run of scheduler calculation for item {}' - 'to get calculated sunset/rise entries'.format(item)) + self.logger.debug(f'Not scheduling item {item}, cond1 {cond1}, cond2 {cond2}') + self.logger.info(f'Dry run of scheduler calculation for item {item} to get calculated sunset/rise entries') self._schedule(item, caller='dry_run') def stop(self): @@ -180,12 +176,13 @@ def stop(self): Stop method for the plugin """ self.logger.debug("stop method called") + self.scheduler_remove('uzsu_sunupdate') for item in self._items: try: - self.scheduler_remove('{}'.format(item.property.path)) - self.logger.debug('Removing scheduler for item {}'.format(item.property.path)) + self.scheduler_remove(item.property.path) + self.logger.debug(f'Removing scheduler for item {item.property.path}') except Exception as err: - self.logger.debug('Scheduler for item {} not removed. Problem: {}'.format(item.property.path, err)) + self.logger.debug(f'Scheduler for item {item.property.path} not removed. Problem: {err}') self.alive = False def _update_all_suns(self, caller=None): @@ -195,9 +192,9 @@ def _update_all_suns(self, caller=None): :type caller: str """ for item in self._items: - success = self._update_sun(item) + success = self._update_sun(item, caller="update_all_suns") if success: - self.logger.debug('Updating sun info for item {}. Caller: {}'.format(item, caller)) + self.logger.debug(f'Updating sun info for item {item}. Caller: {caller}') self._update_item(item, 'UZSU Plugin', 'update_all_suns') def _update_sun(self, item, caller=None): @@ -217,36 +214,35 @@ def _update_sun(self, item, caller=None): _sunrise = _sunrise.astimezone(self._timezone) if _sunset.tzinfo == tzutc(): _sunset = _sunset.astimezone(self._timezone) - self._items[item]['sunrise'] = '{:02}:{:02}'.format(_sunrise.hour, _sunrise.minute) - self._items[item]['sunset'] = '{:02}:{:02}'.format(_sunset.hour, _sunset.minute) - self.logger.debug('Updated sun entries for item {}, triggered by {}. sunrise: {}, sunset: {}'.format( - item, caller, self._items[item]['sunrise'], self._items[item]['sunset'])) + self._items[item]['sunrise'] = f'{_sunrise.hour:02}:{_sunrise.minute:02}' + self._items[item]['sunset'] = f'{_sunset.hour:02}:{_sunset.minute:02}' + self.logger.debug(f'Updated sun entries for item {item}, triggered by {caller}. sunrise: {self._items[item]["sunrise"]}, sunset: {self._items[item]["sunset"]}') success = True except Exception as e: - success = False - self.logger.debug("Not updated sun entries for item {}. Error {}".format(item, e)) + success = f'Not updated sun entries for item {item}. Error {e}' + self.logger.debug(success) return success def _update_suncalc(self, item, entry, entryindex, entryvalue): update = False if entry.get('calculated'): - update = True if entry == self._items[item]['list'][entryindex] else update + if entry == self._items[item]['list'][entryindex]: + update = True with mock.patch.dict(entry, calculated=entryvalue): - update = True if entry == self._items[item]['list'][entryindex] else update + if entry == self._items[item]['list'][entryindex]: + update = True else: update = True if entry.get('calculated') and entryvalue is None: - self.logger.debug("No sunset/rise in time for current entry {}. Removing calculated value.".format(entry)) + self.logger.debug(f'No sunset/rise in time for current entry {entry}. Removing calculated value.') self._items[item]['list'][entryindex].pop('calculated') self._update_item(item, 'UZSU Plugin', 'update_sun') elif update is True and not entry.get('calculated') == entryvalue: - self.logger.debug("Updated calculated time for item {} entry {} with value {}.".format( - item, self._items[item]['list'][entryindex], entryvalue)) + self.logger.debug(f'Updated calculated time for item {item} entry {self._items[item]["list"][entryindex]} with value {entryvalue}.') self._items[item]['list'][entryindex]['calculated'] = entryvalue self._update_item(item, 'UZSU Plugin', 'update_sun') elif entry.get('calculated'): - self.logger.debug("Sun calculation {} entry not updated for item {} with value {}".format( - entryvalue, item, entry.get('calculated'))) + self.logger.debug(f'Sun calculation {entryvalue} entry not updated for item {item} with value {entry["calculated"]}') def _add_type(self, item): """ @@ -260,19 +256,21 @@ def _add_type(self, item): _uzsuitem = self.itemsApi.return_item(_itemforuzsu) except Exception as err: _uzsuitem = None - self.logger.warning("Item to be set by uzsu '{}' does not exist. Error: {}".format(_itemforuzsu, err)) + self.logger.warning(f'Item to be set by uzsu "{_itemforuzsu}" does not exist. Error: {err}') try: _itemtype = _uzsuitem.property.type except Exception as err: try: _itemtype = _uzsuitem.type() except Exception: - _itemtype = 'foo' if _uzsuitem is not None else None + _itemtype = 'foo' if _uzsuitem else None if _itemtype is None: - self.logger.warning("Item to be set by uzsu '{}' does not exist. Error: {}".format(_itemforuzsu, err)) + # TODO: this is apparently wrong, as existence of the item was + # established in the previous try/except block. What does this + # error actually indicate? + self.logger.warning(f'Item to be set by uzsu "{_itemforuzsu}" does not exist. Error: {err}') else: - self.logger.warning("Item to be set by uzsu '{}' does not have a type attribute." - "Error: {}".format(_itemforuzsu, err)) + self.logger.warning(f'Item to be set by uzsu "{_itemforuzsu}" does not have a type attribute. Error: {err}') return _itemtype def _logics_lastvalue(self, by=None, item=None): @@ -280,16 +278,15 @@ def _logics_lastvalue(self, by=None, item=None): lastvalue = self._lastvalues.get(item) else: lastvalue = None - by_test = " Queried by {}".format(by) if by is not None else "" - self.logger.debug("Last value of item {} is: {}.{}".format(item, lastvalue, by_test)) + by_test = f' Queried by {by}' if by else "" + self.logger.debug(f'Last value of item {item} is: {lastvalue}.{by_test}') return lastvalue def _logics_resume(self, activevalue=True, item=None): self._logics_activate(True, item) lastvalue = self._logics_lastvalue(item) self._set(item=item, value=lastvalue, caller='logic') - self.logger.info("Resuming item {}: Activated and value set to {}." - "Active value: {}".format(item, lastvalue, activevalue)) + self.logger.info(f'Resuming item {item}: Activated and value set to {lastvalue}. Active value: {activevalue}') return lastvalue def _logics_activate(self, activevalue=None, item=None): @@ -299,11 +296,11 @@ def _logics_activate(self, activevalue=None, item=None): elif activevalue.lower() in ['0', 'no', 'false', 'off']: activevalue = False else: - self.logger.warning("Value to activate item '{}' has to be True or False".format(item)) + self.logger.warning(f'Value to activate item "{item}" has to be True or False') if isinstance(activevalue, bool): self._items[item] = item() self._items[item]['active'] = activevalue - self.logger.info("Item {} is set via logic to: {}".format(item, activevalue)) + self.logger.info(f'Item {item} is set via logic to: {activevalue}') self._update_item(item, 'UZSU Plugin', 'logic') return activevalue if activevalue is None: @@ -319,8 +316,7 @@ def _logics_interpolation(self, intpl_type=None, interval=None, backintime=None, self._items[item]['interpolation']['type'] = str(intpl_type).lower() self._items[item]['interpolation']['interval'] = abs(int(interval)) self._items[item]['interpolation']['initage'] = int(backintime) - self.logger.info("Item {} interpolation is set via logic to: type={}, interval={}, backintime={}".format( - item, intpl_type, abs(interval), backintime)) + self.logger.info(f'Item {item} interpolation is set via logic to: type={intpl_type}, interval={abs(interval)}, backintime={backintime}') self._update_item(item, 'UZSU Plugin', 'logic') return self._items[item].get('interpolation') @@ -329,11 +325,11 @@ def _logics_clear(self, clear=False, item=None): if clear.lower() in ['1', 'yes', 'true', 'on']: clear = True else: - self.logger.warning("Value to clear uzsu item '{}' has to be True".format(item)) + self.logger.warning(f'Value to clear uzsu item "{item}" has to be True') if isinstance(clear, bool) and clear is True: self._items[item].clear() self._items[item] = {'interpolation': {}, 'active': False} - self.logger.info("UZSU settings for item '{}' are cleared".format(item)) + self.logger.info(f'UZSU settings for item "{item}" are cleared') self._update_item(item, 'UZSU Plugin', 'clear') return True else: @@ -345,28 +341,28 @@ def _logics_itpl(self, clear=False, item=None): clear = True if isinstance(clear, bool) and clear is True: self._itpl[item].clear() - self.logger.info("UZSU interpolation dict for item '{}' is cleared".format(item)) + self.logger.info(f'UZSU interpolation dict for item "{item}" is cleared') return self._itpl[item] else: - self.logger.info("UZSU interpolation dict for item '{}' is: {}".format(item, self._itpl[item])) + self.logger.info(f'UZSU interpolation dict for item "{item}" is: {self._itpl[item]}') return self._itpl[item] def _logics_planned(self, item=None): if self._planned.get(item) not in [None, {}, 'notinit'] and self._items[item].get('active') is True: - self.logger.info("Item '{}' is going to be set to {} at {}".format( - item, self._planned[item]['value'], self._planned[item]['next'])) - self._webdata['items'][item.id()].update({'planned': {'value': self._planned[item]['value'], 'time': self._planned[item]['next']}}) + self.logger.info(f"Item '{item}' is going to be set to {self._planned[item]['value']} at {self._planned[item]['next']}") + self._webdata['items'][item.property.path].update( + {'planned': {'value': self._planned[item]['value'], 'time': self._planned[item]['next']}}) return self._planned[item] elif self._planned.get(item) == 'notinit' and self._items[item].get('active') is True: - self.logger.info("Item '{}' is active but not fully initialized yet.".format(item)) + self.logger.info(f'Item "{item}" is active but not fully initialized yet.') return None elif not self._planned.get(item) and self._items[item].get('active') is True: - self.logger.warning("Item '{}' is active but has no (active) entries.".format(item)) + self.logger.warning(f'Item "{item}" is active but has no (active) entries.') self._planned.update({item: None}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) return None else: - self.logger.info("Nothing planned for item '{}'.".format(item)) + self.logger.info(f'Nothing planned for item "{item}".') return None def _add_dicts(self, item): @@ -375,20 +371,27 @@ def _add_dicts(self, item): :param item: The item to process :type item: item """ - if not self._items[item].get('interpolation'): - self._items[item]['interpolation'] = {} - if not self._items[item]['interpolation'].get('type'): - self._items[item]['interpolation']['type'] = 'none' - if not self._items[item]['interpolation'].get('initialized'): - self._items[item]['interpolation']['initialized'] = False - if not self._items[item]['interpolation'].get('interval'): - self._items[item]['interpolation']['interval'] = self._interpolation_interval - if not self._items[item]['interpolation'].get('initage'): - self._items[item]['interpolation']['initage'] = self._backintime + if 'interpolation' not in self._items[item]: + self._items[item]['interpolation'] = { + 'type': 'none', + 'initialized': False, + 'interval': self._interpolation_interval, + 'initage': self._backintime + } + else: + if 'type' not in self._items[item]['interpolation']: + self._items[item]['interpolation']['type'] = 'none' + if 'initialized' not in self._items[item]['interpolation']: + self._items[item]['interpolation']['initialized'] = False + if 'interval' not in self._items[item]['interpolation']: + self._items[item]['interpolation']['interval'] = self._interpolation_interval + if 'initage' not in self._items[item]['interpolation']: + self._items[item]['interpolation']['initage'] = self._backintime + self._items[item]['plugin_version'] = self.PLUGIN_VERSION - if not self._items[item].get('list'): + if 'list' not in self._items[item]: self._items[item]['list'] = [] - if self._items[item].get('active') is None: + if 'active' not in self._items[item]: self._items[item]['active'] = False def parse_item(self, item): @@ -418,7 +421,7 @@ def parse_item(self, item): item.itpl = functools.partial(self._logics_itpl, item=item) self._items[item] = item() - if self._items[item].get('interpolation'): + if 'interpolation' in self._items[item]: self._items[item]['interpolation']['initialized'] = False if self._items[item].get('list'): for entry, _ in enumerate(self._items[item]['list']): @@ -426,22 +429,22 @@ def parse_item(self, item): self._items[item]['list'][entry].pop('holiday', None) self._items[item]['list'][entry].pop('delayedExec', None) - self._webdata['items'].update({item.id(): {}}) + self._webdata['items'].update({item.property.path: {}}) self._update_item(item, 'UZSU Plugin', 'init') self._planned.update({item: 'notinit'}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) return self.update_item def _remove_dupes(self, item): self._items[item]['list'] = [i for n, i in enumerate(self._items[item]['list']) if i not in self._items[item]['list'][:n]] - self.logger.debug('Removed duplicate entries for item {}.'.format(item)) + self.logger.debug(f'Removed duplicate entries for item {item}.') compare_entries = item.prev_value() if compare_entries.get('list'): newentries = [] [newentries.append(i) for i in self._items[item]['list'] if i not in compare_entries['list']] - self.logger.debug('Got update for item {}: {}'.format(item, newentries)) + self.logger.debug(f'Got update for item {item}: {newentries}') for entry in self._items[item]['list']: for new in newentries: found = False @@ -456,9 +459,7 @@ def _remove_dupes(self, item): self._items[item]['list'][self._items[item]['list'].index(entry)].update({'active': False}) time = entry['time'] oldvalue, newvalue = entry['value'], new['value'] - self.logger.warning("Set old entry for item '{}' at {} with value {} to inactive" - " because newer active entry with value {} found.".format( - item, time, oldvalue, newvalue)) + self.logger.warning(f'Set old entry for item "{item}" at {time} with value {oldvalue} to inactive because newer active entry with value {newvalue} found.') def _check_rruleandplanned(self, item): if self._items[item].get('list'): @@ -473,15 +474,15 @@ def _check_rruleandplanned(self, item): self._items[item]['list'][_index]['rrule'] = 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU' count += 1 except Exception as err: - self.logger.warning("Error creating rrule: {}".format(err)) + self.logger.warning(f'Error creating rrule: {err}') if count > 0: - self.logger.debug("Updated {} rrule entries for item: {}".format(count, item)) + self.logger.debug(f'Updated {count} rrule entries for item: {item}') self._update_item(item, 'UZSU Plugin', 'create_rrule') if _inactive >= len(self._items[item]['list']): self._planned.update({item: None}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) - def update_item(self, item, caller=None, source=None, dest=None): + def update_item(self, item, caller=None, source='', dest=None): """ This is called by smarthome engine when the item changes, e.g. by Visu or by the command line interface The relevant item is put into the internal item list and registered to the scheduler @@ -491,8 +492,7 @@ def update_item(self, item, caller=None, source=None, dest=None): :param dest: if given it represents the dest """ cond = (not caller == 'UZSU Plugin') or source == 'logic' - self.logger.debug('Update Item {}, Caller {}, Source {}, Dest {}. Will update: {}'.format( - item, caller, source, dest, cond)) + self.logger.debug(f'Update Item {item}, Caller {caller}, Source {source}, Dest {dest}. Will update: {cond}') if not source == 'create_rrule': self._check_rruleandplanned(item) # Removing Duplicates @@ -504,17 +504,15 @@ def update_item(self, item, caller=None, source=None, dest=None): self._items[item]['interpolation']['itemtype'] = self._add_type(item) if cond and self._items[item].get('active') is False and not source == 'update_sun': self._lastvalues[item] = None - self._webdata['items'][item.id()].update({'lastvalue': '-'}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) - self.logger.debug('lastvalue for item {} set to None because UZSU is deactivated'.format(item)) + self._webdata['items'][item.property.path].update({'lastvalue': '-'}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) + self.logger.debug(f'lastvalue for item {item} set to None because UZSU is deactivated') if cond: self._schedule(item, caller='update') elif 'sun' in source: - self.logger.info('Not running dry run of scheduler calculation for item {}' - ' because of {} source'.format(item, source)) + self.logger.info(f'Not running dry run of scheduler calculation for item {item} because of {source} source') else: - self.logger.info('Dry run of scheduler calculation for item {}' - ' to get calculated sunset/rise entries. Source: {}'.format(item, source)) + self.logger.info(f'Dry run of scheduler calculation for item {item} to get calculated sunset/rise entries. Source: {source}') self._schedule(item, caller='dry_run') if self._items[item] != self.itemsApi.return_item(str(item)) and cond: @@ -523,57 +521,83 @@ def update_item(self, item, caller=None, source=None, dest=None): def _update_item(self, item, caller="", comment=""): success = self._get_sun4week(item, caller="_update_item") if success: - self.logger.debug('Updated weekly sun info for item {}' - ' caller : {} comment : {}'.format(item, caller, comment)) + self.logger.debug(f'Updated weekly sun info for item {item} caller: {caller} comment: {comment}') else: - self.logger.debug('Issues with updating weekly sun info' - ' for item {} caller : {} comment : {}'.format(item, caller, comment)) - success = False + self.logger.debug(f'Issues with updating weekly sun info for item {item} caller: {caller} comment: {comment}') success = self._series_calculate(item, caller, comment) - if success: - self.logger.debug('Updated seriesCalculated for item {}' - ' caller : {} comment : {}'.format(item, caller, comment)) + if success is True: + self.logger.debug(f'Updated seriesCalculated for item {item} caller: {caller} comment: {comment}') else: - self.logger.debug('Issues with updating seriesCalculated' - ' for item {} caller : {} comment : {}'.format(item, caller, comment)) - success = False + self.logger.debug(f'Issues with updating seriesCalculated for item {item} caller: {caller} comment: {comment}, issue: {success}') success = self._update_sun(item, caller="_update_item") - if success: - self.logger.debug('Updated sunset/rise calculations for item {}' - ' caller : {} comment : {}'.format(item, caller, comment)) + if success is True: + self.logger.debug(f'Updated sunset/rise calculations for item {item} caller: {caller} comment: {comment}') else: - self.logger.debug('Issues with updating sunset/rise calculations' - ' for item {} caller : {} comment : {}'.format(item, caller, comment)) + self.logger.debug(f'Issues with updating sunset/rise calculations for item {item} caller: {caller} comment: {comment}, issue: {success}') item(self._items[item], caller, comment) - self._webdata['items'][item.id()].update({'interpolation': self._items[item].get('interpolation')}) - self._webdata['items'][item.id()].update({'active': str(self._items[item].get('active'))}) - #self._webdata['items'][item.id()].update({'sun': self._items[item].get('SunCalculated')}) + self._webdata['items'][item.property.path].update({'interpolation': self._items[item].get('interpolation')}) + self._webdata['items'][item.property.path].update({'active': str(self._items[item].get('active'))}) + # self._webdata['items'][item.property.path].update({'sun': self._items[item].get('SunCalculated')}) _suncalc = self._items[item].get('SunCalculated') - self._webdata['items'][item.id()].update({'sun': _suncalc}) + self._webdata['items'][item.property.path].update({'sun': _suncalc}) self._webdata['sunCalculated'] = _suncalc - self._webdata['items'][item.id()].update({'dict': self.get_itemdict(item)}) - if not comment == "init": + self._webdata['items'][item.property.path].update({'dict': self.get_itemdict(item)}) + if comment != "init": _uzsuitem, _itemvalue = self._get_dependant(item) - item_id = None if _uzsuitem is None else _uzsuitem.id() - self._webdata['items'][item.id()].update({'depend': {'item': item_id, 'value': str(_itemvalue)}}) + item_id = None if _uzsuitem is None else _uzsuitem.property.path + self._webdata['items'][item.property.path].update({'depend': {'item': item_id, 'value': str(_itemvalue)}}) + + def _interpolate(self, data: dict, time: float, linear=True, use_precision=True): + """ + Returns linear / cubic interpolation for series data at specified time + """ + ts_last = 0 + ts_next = -1 + for ts in data.keys(): + if ts <= time and ts > ts_last: + ts_last = ts + # use <= to get last data value for series of identical timestamps + if ts >= time and (ts <= ts_next or ts_next == -1): + ts_next = ts + + if time == ts_next: + value = float(data[ts_next]) + elif time == ts_last: + value = float(data[ts_last]) + else: + d_last = float(data[ts_last]) + d_next = float(data[ts_next]) + + if linear: + + # linear interpolation + value = d_last + ((d_next - d_last) / (ts_next - ts_last)) * (time - ts_last) + else: + + # cubic interpolation with m0/m1 = 0 + t = (time - ts_last) / (ts_next - ts_last) + value = (2 * t ** 3 - 3 * t ** 2 + 1) * d_last + (-2 * t ** 3 + 3 * t ** 2) * d_next + + if use_precision: + value = round(value, self._interpolation_precision) + + return value def _schedule(self, item, caller=None): """ This function schedules an item: First the item is removed from the scheduler. If the item is active then the list is searched for the nearest next execution time. No matter if active or not the calculation for the execution time is triggered. - :param item: item to be updated towards the plugin + :param item: item to be updated towards the plugin. :param caller: if given it represents the callers name. If the caller is set - to "dry_run" the evaluation of sun entries takes place but no scheduler will be set + to "dry_run" the evaluation of sun entries takes place but no scheduler will be set. """ if caller != "dry_run": - self.scheduler_remove('{}'.format(item.property.path)) - _caller = "scheduler" - self.logger.debug('Schedule Item {}, Trigger: {}, Changed by: {}'.format( - item, caller, item.changed_by())) + self.scheduler_remove(item.property.path) + _caller = "Scheduler:UZSU" + self.logger.debug(f'Schedule Item {item}, Trigger: {caller}, Changed by: {item.changed_by()}') else: - self.logger.debug('Calculate Item {}, Trigger: {}, Changed by: {}'.format( - item, caller, item.changed_by())) + self.logger.debug(f'Calculate Item {item}, Trigger: {caller}, Changed by: {item.changed_by()}') _caller = "dry_run" _next = None _value = None @@ -583,16 +607,10 @@ def _schedule(self, item, caller=None): self._items[item]['interpolation']['itemtype'] == 'none': self._items[item]['interpolation']['itemtype'] = self._add_type(item) if self._items[item].get('interpolation') is None: - self.logger.error("Something is wrong with your UZSU item. You most likely use a" - " wrong smartVISU widget version!" - " Use the latest device.uzsu SV 2.9. or higher " - "If you write your uzsu dict directly please use the format given in the documentation: " - "https://www.smarthomeng.de/user/plugins/uzsu/user_doc.html and " - "include the interpolation array correctly!") + self.logger.error("Something is wrong with your UZSU item. You most likely use a wrong smartVISU widget version! Use the latest device.uzsu SV 2.9. or higher If you write your uzsu dict directly please use the format given in the documentation: https://www.smarthomeng.de/user/plugins/uzsu/user_doc.html and include the interpolation array correctly!") return elif not self._items[item]['interpolation'].get('itemtype'): - self.logger.error("item '{}' to be set by uzsu does not exist.".format( - self.get_iattr_value(item.conf, ITEM_TAG[0]))) + self.logger.error(f'item "{self.get_iattr_value(item.conf, ITEM_TAG[0])}" to be set by uzsu does not exist.') elif self._items[item].get('active') is True or _caller == "dry_run": self._itpl[item] = OrderedDict() for i, entry in enumerate(self._items[item]['list']): @@ -604,23 +622,20 @@ def _schedule(self, item, caller=None): next = previous value = previousvalue if next is not None: - self.logger.debug("uzsu active entry for item {} with datetime {}, value {}" - " and tzinfo {}".format(item, next, value, next.tzinfo)) + self.logger.debug(f'uzsu active entry for item {item} with datetime {next}, value {value} and tzinfo {next.tzinfo}') if _next is None: _next = next _value = value elif next and next < _next: - self.logger.debug("uzsu active entry for item {} using now {}, value {}" - " and tzinfo {}".format(item, next, value, next.tzinfo)) + self.logger.debug(f'uzsu active entry for item {item} using now {next}, value {value} and tzinfo {next.tzinfo}') _next = next _value = value else: - self.logger.debug("uzsu active entry for item {} keep {}, value {} and tzinfo {}".format( - item, _next, _value, _next.tzinfo)) + self.logger.debug(f'uzsu active entry for item {item} keep {_next}, value {_value} and tzinfo {_next.tzinfo}') elif not self._items[item].get('list') and self._items[item].get('active') is True: - self.logger.warning("item '{}' is active but has no entries.".format(item)) + self.logger.warning(f'item "{item}" is active but has no entries.') self._planned.update({item: None}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) if _next and _value is not None and (self._items[item].get('active') is True or _caller == "dry_run"): _reset_interpolation = False _interval = self._items[item]['interpolation'].get('interval') @@ -645,7 +660,7 @@ def _schedule(self, item, caller=None): itpl_list.remove((entry_now, 'NOW')) if _caller != "dry_run": self._lastvalues[item] = _initvalue - self._webdata['items'][item.id()].update({'lastvalue': _initvalue}) + self._webdata['items'][item.property.path].update({'lastvalue': _initvalue}) _timediff = datetime.now(self._timezone) - timedelta(minutes=_initage) if not self._items[item]['interpolation'].get('itemtype') == 'bool': try: @@ -660,71 +675,48 @@ def _schedule(self, item, caller=None): cond6 = caller != 'set' and _caller != "dry_run" self._itpl[item] = OrderedDict(itpl_list) if not cond2 and cond3 and cond4: - self.logger.info("Looking if there was a value set after {} for item {}".format( - _timediff, item)) + self.logger.info(f'Looking if there was a value set after {_timediff} for item {item}') self._items[item]['interpolation']['initialized'] = True self._update_item(item, 'UZSU Plugin', 'init') if cond1 and not cond2 and cond3 and cond6: self._set(item=item, value=_initvalue, caller=_caller) - self.logger.info("Updated item {} on startup with value {} from time {}".format( - item, _initvalue, datetime.fromtimestamp(_inittime/1000.0))) + self.logger.info(f'Updated item {item} on startup with value {_initvalue} from time {datetime.fromtimestamp(_inittime/1000.0)}') _itemtype = self._items[item]['interpolation'].get('itemtype') if cond2 and _interval < 1: - self.logger.warning("Interpolation is set to {} but interval is {}. Ignoring interpolation".format( - _interpolation, _interval)) + self.logger.warning(f'Interpolation is set to {_interpolation} but interval is {_interval}. Ignoring interpolation') elif cond2 and _itemtype not in ['num']: - self.logger.warning("Interpolation is set to {} but type of item {} is {}." - " Ignoring interpolation and setting UZSU interpolation to none.".format( - item, _interpolation, _itemtype)) + self.logger.warning(f'Interpolation is set to {item} but type of item {_interpolation} is {_itemtype}. Ignoring interpolation and setting UZSU interpolation to none.') _reset_interpolation = True - elif _interpolation.lower() == 'cubic' and _interval > 0: + elif _interpolation.lower() in ('cubic', 'linear') and _interval > 0: try: - tck = interpolate.PchipInterpolator(list(self._itpl[item].keys()), list(self._itpl[item].values())) _nextinterpolation = datetime.now(self._timezone) + timedelta(minutes=_interval) _next = _nextinterpolation if _next > _nextinterpolation else _next - _value = round(float(tck(_next.timestamp() * 1000.0)), self._interpolation_precision) - _value_now = round(float(tck(entry_now)), self._interpolation_precision) + _value = self._interpolate(self._itpl[item], _next.timestamp() * 1000.0, _interpolation.lower() == 'linear') + _value_now = self._interpolate(self._itpl[item], entry_now, _interpolation.lower() == 'linear') if _caller != "dry_run": self._set(item=item, value=_value_now, caller=_caller) - self.logger.info("Updated: {}, cubic interpolation value: {}, based on dict: {}." - " Next: {}, value: {}".format(item, _value_now, self._itpl[item], _next, _value)) - except Exception as e: - self.logger.error("Error cubic interpolation for item {} " - "with interpolation list {}: {}".format(item, self._itpl[item], e)) - elif _interpolation.lower() == 'linear' and _interval > 0: - try: - tck = interpolate.interp1d(list(self._itpl[item].keys()), list(self._itpl[item].values())) - _nextinterpolation = datetime.now(self._timezone) + timedelta(minutes=_interval) - _next = _nextinterpolation if _next > _nextinterpolation else _next - _value = round(float(tck(_next.timestamp() * 1000.0)), self._interpolation_precision) - _value_now = round(float(tck(entry_now)), self._interpolation_precision) - if caller != 'set' and _caller != "dry_run": - self._set(item=item, value=_value_now, caller=_caller) - self.logger.info("Updated: {}, linear interpolation value: {}, based on dict: {}." - " Next: {}, value: {}".format(item, _value_now, self._itpl[item], _next, _value)) + self.logger.info(f'Updated: {item}, {_interpolation.lower()} interpolation value: {_value_now}, based on dict: {self._itpl[item]}. Next: {_next}, value: {_value}') except Exception as e: - self.logger.error("Error linear interpolation: {}".format(e)) + self.logger.error(f'Error {_interpolation.lower()} interpolation for item {item} with interpolation list {self._itpl[item]}: {e}') if cond5 and _value < 0: - self.logger.warning("value {} for item '{}' is negative. This might be due" - " to not enough values set in the UZSU.".format(_value, item)) + self.logger.warning(f'value {_value} for item "{item}" is negative. This might be due to not enough values set in the UZSU.') if _reset_interpolation is True: self._items[item]['interpolation']['type'] = 'none' self._update_item(item, 'UZSU Plugin', 'reset_interpolation') if _caller != "dry_run": - self.logger.debug("will add scheduler named uzsu_{} with datetime {} and tzinfo {}" - " and value {}".format(item.property.path, _next, _next.tzinfo, _value)) + self.logger.debug(f'will add scheduler named uzsu_{item.property.path} with datetime {_next} and tzinfo {_next.tzinfo} and value {_value}') self._planned.update({item: {'value': _value, 'next': _next.strftime('%Y-%m-%d %H:%M')}}) - self._webdata['items'][item.id()].update({'planned': {'value': _value, 'time': _next.strftime('%d.%m.%Y %H:%M')}}) - self._update_count['done'] = self._update_count.get('done') + 1 - self.scheduler_add('{}'.format(item.property.path), self._set, - value={'item': item, 'value': _value}, next=_next) + self._webdata['items'][item.property.path].update({'planned': {'value': _value, 'time': _next.strftime('%d.%m.%Y %H:%M')}}) + self._update_count['done'] = self._update_count.get('done', 0) + 1 + self.scheduler_add(item.property.path, self._set, + value={'item': item, 'value': _value, 'caller': 'Scheduler'}, next=_next) if self._update_count.get('done') == self._update_count.get('todo'): self.scheduler_trigger('uzsu_sunupdate', by='UZSU Plugin') self._update_count = {'done': 0, 'todo': 0} elif self._items[item].get('active') is True and self._items[item].get('list'): - self.logger.warning("item '{}' is active but has no active entries.".format(item)) + self.logger.warning(f'item "{item}" is active but has no active entries.') self._planned.update({item: None}) - self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self._webdata['items'][item.property.path].update({'planned': {'value': '-', 'time': '-'}}) def _set(self, item=None, value=None, caller=None): """ @@ -735,8 +727,8 @@ def _set(self, item=None, value=None, caller=None): """ _uzsuitem, _itemvalue = self._get_dependant(item) _uzsuitem(value, 'UZSU Plugin', 'set') - self._webdata['items'][item.id()].update({'depend': {'item': _uzsuitem.id(), 'value': str(_itemvalue)}}) - if not caller: + self._webdata['items'][item.property.path].update({'depend': {'item': _uzsuitem.property.path, 'value': str(_itemvalue)}}) + if not caller or caller == "Scheduler": self._schedule(item, caller='set') def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): @@ -767,6 +759,7 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): if 'time' not in entry: return None, None value = entry['value'] + next = None active = True if caller == "dry_run" else entry['active'] today = datetime.today() tomorrow = today + timedelta(days=1) @@ -784,22 +777,21 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): try: rrule = rrulestr(entry['rrule'], dtstart=datetime.combine( weekbefore, parser.parse(time.strip()).time())) - self.logger.debug("Created rrule: '{}' for time:'{}'".format( - str(rrule).replace('\n', ';'), time)) + rstr = str(rrule).replace('\n', ';') + self.logger.debug(f"Created rrule: '{rstr}'' for time:'{time}'") except ValueError: - self.logger.debug("Could not create a rrule from rrule: '{}' and time:'{}'".format( - entry['rrule'], time)) + self.logger.debug(f"Could not create a rrule from rrule: '{entry['rrule']}' and time:'{time}'") if 'sun' in time: rrule = rrulestr(entry['rrule'], dtstart=datetime.combine( weekbefore, self._sun(datetime.combine(weekbefore.date(), datetime.min.time()).replace(tzinfo=self._timezone), - time, timescan).time())) - self.logger.debug("Looking for {} sun-related time. Found rrule: {}".format( - timescan, str(rrule).replace('\n', ';'))) + time, timescan).time())) + rstr = str(rrule).replace('\n', ';') + self.logger.debug(f'Looking for {timescan} sun-related time. Found rrule: {rstr}') else: rrule = rrulestr(entry['rrule'], dtstart=datetime.combine(weekbefore, datetime.min.time())) - self.logger.debug("Looking for {} time. Found rrule: {}".format( - timescan, str(rrule).replace('\n', ';'))) + rstr = str(rrule).replace('\n', ';') + self.logger.debug(f'Looking for {timescan} time. Found rrule: {rstr}') dt = datetime.now() while self.alive: dt = rrule.before(dt) if timescan == 'previous' else rrule.after(dt) @@ -809,8 +801,8 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): sleep(0.01) next = self._sun(datetime.combine(dt.date(), datetime.min.time()).replace(tzinfo=self._timezone), - time, timescan) - self.logger.debug("Result parsing time (rrule) {}: {}".format(time, next)) + time, timescan) + self.logger.debug(f'Result parsing time (rrule) {time}: {next}') if entryindex is not None and timescan == 'next': self._update_suncalc(item, entry, entryindex, next.strftime("%H:%M")) else: @@ -819,32 +811,32 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): if next and next.date() == dt.date(): self._itpl[item][next.timestamp() * 1000.0] = value if next - timedelta(seconds=1) > datetime.now().replace(tzinfo=self._timezone): - self.logger.debug("Return from rrule {}: {}, value {}.".format(timescan, next, value)) + self.logger.debug(f'Return from rrule {timescan}: {next}, value {value}.') return next, value else: - self.logger.debug("Not returning {} rrule {} because it's in the past.".format(timescan, next)) + self.logger.debug(f"Not returning {timescan} rrule {next} because it's in the past.") if 'sun' in time and 'series' not in time: next = self._sun(datetime.combine(today, datetime.min.time()).replace( tzinfo=self._timezone), time, timescan) cond_future = next > datetime.now(self._timezone) if cond_future: - self.logger.debug("Result parsing time today (sun) {}: {}".format(time, next)) + self.logger.debug(f'Result parsing time today (sun) {time}: {next}') if entryindex is not None: self._update_suncalc(item, entry, entryindex, next.strftime("%H:%M")) else: self._itpl[item][next.timestamp() * 1000.0] = value - self.logger.debug("Include previous today (sun): {}, value {} for interpolation.".format(next, value)) + self.logger.debug(f'Include previous today (sun): {next}, value {value} for interpolation.') if entryindex: self._update_suncalc(item, entry, entryindex, next.strftime("%H:%M")) next = self._sun(datetime.combine(tomorrow, datetime.min.time()).replace( tzinfo=self._timezone), time, timescan) - self.logger.debug("Result parsing time tomorrow (sun) {}: {}".format(time, next)) + self.logger.debug(f'Result parsing time tomorrow (sun) {time}: {next}') elif 'series' not in time: next = datetime.combine(today, parser.parse(time.strip()).time()).replace(tzinfo=self._timezone) cond_future = next > datetime.now(self._timezone) if not cond_future: self._itpl[item][next.timestamp() * 1000.0] = value - self.logger.debug("Include {} today: {}, value {} for interpolation.".format(timescan, next, value)) + self.logger.debug(f'Include {timescan} today: {next}, value {value} for interpolation.') next = datetime.combine(tomorrow, parser.parse(time.strip()).time()).replace(tzinfo=self._timezone) if 'series' in time: # Get next Time for Series @@ -852,34 +844,34 @@ def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): if next is None: return None, None self._itpl[item][next.timestamp() * 1000.0] = value - self.logger.debug("Looking for {} series-related time. Found rrule: {} with start-time . {}".format( - timescan, entry['rrule'].replace('\n', ';'), entry['series']['timeSeriesMin'])) - - cond_today = next.date() == today.date() - cond_yesterday = next.date() - timedelta(days=1) == yesterday.date() - cond_tomorrow = next.date() == tomorrow.date() - cond_next = next > datetime.now(self._timezone) - cond_previous_today = next - timedelta(seconds=1) < datetime.now(self._timezone) - cond_previous_yesterday = next - timedelta(days=1) < datetime.now(self._timezone) + rstr = str(rrule).replace('\n', ';') + self.logger.debug(f'Looking for {timescan} series-related time. Found rrule: {rstr} with start-time {entry["series"]["timeSeriesMin"]}') + + cond_today = False if next is None else next.date() == today.date() + cond_yesterday = False if next is None else next.date() - timedelta(days=1) == yesterday.date() + cond_tomorrow = False if next is None else next.date() == tomorrow.date() + cond_next = False if next is None else next > datetime.now(self._timezone) + cond_previous_today = False if next is None else next - timedelta(seconds=1) < datetime.now(self._timezone) + cond_previous_yesterday = False if next is None else next - timedelta(days=1) < datetime.now(self._timezone) if next and cond_today and cond_next: self._itpl[item][next.timestamp() * 1000.0] = value - self.logger.debug("Return next today: {}, value {}".format(next, value)) + self.logger.debug(f'Return next today: {next}, value {value}') return next, value if next and cond_tomorrow and cond_next: self._itpl[item][next.timestamp() * 1000.0] = value - self.logger.debug("Return next tomorrow: {}, value {}".format(next, value)) + self.logger.debug(f'Return next tomorrow: {next}, value {value}') return next, value if 'series' in time and next and cond_next: - self.logger.debug("Return next for series: {}, value {}".format(next, value)) + self.logger.debug(f'Return next for series: {next}, value {value}') return next, value if next and cond_today and cond_previous_today: self._itpl[item][(next - timedelta(seconds=1)).timestamp() * 1000.0] = value - self.logger.debug("Not returning previous today {} because it's in the past.".format(next)) + self.logger.debug(f'Not returning previous today {next} because it‘s in the past.') if next and cond_yesterday and cond_previous_yesterday: self._itpl[item][(next - timedelta(days=1)).timestamp() * 1000.0] = value - self.logger.debug("Not returning previous yesterday {} because it's in the past.".format(next)) + self.logger.debug(f'Not returning previous yesterday {next} because it‘s in the past.') except Exception as e: - self.logger.error("Error '{}' parsing time: {}".format(time, e)) + self.logger.error(f'Error "{time}" parsing time: {e}') return None, None def _series_calculate(self, item, caller=None, source=None): @@ -891,9 +883,10 @@ def _series_calculate(self, item, caller=None, source=None): :param source: source of the method caller :return: True if everything went smoothly, otherwise False """ - self.logger.debug("Series Calculate method for item {} called by {}. Source: {}".format(item, caller, source)) + self.logger.debug(f'Series Calculate method for item {item} called by {caller}. Source: {source}') if not self._items[item].get('list'): - return + issue = f'No list entry in UZSU dict for item {item}' + return issue try: mydays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] for i, mydict in enumerate(self._items[item]['list']): @@ -906,57 +899,58 @@ def _series_calculate(self, item, caller=None, source=None): try: ##################### seriesbegin, seriesend, daycount, mydict = self._fix_empty_values(mydict) - intervall = mydict['series'].get('timeSeriesIntervall', None) + interval = mydict['series'].get('timeSeriesIntervall', None) seriesstart = seriesbegin + endtime = None - if intervall is None or intervall == "": - self.logger.warning("Could not calculate serie for item {}" - " - because intervall is None - {}".format(item, mydict)) - return + if interval is None or interval == "": + issue = f'Could not calculate serie for item {item} - because interval is None - {mydict}' + self.logger.warning(issue) + return issue if (daycount == '' or daycount is None) and seriesend is None: - self.logger.warning("Could not calculate series" - " because timeSeriesCount is NONE and TimeSeriesMax is NONE") - return + issue = "Could not calculate series because timeSeriesCount is NONE and TimeSeriesMax is NONE" + self.logger.warning(issue) + return issue - intervall = int(intervall.split(":")[0]) * 60 + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) + interval = int(interval.split(":")[0]) * 60 + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) - if intervall == 0: - self.logger.warning("Could not calculate serie because intervall is ZERO - {}".format(mydict)) - return + if interval == 0: + issue = f'Could not calculate serie because interval is ZERO - {mydict}' + self.logger.warning(issue) + return issue if daycount is not None and daycount != '': - if int(daycount) * intervall >= 1440: + if int(daycount) * interval >= 1440: org_daycount = daycount - daycount = int(1439 / intervall) - self.logger.warning("Cut your SerieCount to {} -" - " because intervall {} x SerieCount {}" - " is more than 24h".format(daycount, intervall, org_daycount)) + daycount = int(1439 / interval) + self.logger.warning(f'Cut your SerieCount to {daycount} - because interval {interval} x SerieCount {org_daycount} is more than 24h') if 'sun' not in mydict['series']['timeSeriesMin']: starttime = datetime.strptime(mydict['series']['timeSeriesMin'], "%H:%M") else: - mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), - seriesstart, "next") - starttime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesstart, "next") + starttime = (f'{mytime.hour:02d}:{mytime.minute:02d}') starttime = datetime.strptime(starttime, "%H:%M") # calculate End of Serie by Count if seriesend is None: endtime = starttime - endtime += timedelta(minutes=intervall * int(daycount)) + endtime += timedelta(minutes=interval * int(daycount)) if seriesend is not None and 'sun' in seriesend: - mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), - seriesend, "next") - endtime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesend, "next") + endtime = (f'{mytime.hour:02d}:{mytime.minute:02d}') endtime = datetime.strptime(endtime, "%H:%M") elif seriesend is not None and 'sun' not in seriesend: endtime = datetime.strptime(seriesend, "%H:%M") - if seriesend is None: + if seriesend is None and endtime: seriesend = str(endtime.time())[:5] + if not endtime: + endtime = starttime + if endtime <= starttime: endtime += timedelta(days=1) @@ -964,30 +958,28 @@ def _series_calculate(self, item, caller=None, source=None): original_daycount = daycount if daycount is None: - daycount = int(timediff.total_seconds() / 60 / intervall) + daycount = int(timediff.total_seconds() / 60 / interval) else: - new_daycount = int(timediff.total_seconds() / 60 / intervall) + new_daycount = int(timediff.total_seconds() / 60 / interval) if int(daycount) > new_daycount: - self.logger.warning("Cut your SerieCount to {} - because intervall {}" - " x SerieCount {} is not possible between {} and {}".format( - new_daycount, intervall, daycount, starttime, endtime)) + self.logger.warning(f'Cut your SerieCount to {new_daycount} - because interval {interval} x SerieCount {daycount} is not possible between {starttime} and {endtime}') daycount = new_daycount ##################### # advanced rule including all sun times, start and end times and calculated max counts, etc. rrule = rrulestr(mydict['rrule'] + ";COUNT=7", dtstart=datetime.combine(datetime.now(), - parser.parse(str(starttime.hour) + ':' + - str(starttime.minute)).time())) + parser.parse(str(starttime.hour) + ':' + + str(starttime.minute)).time())) mynewlist = [] - intervall = int(mydict['series']['timeSeriesIntervall'].split(":")[0])*60 + \ - int(mydict['series']['timeSeriesIntervall'].split(":")[1]) + interval = int(mydict['series']['timeSeriesIntervall'].split(":")[0]) * 60 + \ + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) exceptions = 0 for day in list(rrule): if not mydays[day.weekday()] in mydict['rrule']: continue - myrulenext = "FREQ=MINUTELY;COUNT={};INTERVAL={}".format(daycount, intervall) + myrulenext = f'FREQ=MINUTELY;COUNT={daycount};INTERVAL={interval}' if 'sun' not in mydict['series']['timeSeriesMin']: starttime = datetime.strptime(mydict['series']['timeSeriesMin'], "%H:%M") @@ -995,7 +987,7 @@ def _series_calculate(self, item, caller=None, source=None): seriesstart = mydict['series']['timeSeriesMin'] mytime = self._sun(day.replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesstart, "next") - starttime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + starttime = (f'{mytime.hour:02d}:{mytime.minute:02d}') starttime = datetime.strptime(starttime, "%H:%M") dayrule = rrulestr(myrulenext, dtstart=day.replace(hour=starttime.hour, minute=starttime.minute, second=0)) @@ -1007,17 +999,10 @@ def _series_calculate(self, item, caller=None, source=None): except Exception: max_interval = endtime - starttime if exceptions == 0: - self.logger.info("Item {}: Between starttime {} and endtime {}" - " is a maximum valid interval of {:02d}:{:02d}. " - "{} is set too high for a continuous series trigger. " - "The UZSU will only be scheduled for the start time.".format( - item, datetime.strftime(starttime, "%H:%M"), - datetime.strftime(endtime, "%H:%M"), - max_interval.seconds // 3600, max_interval.seconds % 3600//60, - mydict['series']['timeSeriesIntervall'])) + self.logger.info(f'Item {item}: Between starttime {datetime.strftime(starttime, "%H:%M")} and endtime {datetime.strftime(endtime, "%H:%M")} is a maximum valid interval of {max_interval.seconds // 3600:02d}:{max_interval.seconds % 3600//60:02d}. {mydict["series"]["timeSeriesIntervall"]} is set too high for a continuous series trigger. The UZSU will only be scheduled for the start time.') exceptions += 1 max_interval = int(max_interval.total_seconds() / 60) - myrulenext = "FREQ=MINUTELY;COUNT=1;INTERVAL={}".format(max_interval) + myrulenext = f'FREQ=MINUTELY;COUNT=1;INTERVAL={max_interval}' dayrule = rrulestr(myrulenext, dtstart=day.replace(hour=starttime.hour, minute=starttime.minute, second=0)) dayrule.after(day.replace(hour=0, minute=0)) @@ -1028,21 +1013,19 @@ def _series_calculate(self, item, caller=None, source=None): if seriestarttime is not None: mytpl = {'seriesMin': str(seriestarttime.time())[:5]} if original_daycount is not None: - mytpl['seriesMax'] = str((seriestarttime + - timedelta(minutes=intervall * count)).time())[:5] + mytpl['seriesMax'] = str((seriestarttime + timedelta(minutes=interval * count)).time())[:5] else: - mytpl['seriesMax'] = "{:02d}".format(endtime.hour) + ":" + \ - "{:02d}".format(endtime.minute) + mytpl['seriesMax'] = f'{endtime.hour:02d}:{endtime.minute:02d}' mytpl['seriesDay'] = actday mytpl['maxCountCalculated'] = count if exceptions == 0 else 0 - self.logger.debug("Mytpl: {}, count {}, daycount {}, interval {}".format(mytpl, count, daycount, intervall)) + self.logger.debug(f'Mytpl: {mytpl}, count {count}, daycount {daycount}, interval {interval}') mynewlist.append(mytpl) count = 0 seriestarttime = None actday = mydays[time.weekday()] if time.time() < datetime.now().time() and time.date() <= datetime.now().date(): continue - if time >= datetime.now()+timedelta(days=7): + if time >= datetime.now() + timedelta(days=7): continue if seriestarttime is None: seriestarttime = time @@ -1051,29 +1034,24 @@ def _series_calculate(self, item, caller=None, source=None): if seriestarttime is not None: mytpl = {'seriesMin': str(seriestarttime.time())[:5]} if original_daycount is not None: - mytpl['seriesMax'] = str((seriestarttime + timedelta(minutes=intervall * count)).time())[:5] + mytpl['seriesMax'] = str((seriestarttime + timedelta(minutes=interval * count)).time())[:5] else: - mytpl['seriesMax'] = "{:02d}".format(endtime.hour) + ":" + "{:02d}".format(endtime.minute) + mytpl['seriesMax'] = f'{endtime.hour:02d}:{endtime.minute:02d}' mytpl['maxCountCalculated'] = count if exceptions == 0 else 0 mytpl['seriesDay'] = actday - self.logger.debug("Mytpl for last time of day: {}," - " count {} daycount {}," - " interval {}".format(mytpl, count, original_daycount, intervall)) + self.logger.debug(f'Mytpl for last time of day: {mytpl}, count {count} daycount {original_daycount}, interval {interval}') mynewlist.append(mytpl) if mynewlist: self._items[item]['list'][i]['seriesCalculated'] = mynewlist - self.logger.debug("Series for item {} calculated: {}".format( - item, self._items[item]['list'][i]['seriesCalculated'])) + self.logger.debug(f'Series for item {item} calculated: {self._items[item]["list"][i]["seriesCalculated"]}') except Exception as e: - self.logger.warning("Error: {}. Series entry {} for item {} could not be calculated." - " Skipping series calculation".format(e, mydict, item)) + self.logger.warning(f'Error: {e}. Series entry {mydict} for item {item} could not be calculated. Skipping series calculation') continue return True except Exception as e: - self.logger.warning("Series for item {} could not be calculated for list {}. Error: {}".format( - item, self._items[item]['list'], e)) + self.logger.warning(f'Series for item {item} could not be calculated for list {self._items[item]["list"]}. Error: {e}') def _get_sun4week(self, item, caller=None): """ @@ -1086,14 +1064,14 @@ def _get_sun4week(self, item, caller=None): """ dayrule = rrulestr("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU" + ";COUNT=7", dtstart=datetime.now().replace(hour=0, minute=0, second=0)) - self.logger.debug("Get sun4week for item {} called by {}".format(item, caller)) + self.logger.debug(f'Get sun4week for item {item} called by {caller}') mynewdict = {'sunrise': {}, 'sunset': {}} for day in (list(dayrule)): actday = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'][day.weekday()] mysunrise = self._sun(day.astimezone(self._timezone), "sunrise", "next") mysunset = self._sun(day.astimezone(self._timezone), "sunset", "next") - mynewdict['sunrise'][actday] = ("{:02d}".format(mysunrise.hour) + ":" + "{:02d}".format(mysunrise.minute)) - mynewdict['sunset'][actday] = ("{:02d}".format(mysunset.hour) + ":" + "{:02d}".format(mysunset.minute)) + mynewdict['sunrise'][actday] = (f'{mysunrise.hour:02d}:{mysunrise.minute:02d}') + mynewdict['sunset'][actday] = (f'{mysunset.hour:02d}:{mysunset.minute:02d}') self._items[item]['SunCalculated'] = mynewdict return True @@ -1125,15 +1103,15 @@ def _series_get_time(self, mydict, timescan=''): returnvalue = None seriesbegin, seriesend, daycount, mydict = self._fix_empty_values(mydict) - intervall = mydict['series'].get('timeSeriesIntervall', None) + interval = mydict['series'].get('timeSeriesIntervall', None) seriesstart = seriesbegin - if intervall is not None and intervall != "": - intervall = int(intervall.split(":")[0])*60 + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) + if interval is not None and interval != "": + interval = int(interval.split(":")[0]) * 60 + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) else: return returnvalue - if intervall == 0: - self.logger.warning("Could not calculate serie because intervall is ZERO - {}".format(mydict)) + if interval == 0: + self.logger.warning(f'Could not calculate serie because interval is ZERO - {mydict}') return returnvalue if 'sun' not in mydict['series']['timeSeriesMin']: @@ -1141,57 +1119,54 @@ def _series_get_time(self, mydict, timescan=''): else: mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesstart, "next") - starttime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + starttime = f'{mytime.hour:02d}:{mytime.minute:02d}' starttime = datetime.strptime(starttime, "%H:%M") if daycount is None and seriesend is not None: if 'sun' in seriesend: mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), seriesend, "next") - seriesend = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + seriesend = (f'{mytime.hour:02d}:{mytime.minute:02d}') endtime = datetime.strptime(seriesend, "%H:%M") else: endtime = datetime.strptime(seriesend, "%H:%M") if endtime < starttime: endtime += timedelta(days=1) timediff = endtime - starttime - daycount = int(timediff.total_seconds() / 60 / intervall) + daycount = int(timediff.total_seconds() / 60 / interval) else: if seriesend is None: endtime = starttime - endtime += timedelta(minutes=intervall * int(daycount)) + endtime += timedelta(minutes=interval * int(daycount)) timediff = endtime - starttime - daycount = int(timediff.total_seconds() / 60 / intervall) + daycount = int(timediff.total_seconds() / 60 / interval) else: endtime = datetime.strptime(seriesend, "%H:%M") timediff = endtime - starttime if daycount is not None and daycount != '': - if seriesend is None and (int(daycount) * intervall >= 1440): + if seriesend is None and (int(daycount) * interval >= 1440): org_count = daycount - count = int(1439 / intervall) - self.logger.warning("Cut your SerieCount to {} - because intervall {}" - " x SerieCount {} is more than 24h".format(count, intervall, org_count)) + count = int(1439 / interval) + self.logger.warning(f'Cut your SerieCount to {count} - because interval {interval} x SerieCount {org_count} is more than 24h') else: - new_daycount = int(timediff.total_seconds() / 60 / intervall) - #self.logger.debug("new daycount: {}, seriesend {}".format(new_daycount, seriesend)) + new_daycount = int(timediff.total_seconds() / 60 / interval) if int(daycount) > new_daycount: - self.logger.warning("Cut your SerieCount to {} - because intervall {}" - " x SerieCount {} is not possible between {} and {}".format( - new_daycount, intervall, daycount, datetime.strftime(starttime, "%H:%M"), - datetime.strftime(endtime, "%H:%M"))) + self.logger.warning(f'Cut your SerieCount to {new_daycount} - because interval {interval} x SerieCount {daycount} is not possible between {datetime.strftime(starttime, "%H:%M")} and {datetime.strftime(endtime, "%H:%M")}') daycount = new_daycount mylist = OrderedDict() actrrule = mydict['rrule'] + ';COUNT=9' - rrule = rrulestr(actrrule, dtstart=datetime.combine(datetime.now()-timedelta(days=7), - parser.parse(str(starttime.hour) + ':' + - str(starttime.minute)).time())) + rrule = rrulestr( + actrrule, + dtstart=datetime.combine(datetime.now() - timedelta(days=7), + parser.parse(str(starttime.hour) + ':' + str(starttime.minute)).time()) + ) for day in list(rrule): mycount = 1 timestamp = day mylist[timestamp] = 'x' while mycount < daycount: - timestamp = timestamp + timedelta(minutes=intervall) + timestamp = timestamp + timedelta(minutes=interval) mylist[timestamp] = 'x' mycount += 1 @@ -1200,9 +1175,9 @@ def _series_get_time(self, mydict, timescan=''): mysortedlist = sorted(mylist) myindex = mysortedlist.index(now) if timescan == 'next': - returnvalue = mysortedlist[myindex+1] + returnvalue = mysortedlist[myindex + 1] else: - returnvalue = mysortedlist[myindex-1] + returnvalue = mysortedlist[myindex - 1] # Get correct "sun" for this Day if 'sun' in mydict['series']['timeSeriesMin'] and returnvalue is not None: @@ -1227,10 +1202,10 @@ def _sun(self, dt, tstr, timescan): :return: the calculated date and time in timezone aware format """ - self.logger.debug("Given param dt={}, tz={} for {}".format(dt, dt.tzinfo, timescan)) + self.logger.debug(f'Given param dt={dt}, tz={dt.tzinfo} for {timescan}') # now start into parsing details - self.logger.debug('Examine param time string: {}'.format(tstr)) + self.logger.debug(f'Examine param time string: {tstr}') # find min/max times tabs = tstr.split('<') @@ -1252,20 +1227,19 @@ def _sun(self, dt, tstr, timescan): cron = tabs[1].strip() smax = tabs[2].strip() else: - self.logger.error('Wrong syntax: {} - wrong amount of tabs. Should be' - ' [H:M<](sunrise|sunset)[+|-][offset][ dmax: - self.logger.error("Wrong times: the earliest time should be smaller than the " - "latest time in {}".format(tstr)) + self.logger.error(f'Wrong times: the earliest time should be smaller than the latest time in {tstr}') return try: - next_time = dmin if dmin > next_time else next_time + next_time = max(dmin, next_time) except Exception: pass try: - next_time = dmax if dmax < next_time else next_time + next_time = min(dmax, next_time) except Exception: pass return next_time @@ -1341,16 +1311,14 @@ def _get_dependant(self, item): try: _uzsuitem = self.itemsApi.return_item(self.get_iattr_value(item.conf, ITEM_TAG[0])) except Exception as err: - self.logger.warning("Item to be queried '{}' does not exist. Error: {}".format( - self.get_iattr_value(item.conf, ITEM_TAG[0]), err)) - return None + self.logger.warning(f'Item to be queried "{self.get_iattr_value(item.conf, ITEM_TAG[0])}" does not exist. Error: {err}') + return try: _itemvalue = _uzsuitem() except Exception as err: _itemvalue = None - self.logger.warning("Item to be queried '{}' does not have a type attribute. Error: {}".format( - self.get_iattr_value(item.conf, ITEM_TAG[0]), err)) + self.logger.warning(f'Item to be queried "{self.get_iattr_value(item.conf, ITEM_TAG[0])}" does not have a type attribute. Error: {err}') return _uzsuitem, _itemvalue def get_itemdict(self, item): @@ -1368,7 +1336,7 @@ def get_items(self): :return: sorted itemlist """ - sortedlist = sorted([k.id() for k in self._items.keys()]) + sortedlist = sorted([k.property.path for k in self._items.keys()]) finallist = [] for i in sortedlist: finallist.append(self.itemsApi.return_item(i)) diff --git a/uzsu/_pv_1_6_6/__init__.py b/uzsu/_pv_1_6_6/__init__.py new file mode 100755 index 000000000..f752f29d8 --- /dev/null +++ b/uzsu/_pv_1_6_6/__init__.py @@ -0,0 +1,1381 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +###################################################################################### +# Copyright 2011-2013 Niko Will +# Copyright 2017,2022 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2018 Andreas Künz onkelandy66@gmail.com +# Copyright 2021 extension for series Andre Kohler andre.kohler01@googlemail.com +###################################################################################### +# This file is part of SmartHomeNG. https://github.com/smarthomeNG// +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +########################################################################## + + +# Item Data Format +# +# Each UZSU item is of type list. Each list entry has to be a dict with specific key and value pairs. +# Here are the possible keys and their purpose: +# +# dtstart: a datetime object. Exact datetime as start value for the rrule algorithm. +# Important e.g. for FREQ=MINUTELY rrules (optional). +# +# value: the value which will be set to the item. +# +# active: True if the entry is activated, False if not. +# A deactivated entry is stored to the database but doesn't trigger the setting of the value. +# It can be enabled later with the update method. +# +# time: time as string +# A) regular time expression like 17:00 +# B) to use sunrise/sunset arithmetics like in the crontab +# examples: +# 17:008:00 +# 17:00 0: + self.logger.debug("Updated {} rrule entries for item: {}".format(count, item)) + self._update_item(item, 'UZSU Plugin', 'create_rrule') + if _inactive >= len(self._items[item]['list']): + self._planned.update({item: None}) + self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + + def update_item(self, item, caller=None, source=None, dest=None): + """ + This is called by smarthome engine when the item changes, e.g. by Visu or by the command line interface + The relevant item is put into the internal item list and registered to the scheduler + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + cond = (not caller == 'UZSU Plugin') or source == 'logic' + self.logger.debug('Update Item {}, Caller {}, Source {}, Dest {}. Will update: {}'.format( + item, caller, source, dest, cond)) + if not source == 'create_rrule': + self._check_rruleandplanned(item) + # Removing Duplicates + if self._remove_duplicates is True and self._items[item].get('list') and cond: + self._remove_dupes(item) + self._add_dicts(item) + if not self._items[item]['interpolation'].get('itemtype') or \ + self._items[item]['interpolation']['itemtype'] == 'none': + self._items[item]['interpolation']['itemtype'] = self._add_type(item) + if cond and self._items[item].get('active') is False and not source == 'update_sun': + self._lastvalues[item] = None + self._webdata['items'][item.id()].update({'lastvalue': '-'}) + self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + self.logger.debug('lastvalue for item {} set to None because UZSU is deactivated'.format(item)) + if cond: + self._schedule(item, caller='update') + elif 'sun' in source: + self.logger.info('Not running dry run of scheduler calculation for item {}' + ' because of {} source'.format(item, source)) + else: + self.logger.info('Dry run of scheduler calculation for item {}' + ' to get calculated sunset/rise entries. Source: {}'.format(item, source)) + self._schedule(item, caller='dry_run') + + if self._items[item] != self.itemsApi.return_item(str(item)) and cond: + self._update_item(item, 'UZSU Plugin', 'update') + + def _update_item(self, item, caller="", comment=""): + success = self._get_sun4week(item, caller="_update_item") + if success: + self.logger.debug('Updated weekly sun info for item {}' + ' caller: {} comment: {}'.format(item, caller, comment)) + else: + self.logger.debug('Issues with updating weekly sun info' + ' for item {} caller: {} comment: {}'.format(item, caller, comment)) + success = self._series_calculate(item, caller, comment) + if success is True: + self.logger.debug('Updated seriesCalculated for item {}' + ' caller: {} comment: {}'.format(item, caller, comment)) + else: + self.logger.debug('Issues with updating seriesCalculated' + ' for item {} caller: {} comment: {}, issue: {}'.format(item, caller, comment, success)) + success = self._update_sun(item, caller="_update_item") + if success is True: + self.logger.debug('Updated sunset/rise calculations for item {}' + ' caller: {} comment: {}'.format(item, caller, comment)) + else: + self.logger.debug('Issues with updating sunset/rise calculations' + ' for item {} caller: {} comment: {}, issue: {}'.format(item, caller, comment, success)) + item(self._items[item], caller, comment) + self._webdata['items'][item.id()].update({'interpolation': self._items[item].get('interpolation')}) + self._webdata['items'][item.id()].update({'active': str(self._items[item].get('active'))}) + #self._webdata['items'][item.id()].update({'sun': self._items[item].get('SunCalculated')}) + _suncalc = self._items[item].get('SunCalculated') + self._webdata['items'][item.id()].update({'sun': _suncalc}) + self._webdata['sunCalculated'] = _suncalc + self._webdata['items'][item.id()].update({'dict': self.get_itemdict(item)}) + if not comment == "init": + _uzsuitem, _itemvalue = self._get_dependant(item) + item_id = None if _uzsuitem is None else _uzsuitem.id() + self._webdata['items'][item.id()].update({'depend': {'item': item_id, 'value': str(_itemvalue)}}) + + def _schedule(self, item, caller=None): + """ + This function schedules an item: First the item is removed from the scheduler. + If the item is active then the list is searched for the nearest next execution time. + No matter if active or not the calculation for the execution time is triggered. + :param item: item to be updated towards the plugin. + :param caller: if given it represents the callers name. If the caller is set + to "dry_run" the evaluation of sun entries takes place but no scheduler will be set. + """ + if caller != "dry_run": + self.scheduler_remove('{}'.format(item.property.path)) + _caller = "Scheduler:UZSU" + self.logger.debug('Schedule Item {}, Trigger: {}, Changed by: {}'.format( + item, caller, item.changed_by())) + else: + self.logger.debug('Calculate Item {}, Trigger: {}, Changed by: {}'.format( + item, caller, item.changed_by())) + _caller = "dry_run" + _next = None + _value = None + self._update_sun(item, caller=_caller) + self._add_dicts(item) + if not self._items[item]['interpolation'].get('itemtype') or \ + self._items[item]['interpolation']['itemtype'] == 'none': + self._items[item]['interpolation']['itemtype'] = self._add_type(item) + if self._items[item].get('interpolation') is None: + self.logger.error("Something is wrong with your UZSU item. You most likely use a" + " wrong smartVISU widget version!" + " Use the latest device.uzsu SV 2.9. or higher " + "If you write your uzsu dict directly please use the format given in the documentation: " + "https://www.smarthomeng.de/user/plugins/uzsu/user_doc.html and " + "include the interpolation array correctly!") + return + elif not self._items[item]['interpolation'].get('itemtype'): + self.logger.error("item '{}' to be set by uzsu does not exist.".format( + self.get_iattr_value(item.conf, ITEM_TAG[0]))) + elif self._items[item].get('active') is True or _caller == "dry_run": + self._itpl[item] = OrderedDict() + for i, entry in enumerate(self._items[item]['list']): + next, value = self._get_time(entry, 'next', item, i, _caller) + previous, previousvalue = self._get_time(entry, 'previous', item, i, _caller) + cond1 = next is None and previous is not None + cond2 = previous is not None and next is not None and previous < next + if cond1 or cond2: + next = previous + value = previousvalue + if next is not None: + self.logger.debug("uzsu active entry for item {} with datetime {}, value {}" + " and tzinfo {}".format(item, next, value, next.tzinfo)) + if _next is None: + _next = next + _value = value + elif next and next < _next: + self.logger.debug("uzsu active entry for item {} using now {}, value {}" + " and tzinfo {}".format(item, next, value, next.tzinfo)) + _next = next + _value = value + else: + self.logger.debug("uzsu active entry for item {} keep {}, value {} and tzinfo {}".format( + item, _next, _value, _next.tzinfo)) + elif not self._items[item].get('list') and self._items[item].get('active') is True: + self.logger.warning("item '{}' is active but has no entries.".format(item)) + self._planned.update({item: None}) + self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + if _next and _value is not None and (self._items[item].get('active') is True or _caller == "dry_run"): + _reset_interpolation = False + _interval = self._items[item]['interpolation'].get('interval') + _interval = self._interpolation_interval if not _interval else int(_interval) + if _interval < 0: + _interval = abs(int(_interval)) + self._items[item]['interpolation']['interval'] = _interval + self._update_item(item, 'UZSU Plugin', 'intervalchange') + _interpolation = self._items[item]['interpolation'].get('type') + _interpolation = self._interpolation_type if not _interpolation else _interpolation + _initage = self._items[item]['interpolation'].get('initage') + _initage = 0 if not _initage else int(_initage) + _initialized = self._items[item]['interpolation'].get('initialized') + _initialized = False if not _initialized else _initialized + entry_now = datetime.now(self._timezone).timestamp() * 1000.0 + self._itpl[item][entry_now] = 'NOW' + itpl_list = sorted(list(self._itpl[item].items())) + entry_index = itpl_list.index((entry_now, 'NOW')) + _inittime = itpl_list[entry_index - min(1, entry_index)][0] + _initvalue = itpl_list[entry_index - min(1, entry_index)][1] + itpl_list = itpl_list[entry_index - min(2, entry_index):entry_index + min(3, len(itpl_list))] + itpl_list.remove((entry_now, 'NOW')) + if _caller != "dry_run": + self._lastvalues[item] = _initvalue + self._webdata['items'][item.id()].update({'lastvalue': _initvalue}) + _timediff = datetime.now(self._timezone) - timedelta(minutes=_initage) + if not self._items[item]['interpolation'].get('itemtype') == 'bool': + try: + _value = float(_value) + except ValueError: + pass + cond1 = _inittime - _timediff.timestamp() * 1000.0 >= 0 + cond2 = _interpolation.lower() in ['cubic', 'linear'] + cond3 = _initialized is False + cond4 = _initage > 0 + cond5 = isinstance(_value, float) + cond6 = caller != 'set' and _caller != "dry_run" + self._itpl[item] = OrderedDict(itpl_list) + if not cond2 and cond3 and cond4: + self.logger.info("Looking if there was a value set after {} for item {}".format( + _timediff, item)) + self._items[item]['interpolation']['initialized'] = True + self._update_item(item, 'UZSU Plugin', 'init') + if cond1 and not cond2 and cond3 and cond6: + self._set(item=item, value=_initvalue, caller=_caller) + self.logger.info("Updated item {} on startup with value {} from time {}".format( + item, _initvalue, datetime.fromtimestamp(_inittime/1000.0))) + _itemtype = self._items[item]['interpolation'].get('itemtype') + if cond2 and _interval < 1: + self.logger.warning("Interpolation is set to {} but interval is {}. Ignoring interpolation".format( + _interpolation, _interval)) + elif cond2 and _itemtype not in ['num']: + self.logger.warning("Interpolation is set to {} but type of item {} is {}." + " Ignoring interpolation and setting UZSU interpolation to none.".format( + item, _interpolation, _itemtype)) + _reset_interpolation = True + elif _interpolation.lower() == 'cubic' and _interval > 0: + try: + tck = interpolate.PchipInterpolator(list(self._itpl[item].keys()), list(self._itpl[item].values())) + _nextinterpolation = datetime.now(self._timezone) + timedelta(minutes=_interval) + _next = _nextinterpolation if _next > _nextinterpolation else _next + _value = round(float(tck(_next.timestamp() * 1000.0)), self._interpolation_precision) + _value_now = round(float(tck(entry_now)), self._interpolation_precision) + if _caller != "dry_run": + self._set(item=item, value=_value_now, caller=_caller) + self.logger.info("Updated: {}, cubic interpolation value: {}, based on dict: {}." + " Next: {}, value: {}".format(item, _value_now, self._itpl[item], _next, _value)) + except Exception as e: + self.logger.error("Error cubic interpolation for item {} " + "with interpolation list {}: {}".format(item, self._itpl[item], e)) + elif _interpolation.lower() == 'linear' and _interval > 0: + try: + tck = interpolate.interp1d(list(self._itpl[item].keys()), list(self._itpl[item].values())) + _nextinterpolation = datetime.now(self._timezone) + timedelta(minutes=_interval) + _next = _nextinterpolation if _next > _nextinterpolation else _next + _value = round(float(tck(_next.timestamp() * 1000.0)), self._interpolation_precision) + _value_now = round(float(tck(entry_now)), self._interpolation_precision) + if caller != 'set' and _caller != "dry_run": + self._set(item=item, value=_value_now, caller=_caller) + self.logger.info("Updated: {}, linear interpolation value: {}, based on dict: {}." + " Next: {}, value: {}".format(item, _value_now, self._itpl[item], _next, _value)) + except Exception as e: + self.logger.error("Error linear interpolation: {}".format(e)) + if cond5 and _value < 0: + self.logger.warning("value {} for item '{}' is negative. This might be due" + " to not enough values set in the UZSU.".format(_value, item)) + if _reset_interpolation is True: + self._items[item]['interpolation']['type'] = 'none' + self._update_item(item, 'UZSU Plugin', 'reset_interpolation') + if _caller != "dry_run": + self.logger.debug("will add scheduler named uzsu_{} with datetime {} and tzinfo {}" + " and value {}".format(item.property.path, _next, _next.tzinfo, _value)) + self._planned.update({item: {'value': _value, 'next': _next.strftime('%Y-%m-%d %H:%M')}}) + self._webdata['items'][item.id()].update({'planned': {'value': _value, 'time': _next.strftime('%d.%m.%Y %H:%M')}}) + self._update_count['done'] = self._update_count.get('done') + 1 + self.scheduler_add('{}'.format(item.property.path), self._set, + value={'item': item, 'value': _value, 'caller': 'Scheduler'}, next=_next) + if self._update_count.get('done') == self._update_count.get('todo'): + self.scheduler_trigger('uzsu_sunupdate', by='UZSU Plugin') + self._update_count = {'done': 0, 'todo': 0} + elif self._items[item].get('active') is True and self._items[item].get('list'): + self.logger.warning("item '{}' is active but has no active entries.".format(item)) + self._planned.update({item: None}) + self._webdata['items'][item.id()].update({'planned': {'value': '-', 'time': '-'}}) + + def _set(self, item=None, value=None, caller=None): + """ + This function sets the specific item + :param item: item to be updated towards the plugin + :param value: value the item should be set to + :param caller: if given it represents the callers name + """ + _uzsuitem, _itemvalue = self._get_dependant(item) + _uzsuitem(value, 'UZSU Plugin', 'set') + self._webdata['items'][item.id()].update({'depend': {'item': _uzsuitem.id(), 'value': str(_itemvalue)}}) + if not caller or caller == "Scheduler": + self._schedule(item, caller='set') + + def _get_time(self, entry, timescan, item=None, entryindex=None, caller=None): + """ + Returns the next and previous execution time and value + :param entry: a dictionary that may contain the following keys: + value + active + date + rrule + dtstart + :param item: item to be updated towards the plugin + :param timescan: defines whether to find values in the future or past + :param caller: defines the caller of the method. If it's name is dry_run just + simulate getting time even if entry is not active + """ + try: + time = entry['time'] + except Exception: + time = None + try: + if not isinstance(entry, dict): + return None, None + if 'value' not in entry: + return None, None + if 'active' not in entry: + return None, None + if 'time' not in entry: + return None, None + value = entry['value'] + next = None + active = True if caller == "dry_run" else entry['active'] + today = datetime.today() + tomorrow = today + timedelta(days=1) + yesterday = today - timedelta(days=1) + weekbefore = today - timedelta(days=7) + time = entry['time'] + if not active: + return None, None + if 'rrule' in entry and 'series' not in time: + if entry['rrule'] == '': + entry['rrule'] = 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU' + if 'dtstart' in entry: + rrule = rrulestr(entry['rrule'], dtstart=entry['dtstart']) + else: + try: + rrule = rrulestr(entry['rrule'], dtstart=datetime.combine( + weekbefore, parser.parse(time.strip()).time())) + self.logger.debug("Created rrule: '{}' for time:'{}'".format( + str(rrule).replace('\n', ';'), time)) + except ValueError: + self.logger.debug("Could not create a rrule from rrule: '{}' and time:'{}'".format( + entry['rrule'], time)) + if 'sun' in time: + rrule = rrulestr(entry['rrule'], dtstart=datetime.combine( + weekbefore, self._sun(datetime.combine(weekbefore.date(), + datetime.min.time()).replace(tzinfo=self._timezone), + time, timescan).time())) + self.logger.debug("Looking for {} sun-related time. Found rrule: {}".format( + timescan, str(rrule).replace('\n', ';'))) + else: + rrule = rrulestr(entry['rrule'], dtstart=datetime.combine(weekbefore, datetime.min.time())) + self.logger.debug("Looking for {} time. Found rrule: {}".format( + timescan, str(rrule).replace('\n', ';'))) + dt = datetime.now() + while self.alive: + dt = rrule.before(dt) if timescan == 'previous' else rrule.after(dt) + if dt is None: + return None, None + if 'sun' in time: + sleep(0.01) + next = self._sun(datetime.combine(dt.date(), + datetime.min.time()).replace(tzinfo=self._timezone), + time, timescan) + self.logger.debug("Result parsing time (rrule) {}: {}".format(time, next)) + if entryindex is not None and timescan == 'next': + self._update_suncalc(item, entry, entryindex, next.strftime("%H:%M")) + else: + next = datetime.combine(dt.date(), parser.parse(time.strip()).time()).replace(tzinfo=self._timezone) + self._update_suncalc(item, entry, entryindex, None) + if next and next.date() == dt.date(): + self._itpl[item][next.timestamp() * 1000.0] = value + if next - timedelta(seconds=1) > datetime.now().replace(tzinfo=self._timezone): + self.logger.debug("Return from rrule {}: {}, value {}.".format(timescan, next, value)) + return next, value + else: + self.logger.debug("Not returning {} rrule {} because it's in the past.".format(timescan, next)) + if 'sun' in time and 'series' not in time: + next = self._sun(datetime.combine(today, datetime.min.time()).replace( + tzinfo=self._timezone), time, timescan) + cond_future = next > datetime.now(self._timezone) + if cond_future: + self.logger.debug("Result parsing time today (sun) {}: {}".format(time, next)) + if entryindex is not None: + self._update_suncalc(item, entry, entryindex, next.strftime("%H:%M")) + else: + self._itpl[item][next.timestamp() * 1000.0] = value + self.logger.debug("Include previous today (sun): {}, value {} for interpolation.".format(next, value)) + if entryindex: + self._update_suncalc(item, entry, entryindex, next.strftime("%H:%M")) + next = self._sun(datetime.combine(tomorrow, datetime.min.time()).replace( + tzinfo=self._timezone), time, timescan) + self.logger.debug("Result parsing time tomorrow (sun) {}: {}".format(time, next)) + elif 'series' not in time: + next = datetime.combine(today, parser.parse(time.strip()).time()).replace(tzinfo=self._timezone) + cond_future = next > datetime.now(self._timezone) + if not cond_future: + self._itpl[item][next.timestamp() * 1000.0] = value + self.logger.debug("Include {} today: {}, value {} for interpolation.".format(timescan, next, value)) + next = datetime.combine(tomorrow, parser.parse(time.strip()).time()).replace(tzinfo=self._timezone) + if 'series' in time: + # Get next Time for Series + next = self._series_get_time(entry, timescan) + if next is None: + return None, None + self._itpl[item][next.timestamp() * 1000.0] = value + self.logger.debug("Looking for {} series-related time. Found rrule: {} with start-time . {}".format( + timescan, entry['rrule'].replace('\n', ';'), entry['series']['timeSeriesMin'])) + + cond_today = False if next is None else next.date() == today.date() + cond_yesterday = False if next is None else next.date() - timedelta(days=1) == yesterday.date() + cond_tomorrow = False if next is None else next.date() == tomorrow.date() + cond_next = False if next is None else next > datetime.now(self._timezone) + cond_previous_today = False if next is None else next - timedelta(seconds=1) < datetime.now(self._timezone) + cond_previous_yesterday = False if next is None else next - timedelta(days=1) < datetime.now(self._timezone) + if next and cond_today and cond_next: + self._itpl[item][next.timestamp() * 1000.0] = value + self.logger.debug("Return next today: {}, value {}".format(next, value)) + return next, value + if next and cond_tomorrow and cond_next: + self._itpl[item][next.timestamp() * 1000.0] = value + self.logger.debug("Return next tomorrow: {}, value {}".format(next, value)) + return next, value + if 'series' in time and next and cond_next: + self.logger.debug("Return next for series: {}, value {}".format(next, value)) + return next, value + if next and cond_today and cond_previous_today: + self._itpl[item][(next - timedelta(seconds=1)).timestamp() * 1000.0] = value + self.logger.debug("Not returning previous today {} because it's in the past.".format(next)) + if next and cond_yesterday and cond_previous_yesterday: + self._itpl[item][(next - timedelta(days=1)).timestamp() * 1000.0] = value + self.logger.debug("Not returning previous yesterday {} because it's in the past.".format(next)) + except Exception as e: + self.logger.error("Error '{}' parsing time: {}".format(time, e)) + return None, None + + def _series_calculate(self, item, caller=None, source=None): + """ + Calculate serie-entries for next 168 hour (7 days) - from now to now-1 second + and writes the list to "seriesCalculated" in item + :param item: an item with series entry + :param caller: caller of the method + :param source: source of the method caller + :return: True if everything went smoothly, otherwise False + """ + self.logger.debug("Series Calculate method for item {} called by {}. Source: {}".format(item, caller, source)) + if not self._items[item].get('list'): + issue = "No list entry in UZSU dict for item {}".format(item) + return issue + try: + mydays = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + for i, mydict in enumerate(self._items[item]['list']): + try: + del mydict['seriesCalculated'] + except Exception: + pass + if mydict.get('series', None) is None: + continue + try: + ##################### + seriesbegin, seriesend, daycount, mydict = self._fix_empty_values(mydict) + interval = mydict['series'].get('timeSeriesIntervall', None) + seriesstart = seriesbegin + endtime = None + + if interval is None or interval == "": + issue = "Could not calculate serie for item {}"\ + " - because interval is None - {}".format(item, mydict) + self.logger.warning(issue) + return issue + + if (daycount == '' or daycount is None) and seriesend is None: + issue = "Could not calculate series because "\ + "timeSeriesCount is NONE and TimeSeriesMax is NONE" + self.logger.warning(issue) + return issue + + interval = int(interval.split(":")[0]) * 60 + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) + + if interval == 0: + issue = "Could not calculate serie because interval is ZERO - {}".format(mydict) + self.logger.warning(issue) + return issue + + if daycount is not None and daycount != '': + if int(daycount) * interval >= 1440: + org_daycount = daycount + daycount = int(1439 / interval) + self.logger.warning("Cut your SerieCount to {} -" + " because interval {} x SerieCount {}" + " is more than 24h".format(daycount, interval, org_daycount)) + + if 'sun' not in mydict['series']['timeSeriesMin']: + starttime = datetime.strptime(mydict['series']['timeSeriesMin'], "%H:%M") + else: + mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), + seriesstart, "next") + starttime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + starttime = datetime.strptime(starttime, "%H:%M") + + # calculate End of Serie by Count + if seriesend is None: + endtime = starttime + endtime += timedelta(minutes=interval * int(daycount)) + + if seriesend is not None and 'sun' in seriesend: + mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), + seriesend, "next") + endtime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + endtime = datetime.strptime(endtime, "%H:%M") + elif seriesend is not None and 'sun' not in seriesend: + endtime = datetime.strptime(seriesend, "%H:%M") + + if seriesend is None and endtime: + seriesend = str(endtime.time())[:5] + + if endtime <= starttime: + endtime += timedelta(days=1) + + timediff = endtime - starttime + original_daycount = daycount + + if daycount is None: + daycount = int(timediff.total_seconds() / 60 / interval) + else: + new_daycount = int(timediff.total_seconds() / 60 / interval) + if int(daycount) > new_daycount: + self.logger.warning("Cut your SerieCount to {} - because interval {}" + " x SerieCount {} is not possible between {} and {}".format( + new_daycount, interval, daycount, starttime, endtime)) + daycount = new_daycount + + ##################### + # advanced rule including all sun times, start and end times and calculated max counts, etc. + rrule = rrulestr(mydict['rrule'] + ";COUNT=7", + dtstart=datetime.combine(datetime.now(), + parser.parse(str(starttime.hour) + ':' + + str(starttime.minute)).time())) + mynewlist = [] + + interval = int(mydict['series']['timeSeriesIntervall'].split(":")[0])*60 + \ + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) + exceptions = 0 + for day in list(rrule): + if not mydays[day.weekday()] in mydict['rrule']: + continue + myrulenext = "FREQ=MINUTELY;COUNT={};INTERVAL={}".format(daycount, interval) + + if 'sun' not in mydict['series']['timeSeriesMin']: + starttime = datetime.strptime(mydict['series']['timeSeriesMin'], "%H:%M") + else: + seriesstart = mydict['series']['timeSeriesMin'] + mytime = self._sun(day.replace(hour=0, minute=0, second=0).astimezone(self._timezone), + seriesstart, "next") + starttime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + starttime = datetime.strptime(starttime, "%H:%M") + dayrule = rrulestr(myrulenext, dtstart=day.replace(hour=starttime.hour, + minute=starttime.minute, second=0)) + dayrule.after(day.replace(hour=0, minute=0)) # First Entry for this day + count = 0 + + try: + actday = mydays[list(dayrule)[0].weekday()] + except Exception: + max_interval = endtime - starttime + if exceptions == 0: + self.logger.info("Item {}: Between starttime {} and endtime {}" + " is a maximum valid interval of {:02d}:{:02d}. " + "{} is set too high for a continuous series trigger. " + "The UZSU will only be scheduled for the start time.".format( + item, datetime.strftime(starttime, "%H:%M"), + datetime.strftime(endtime, "%H:%M"), + max_interval.seconds // 3600, max_interval.seconds % 3600//60, + mydict['series']['timeSeriesIntervall'])) + exceptions += 1 + max_interval = int(max_interval.total_seconds() / 60) + myrulenext = "FREQ=MINUTELY;COUNT=1;INTERVAL={}".format(max_interval) + dayrule = rrulestr(myrulenext, dtstart=day.replace(hour=starttime.hour, + minute=starttime.minute, second=0)) + dayrule.after(day.replace(hour=0, minute=0)) + actday = mydays[day.weekday()] if list(dayrule) is None else mydays[list(dayrule)[0].weekday()] + seriestarttime = None + for time in list(dayrule): + if mydays[time.weekday()] != actday: + if seriestarttime is not None: + mytpl = {'seriesMin': str(seriestarttime.time())[:5]} + if original_daycount is not None: + mytpl['seriesMax'] = str((seriestarttime + + timedelta(minutes=interval * count)).time())[:5] + else: + mytpl['seriesMax'] = "{:02d}".format(endtime.hour) + ":" + \ + "{:02d}".format(endtime.minute) + mytpl['seriesDay'] = actday + mytpl['maxCountCalculated'] = count if exceptions == 0 else 0 + self.logger.debug("Mytpl: {}, count {}, " + "daycount {}, interval {}".format(mytpl, count, daycount, interval)) + mynewlist.append(mytpl) + count = 0 + seriestarttime = None + actday = mydays[time.weekday()] + if time.time() < datetime.now().time() and time.date() <= datetime.now().date(): + continue + if time >= datetime.now()+timedelta(days=7): + continue + if seriestarttime is None: + seriestarttime = time + count += 1 + # add the last Time for this day + if seriestarttime is not None: + mytpl = {'seriesMin': str(seriestarttime.time())[:5]} + if original_daycount is not None: + mytpl['seriesMax'] = str((seriestarttime + timedelta(minutes=interval * count)).time())[:5] + else: + mytpl['seriesMax'] = "{:02d}".format(endtime.hour) + ":" + "{:02d}".format(endtime.minute) + mytpl['maxCountCalculated'] = count if exceptions == 0 else 0 + mytpl['seriesDay'] = actday + self.logger.debug("Mytpl for last time of day: {}," + " count {} daycount {}," + " interval {}".format(mytpl, count, original_daycount, interval)) + mynewlist.append(mytpl) + + if mynewlist: + self._items[item]['list'][i]['seriesCalculated'] = mynewlist + self.logger.debug("Series for item {} calculated: {}".format( + item, self._items[item]['list'][i]['seriesCalculated'])) + except Exception as e: + self.logger.warning("Error: {}. Series entry {} for item {} could not be calculated." + " Skipping series calculation".format(e, mydict, item)) + continue + return True + + except Exception as e: + self.logger.warning("Series for item {} could not be calculated for list {}. Error: {}".format( + item, self._items[item]['list'], e)) + + def _get_sun4week(self, item, caller=None): + """ + Getting the values for sunrise and sunset for the whole upcoming 7 days - relevant for time series + :param item: uzsu item + :type item: item + :param caller: Method calling this method + :type caller: string + :return: True at the end of the method + """ + dayrule = rrulestr("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU" + ";COUNT=7", + dtstart=datetime.now().replace(hour=0, minute=0, second=0)) + self.logger.debug("Get sun4week for item {} called by {}".format(item, caller)) + mynewdict = {'sunrise': {}, 'sunset': {}} + for day in (list(dayrule)): + actday = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'][day.weekday()] + mysunrise = self._sun(day.astimezone(self._timezone), "sunrise", "next") + mysunset = self._sun(day.astimezone(self._timezone), "sunset", "next") + mynewdict['sunrise'][actday] = ("{:02d}".format(mysunrise.hour) + ":" + "{:02d}".format(mysunrise.minute)) + mynewdict['sunset'][actday] = ("{:02d}".format(mysunset.hour) + ":" + "{:02d}".format(mysunset.minute)) + self._items[item]['SunCalculated'] = mynewdict + return True + + def _fix_empty_values(self, mydict): + daycount = mydict['series'].get('timeSeriesCount', None) + seriesend = mydict['series'].get('timeSeriesMax', None) + seriesbegin = mydict['series'].get('timeSeriesMin', None) + # Fix empty values in series dict + if seriesbegin == '': + self.logger.warning("No starttime for series set. Setting it to midnight 00:00") + seriesbegin = '00:00' + mydict['series']['timeSeriesMin'] = seriesbegin + if seriesend == '': + self.logger.warning("No endtime for series set. Setting it to midnight 23:59") + seriesend = '23:59' + mydict['series']['timeSeriesMax'] = seriesend + if daycount == '': + self.logger.warning("No count for series set. Setting it to 1") + daycount = '1' + mydict['series']['timeSeriesCount'] = daycount + return seriesbegin, seriesend, daycount, mydict + + def _series_get_time(self, mydict, timescan=''): + """ + Returns the next time/date for a serie + :param mydict: list-Item from UZSU-dict + :param timescan: direction to search for next/previous + """ + + returnvalue = None + seriesbegin, seriesend, daycount, mydict = self._fix_empty_values(mydict) + interval = mydict['series'].get('timeSeriesIntervall', None) + seriesstart = seriesbegin + + if interval is not None and interval != "": + interval = int(interval.split(":")[0])*60 + int(mydict['series']['timeSeriesIntervall'].split(":")[1]) + else: + return returnvalue + if interval == 0: + self.logger.warning("Could not calculate serie because interval is ZERO - {}".format(mydict)) + return returnvalue + + if 'sun' not in mydict['series']['timeSeriesMin']: + starttime = datetime.strptime(mydict['series']['timeSeriesMin'], "%H:%M") + else: + mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), + seriesstart, "next") + starttime = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + starttime = datetime.strptime(starttime, "%H:%M") + + if daycount is None and seriesend is not None: + if 'sun' in seriesend: + mytime = self._sun(datetime.now().replace(hour=0, minute=0, second=0).astimezone(self._timezone), + seriesend, "next") + seriesend = ("{:02d}".format(mytime.hour) + ":" + "{:02d}".format(mytime.minute)) + endtime = datetime.strptime(seriesend, "%H:%M") + else: + endtime = datetime.strptime(seriesend, "%H:%M") + if endtime < starttime: + endtime += timedelta(days=1) + timediff = endtime - starttime + daycount = int(timediff.total_seconds() / 60 / interval) + else: + if seriesend is None: + endtime = starttime + endtime += timedelta(minutes=interval * int(daycount)) + timediff = endtime - starttime + daycount = int(timediff.total_seconds() / 60 / interval) + else: + endtime = datetime.strptime(seriesend, "%H:%M") + timediff = endtime - starttime + + if daycount is not None and daycount != '': + if seriesend is None and (int(daycount) * interval >= 1440): + org_count = daycount + count = int(1439 / interval) + self.logger.warning("Cut your SerieCount to {} - because interval {}" + " x SerieCount {} is more than 24h".format(count, interval, org_count)) + else: + new_daycount = int(timediff.total_seconds() / 60 / interval) + if int(daycount) > new_daycount: + self.logger.warning("Cut your SerieCount to {} - because interval {}" + " x SerieCount {} is not possible between {} and {}".format( + new_daycount, interval, daycount, datetime.strftime(starttime, "%H:%M"), + datetime.strftime(endtime, "%H:%M"))) + daycount = new_daycount + mylist = OrderedDict() + actrrule = mydict['rrule'] + ';COUNT=9' + rrule = rrulestr(actrrule, dtstart=datetime.combine(datetime.now()-timedelta(days=7), + parser.parse(str(starttime.hour) + ':' + + str(starttime.minute)).time())) + for day in list(rrule): + mycount = 1 + timestamp = day + mylist[timestamp] = 'x' + while mycount < daycount: + timestamp = timestamp + timedelta(minutes=interval) + mylist[timestamp] = 'x' + mycount += 1 + + now = datetime.now() + mylist[now] = 'now' + mysortedlist = sorted(mylist) + myindex = mysortedlist.index(now) + if timescan == 'next': + returnvalue = mysortedlist[myindex+1] + else: + returnvalue = mysortedlist[myindex-1] + + # Get correct "sun" for this Day + if 'sun' in mydict['series']['timeSeriesMin'] and returnvalue is not None: + mytime = self._sun(returnvalue.replace(hour=0, minute=0, second=0).astimezone(self._timezone), + mydict['series']['timeSeriesMin'], "next") + delta_dt1 = returnvalue.replace(hour=mytime.hour, minute=mytime.minute, second=0) + delta_dt2 = returnvalue.replace(hour=starttime.hour, minute=starttime.minute, second=0) + delta_time = delta_dt1.minute - delta_dt2.minute + returnvalue += timedelta(minutes=delta_time) + if returnvalue is not None: + returnvalue = returnvalue.replace(tzinfo=self._timezone) + + return returnvalue + + def _sun(self, dt, tstr, timescan): + """ + parses a given string with a time range to determine its timely boundaries and + returns a time + :param dt: contains a datetime object, + :param tstr: contains a string with '[H:M<](sunrise|sunset)[+|-][offset][ dmax: + self.logger.error("Wrong times: the earliest time should be smaller than the " + "latest time in {}".format(tstr)) + return + try: + next_time = dmin if dmin > next_time else next_time + except Exception: + pass + try: + next_time = dmax if dmax < next_time else next_time + except Exception: + pass + return next_time + + def _get_dependant(self, item): + """ + Getting the value of the dependent item for the webif + :param item: uzsu item + :type item: item + :return: The item value of the item that is changed + """ + try: + _uzsuitem = self.itemsApi.return_item(self.get_iattr_value(item.conf, ITEM_TAG[0])) + except Exception as err: + self.logger.warning("Item to be queried '{}' does not exist. Error: {}".format( + self.get_iattr_value(item.conf, ITEM_TAG[0]), err)) + return None + + try: + _itemvalue = _uzsuitem() + except Exception as err: + _itemvalue = None + self.logger.warning("Item to be queried '{}' does not have a type attribute. Error: {}".format( + self.get_iattr_value(item.conf, ITEM_TAG[0]), err)) + return _uzsuitem, _itemvalue + + def get_itemdict(self, item): + """ + Getting a sorted item list with uzsu config + :item: uzsu item + :return: sanitized dict from uzsu item + """ + + return html.escape(json.dumps(self._items[item])) + + def get_items(self): + """ + Getting a sorted item list with uzsu config + + :return: sorted itemlist + """ + sortedlist = sorted([k.id() for k in self._items.keys()]) + finallist = [] + for i in sortedlist: + finallist.append(self.itemsApi.return_item(i)) + return finallist diff --git a/uzsu/_pv_1_6_6/assets/uzsu_webif.png b/uzsu/_pv_1_6_6/assets/uzsu_webif.png new file mode 100755 index 000000000..1cbe600e5 Binary files /dev/null and b/uzsu/_pv_1_6_6/assets/uzsu_webif.png differ diff --git a/uzsu/_pv_1_6_6/locale.yaml b/uzsu/_pv_1_6_6/locale.yaml new file mode 100755 index 000000000..610688ea1 --- /dev/null +++ b/uzsu/_pv_1_6_6/locale.yaml @@ -0,0 +1,19 @@ +plugin_translations: + # Translations for the plugin specially for the web interface + 'Die folgenden Items sind dem UZSU Plugin zugewiesen': {'de': '=', 'en': 'The following items are assigned to the UZSU plugin'} + 'Klick auf ein Item um dessen Konfiguration anzuzeigen': {'de': '=', 'en': 'Click on an item to show its configuration'} + 'Aktives Item, keine (aktiven) Einträge!': {'de': '=', 'en': 'Active item, no (active) entries!'} + 'Item existiert nicht!': {'de': '=', 'en': 'Item does not exist!'} + 'fehlt!': {'de': '=', 'en': 'missing!'} + 'UZSU Item': {'de': '=', 'en': '='} + 'Abhängige Items (mit Typ)': {'de': '=', 'en': 'Dependant Item (with type)'} + 'Wert': {'de': '=', 'en': 'Value'} + 'Interpolation (Intervall)': {'de': '=', 'en': 'Interpolation (interval)'} + 'Back in Time': {'de': '=', 'en': '='} + 'Dictionary': {'de': '=', 'en': '='} + 'Letzter Wert': {'de': '=', 'en': 'Last Value'} + 'Sonnenaufgang': {'de': '=', 'en': 'sun rise'} + 'Sonnenuntergang': {'de': '=', 'en': 'sun set'} + 'Uhr': {'de': '=', 'en': "o'clock"} + 'Initialisiere...': {'de': '=', 'en': 'Initializing...'} + 'Init': {'de': '=', 'en': '='} diff --git a/uzsu/_pv_1_6_6/plugin.yaml b/uzsu/_pv_1_6_6/plugin.yaml new file mode 100755 index 000000000..5a0096583 --- /dev/null +++ b/uzsu/_pv_1_6_6/plugin.yaml @@ -0,0 +1,272 @@ +# Metadata for the Smart-Plugin +plugin: + # Global plugin attributes + type: system # plugin type (gateway, interface, protocol, system, web) + description: # Alternative: description in multiple languages + de: 'Universelle Zeitschaltuhr' + en: 'Universal time switch' + description_long: # Alternative: description in multiple languages + de: 'Dieses Plugin ermöglicht gezielte Schaltvorgänge von Items zu bestimmten Uhrzeiten oder + abhängig vom Sonnenstand. Die automatischen Schaltungen können dabei pro Wochentag separat + definiert werden.\n + Außerdem ermöglicht eine Interpolationsfunktion das Errechnen von + Werten zwischen zwei manuell angelegten Schaltzeiten, wodurch z.B. Lichtkurven über den + Tagesverlauf umgesetzt werden können. + ' + en: 'This plugin provides specific item changes at a given time or sun position. Those automatic + switchings can be defined for each day of the week separately.\n + Furthermore the interpolation function allows the calculation of values between two manual + settings. You can use this feature for smooth light curves based on the time of the day. + ' + requirements: + de: 'SciPy python Modul' + en: 'SciPy python module' + requirements_long: + de: 'Das Plugin benötigt die folgende Software:\n + \n + - libatlas-base-dev: Zumindest auf einem Raspberry Pi mit Debian Stretch ist der Befehl nötig: ``sudo apt install libatlas-base-dev``\n + - Bei neueren SciPy Versionen kann auf einem Raspi der Build trotzdem scheitern. Es ist dann empfohlen, die aktuellste passende Datei (armv6 = Raspi1, armv7 = Rest) + von hier herunterzuladen und ``pip3 install scipy*.whl`` zu starten: https://www.piwheels.org/simple/scipy/\n + - Python Modul scipy: ``pip3 install scipy``. Es wird empfohlen, zuerst die Pythonmodule zu aktualisieren, + aber unbedingt darauf zu achten, dass die Requirements von SmarthomeNG erfüllt bleiben! + Sollte die Installation via pip nicht funktionieren: ``sudo apt update && sudo apt install -y python3-scipy``\n + ' + en: 'This plugin needs the following software to be installed and running:\n + \n + - libatlas-base-dev: On Raspberry Pi debian stretch you also have to run ``sudo apt install libatlas-base-dev``\n + - With newer SciPy versions build can still fail on Raspis. It is recommended to download the most recent file (armv6 = Raspi1, armv7 = others) from here and run + ``pip3 install scipy*.whl``: https://www.piwheels.org/simple/scipy/\n + - Python module scipy: ``pip3 install scipy``. Update your Python packages first + (but make sure they still meet the requirements for smarthomeng)! + If that does not work you can use: ``sudo apt update && sudo apt install -y python3-scipy``\n + ' + maintainer: cmalo, bmxp, onkelandy, andrek + tester: Sandman60, cmalo, schuma + state: ready + keywords: scheduler uzsu trigger series + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1364692-supportthread-für-uzsu-plugin + + version: 1.6.6 # Plugin version + sh_minversion: 1.6 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + multi_instance: False # plugin supports multi instance + restartable: unknown + classname: UZSU # class containing the plugin + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml + remove_duplicates: + type: bool + default: True + description: + de: 'Falls True, werden Einträge mit exakt den selben Einstellungen, aber unterschiedlichem Wert durch einen neu getätigten Eintrag ersetzt' + en: 'If True, existing entries with exactly the same settings except the value get replaced by the new entry' + + suncalculation_cron: + type: str + default: '0 0 * *' + description: + de: 'Cron-Angabe, wann für die UZSU Einträge mit Sonnenstandbezug die errechnete Uhrzeit ins dict Item geschrieben werden soll.' + en: 'Cron definition when the UZSU item should be updated with the calculated times for sun-related UZSU entries.' + description_long: + de: 'Cron-Angabe, wann für die UZSU Einträge mit Sonnenstandbezug die errechnete Uhrzeit ins dict Item geschrieben werden soll. Diese "calculated" Einträge sind relevant für diverse UZSU Widgets der SmartVisu.' + en: 'Cron definition when the UZSU item should be updated with the calculated times for sun-related UZSU entries.There "calculated" entries are relevant for some SmartVisu UZSU widgets.' + + interpolation_interval: + type: int + default: 5 + valid_min: 0 + description: + de: 'Standardintervall in Minuten, in dem ein interpolierter Wert erneut errechnet werden soll. Kann pro UZSU individuell konfiguriert werden.' + en: 'Standard interval in minutes that is used to re-calculate an interpolated value. Can be configured for each UZSU individually.' + + interpolation_type: + type: str + default: 'none' + valid_list: + - 'none' + - 'cubic' + - 'linear' + description: + de: 'Standardintervall in Minuten, in dem ein interpolierter Wert erneut errechnet werden soll. Kann pro UZSU individuell konfiguriert werden.' + en: 'Standard interval in minutes that is used to re-calculate an interpolated value. Can be configured for each UZSU individually.' + + backintime: + type: int + default: 0 + valid_min: 0 + description: + de: 'Standardmaximalalter eines UZSU Eintrags in Minuten, um beim Plugin-Start versäumte Einträge nachzuholen. Kann pro UZSU individuell konfiguriert werden.' + en: 'Standard maximum age of an uzsu entry in minutes to be catched up at the plugin start. Can be configured for each UZSU individually' + + interpolation_precision: + type: int + default: 2 + valid_min: 0 + valid_max: 4 + description: + de: 'Anzahl an Dezimalstellen bei der Berechnung der Interpolation' + en: 'Amount of decimal places when calculating interpolation values' + +item_attributes: + # Definition of item attributes defined by this plugin + uzsu_item: + type: str + description: + de: 'Das Item, das durch die UZSU geschaltet werden soll. Entweder als komplette ID oder relativer Pfad angegeben.' + en: 'The item that gets changed by the UZSU. Declare as full ID or relative path.' + description_long: + de: '**Das Item, das durch die UZSU geschaltet werden soll:**\n + Im items Ordner ist pro Item, das geschaltet werden soll, ein UZSU Item-Eintrag mit + ``type: dict`` zu erstellen. Die Hierarchie spielt dabei keine Rolle, es wird allerdings empfohlen, + das UZSU Item als Kind des zu schaltenden Items zu deklarieren und die relative Item-Referenzierung + ``".."`` für den Parameter ``uzsu_item`` zu nutzen. Es wird dringend empfohlen, + ``cache: True`` zu setzen, damit die Einstellungen bei einem Neustart nicht verloren gehen. + ' + en: '**The item that gets changed by the UZSU:**\n + You have to specify an item with ``type: dict`` and with the ``uzsu_item`` + attribute set to the path of the item which will be set by this item. + The hierarchy does not matter but it is recommended to define the UZSU item + as a child of the item to be set and use the relative item reference ``".."`` + for the uzsu_item parameter. It is highly recommended to specify + ``cache: True`` as well for persistent storage of the UZSU information. + ' + +item_structs: + child: + name: Vorlage-Struktur für Zeitschaltuhren + + uzsu: + type: dict + uzsu_item: .. + cache: True + visu_acl: rw + + last: + remark: The last set value if UZSU is active + type: foo + visu_acl: ro + eval: sh...lastvalue('uzsu_dict_updated') + crontab: init = None + eval_trigger: .. + + active: + remark: Use this item to easily turn on or off your UZSU + type: bool + eval: sh...activate(value) + visu_acl: rw + + status: + type: bool + eval: sh....activate() + eval_trigger: + - .. + - ... + on_change: .. = value + crontab: init = 0 + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin + +plugin_functions: + # Definition of function interface of the plugin + planned: + type: dict + description: + de: 'Abfrage des nächsten Aktualisierungszeitpunkts' + en: 'Query the next scheduled value and time' + description_long: + de: 'Abfrage des nächsten Aktualisierungszeitpunkts. Ist keine Aktualisierung geplant, z.B. weil das + UZSU Item nicht aktiviert ist, wird None zurückgegeben, ansonsten ein Dictionary + mit den Einträgen Zeit und Wert. + ' + en: 'Query the next scheduled value and time. If no update is planned, e.g. if the UZSU item + is not active, the result is None, otherwise a dictionary containing entries for time and value. + ' + + resume: + type: foo + description: + de: 'Fortsetzen der UZSU Evaluierung: Aktivieren des Items und Setzen des zuletzt festgelegten Wertes.' + en: 'Resuming the UZSU evaluation: activating the item and setting the last defined value.' + + lastvalue: + type: foo + description: + de: 'Abfrage des zuletzt gesetzten Werts. Kann z.B. beim Aktivieren der UZSU genutzt werden, um sofort auf den gewünschten Wert zu schalten.' + en: 'Query the last value. Can be used to immediately set the correct value after activating an UZSU.' + parameters: + by: + type: str + default: None + description: + de: 'Für eine entsprechende Info im Logfile kann hier z.B. der Itemname, der den Wert abruft eingetragen werden' + en: 'For respective info in the log file you can choose to put the item name that queries the value.' + + clear: + type: bool + description: + de: 'Beim Aufrufen mit dem Parameter True werden die Einträge der UZSU gelöscht.' + en: 'Using this function with the parameter True clears the UZSU.' + description_long: + de: 'Löschen der UZSU Einträge eines Items.\n + - Leer: nichts ausführen\n + - True: löschen\n + ' + en: 'Delete the UZSU entries of an item.\n + - Empty: do nothing\n + - True: delete\n + ' + + activate: + type: bool + description: + de: 'Abfrage oder Setzen, ob die uzsu aktiv ist oder nicht.' + en: 'query or set whether the uzsu is set active or not.' + description_long: + de: 'Abfrage oder Setzen, ob die uzsu aktiv ist oder nicht.\n + - Leer: Abfrage\n + - True: aktivieren\n + - False: deaktivieren\n + ' + en: 'Query or set whether the uzsu is set active or not.\n + - Empty: query\n + - True: activate\n + - False: deactivate\n + ' + + interpolation: + type: dict + description: + de: 'Abfrage (leerer Parameter) oder Setzen der Interpolationseinstellungen' + en: 'Query (empty parameter) or set the interpolation settings' + parameters: + 'type': + type: str + default: none + description: + de: 'Interpolationstyp: linear/none/cubic' + en: 'interpolation type: linear/none/cubic' + description_long: + de: 'Interpolationstyp:\n + - linear: konstant gleiche Zwischenberechnung\n + - cubic: Spline-Interpolation mit verzögertem Start und sanftem Verlangsamen\n + - none: keine Interpolation\n + ' + en: 'interpolation type:\n + - linear: constant interpolation\n + - cubic: splinte interpolation with ease in and out\n + - none: no interpolation\n + ' + interval: + type: int + default: 5 + description: + de: 'Intervall in Minuten, in dem der interpolierte Wert aktualisiert werden soll' + en: 'Interval in minutes to re-calculate the interpolated value' + backintime: + type: int + default: 0 + description: + de: 'Maximales Alter eines UZSU Eintrags in Minuten, um beim Plugin-Start versäumte Einträge nachzuholen.' + en: 'maximum age of an uzsu entry in minutes to be catched up at the plugin start' diff --git a/uzsu/requirements.txt b/uzsu/_pv_1_6_6/requirements.txt similarity index 100% rename from uzsu/requirements.txt rename to uzsu/_pv_1_6_6/requirements.txt diff --git a/uzsu/_pv_1_6_6/user_doc.rst b/uzsu/_pv_1_6_6/user_doc.rst new file mode 100755 index 000000000..ed1bc86f9 --- /dev/null +++ b/uzsu/_pv_1_6_6/user_doc.rst @@ -0,0 +1,197 @@ +.. index:: Plugins; uzsu +.. index:: uzsu + +==== +uzsu +==== + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Dieses Plugin ermöglicht gezielte Schaltvorgänge von Items zu bestimmten Uhrzeiten oder abhängig vom +Sonnenstand. Die automatischen Schaltungen können dabei pro Wochentag separat definiert werden. + +Außerdem ermöglicht eine Interpolationsfunktion das Errechnen von Werten zwischen zwei manuell +angelegten Schaltzeiten, wodurch z.B. Lichtkurven über den Tagesverlauf umgesetzt werden können + + +Einführung +========== + +Die Funktionsweise der universellen Zeitschaltuhr wird auf dem `SmarthomeNG Blog `_ +beschrieben. Dort finden sich auch einige praktische Beispiele. + + +Konfiguration +============= + +.. important:: + + Detaillierte Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/uzsu` zu finden. + + +.. code-block:: yaml + + # etc/plugin.yaml + uzsu: + plugin_name: uzsu + #remove_duplicates: True + +In der Item Hierarchie muss ein Kind-Item zum Item erstellt werden, das zeitlich gesteuert werden soll. + +.. code-block:: yaml + + # items/my.yaml + someroom: + + someitem: + type: num + + uzsu: + type: dict + uzsu_item: someroom.someitem #Ab smarthomeNG 1.6 ist es möglich, einfach nur '..' zu nutzen, um auf das Parent-Item zu verweisen. + cache: 'True' + + active: # Dieser Eintrag kann genutzt werden, um die UZSU durch einen einfachen Item Call zu (de)aktivieren. + type: bool + eval: sh...activate(value) + visu_acl: rw + +Ab *smarthomeNG 1.6* ist das Anlegen der nötigen Items via ``struct`` besonders einfach: + +.. code-block:: yaml + + # items/my.yaml + someroom: + + someitem: + type: num + struct: uzsu.child + +SmartVISU +========= + +Das UZSU Plugin wird durch die smartVISU ab Version 2.9 sowohl in Form eines Popups als auch einer grafischen Darstellung mittels *device.uzsu Widget* unterstützt. +Bei Problemen bitte das entsprechende Supportforum konsultieren. Es wird empfohlen, die Visualisierung für das Einstellen der UZSU zu verwenden. + + +Standard Einstellungen +----------------------- + +Für die universelle Zeitschaltuhr können folgende Einstellungen vorgenommen werden: + +* Allgemein Aktivieren: Komplette UZSU (de)aktivieren +* Wochentag: Es können beliebig viele Wochentage aktiviert werden. Wird kein Wochentag in der Visu gewählt, werden automatisch alle Wochentage aktiviert. +* Wert: Der zu schaltende Wert +* Zeit: Die Uhrzeit, zu der der gewünschte Wert geschaltet werden soll. Im Experten- und Serienmodus kann dieser Parameter auch detaillierter konfiguriert werden. +* Aktivieren: Eintrag aktivieren oder deaktivieren. + + +Experteneinstellungen +--------------------- + +Alternativ zu fest definierten Schaltzeiten lassen sich die Zeitpunkte auch in Abhängigkeit des Sonnenstandes +definieren. Hier ist außerdem ein Offset zum Sonnenauf- und Sonnenuntergang in Minuten oder Grad einstellbar. +Pro Eintrag kann auch ein frühester oder spätester Zeitpunkt gewählt werden, der dann herangezogen wird, +wenn die sonnenbasierte Schaltung über diese Grenzwerte hinaus berechnet werden würde. + + +Zeitserie +--------- + +Für wiederkehrende Schaltungen können auch Serien angelegt werden. Dabei ist ein Startzeitpunkt und ein Intervall zu definieren. Das Ende kann entweder über einen Zeitpunkt oder die Anzahl Wiederholungen definiert werden. Start- und Endzeitpunkte können wie bei der normalen UZSU auch sonnenstandsabhängig deklariert werden. + + +Interpolation +============= + +.. important:: + + Wenn die Interpolation aktiviert ist, wird das UZSU Item im gegebenen Intervall aktualisiert, auch wenn der nächste UZSU Eintrag über die Tagesgrenze hinaus geht. Gibt es beispielsweise heute um 23:00 einen Eintrag mit dem Wert 100 und morgen um 1:00 einen Eintrag mit dem Wert 0, wird zwischen den beiden Zeitpunkten der Wert kontinuierlich abnehmen. Bei linearer Interpolation wird um Mitternacht der Wert 50 geschrieben. + +Interpolation ist ein eigenes Dict innerhalb des UZSU Dictionary mit folgenden Einträgen: + +- **type**: string, setzt die mathematische Interpolationsfunktion cubic, linear oder none. Ist der Wert cubic oder linear gesetzt, wird der für die aktuelle Zeit interpolierte Wert sowohl beim Pluginstart als auch im entsprechenden Intervall gesetzt. + +- **interval**: integer, setzt den zeitlichen Abstand (in Sekunden) der automatischen UZSU Auslösungen + +- **initage**: integer, definiert die Anzahl Sekunden, innerhalb der beim Pluginstart etwaige versäumte UZSU Einträge gesucht werden sollen. Diese Einstellung ist obsolet, wenn die Interpolation nicht auf none ist, weil dann beim Pluginstart der errechnete Wert automatisch gesetzt wird. + +- **itemtype**: Der Item-Typ des uzsu_item, das durch die UZSU gesetzt werden soll. Dieser Wert wird beim Pluginstart automatisch ermittelt und sollte nicht verändert werden. + +- **initizialized**: bool, wird beim Pluginstart automatisch gesetzt, sobald ein gültiger Eintrag innerhalb der initage Zeit gefunden wurde und diese Initialisierung tatsächlich ausgeführt wurde. + + +Pluginfunktionen +================ + +Detaillierte Informationen zu den Funktionen des Plugins sind unter :doc:`/plugins_doc/config/uzsu` zu finden. + + +Web Interface +============= + +Das Webinterface bietet folgende Informationen: + +- **Allgemeines**: Oben rechts werden die berechneten Sonnenauf- und Sonnenuntergänge der nächsten 7 Tage und die Anzahl der UZSU Items angezeigt. + +- **UZSUs**: Liste aller UZSU Items mit farbkodierter Information über den Status (inaktiv = grau, aktiv = grün, Problem = rot) + +- **UZSU Items**: Info zu den Items, die über die UZSU geschaltet werden (inkl. Typ) + +- **UZSU Item Wert**: Aktueller Wert des Items, das durch die UZSU geschaltet wird. + +- **Nächster Wert**: geplanter nächster Wert und Zeitpunkt der Schaltung + +- **Nächstes Update**: geplanter nächster Zeitpunkt der Schaltung + +- **Letzter Wert**: zuletzt berechneter Wert (relevant bei Interpolation). Dies ist NICHT ident mit property.last_value! + +- **Interpolation**: Interpolationstyp und Intervall + +- **Init**: Back in Time bzw. init age Wert + +- **dict**: Durch Klicken auf das Plus am Beginn jeder Zeile wird das gesamte Dictionary einer UZSU angezeigt. + +.. image:: assets/uzsu_webif.png + :height: 1616px + :width: 3324px + :scale: 25% + :alt: Web Interface + :align: center + + +Beispiel +======== + +Folgender Python Aufruf bzw. Dictionary Eintrag schaltet das Licht jeden zweiten Tag um 16:30 auf den Wert 100% und deaktiviert es um 17:30 Uhr. Dazwischen wird im Abstand von 5 Minuten der Wert linear interpoliert. Um 17:00 Uhr ist er somit bei 50%. + +.. code:: python + + sh.eg.wohnen.leuchte.uzsu({'active':True, 'list':[ + {'value':100, 'active':True, 'rrule':'FREQ=DAILY;INTERVAL=2', 'time': '16:30'}, + {'value':0, 'active':True, 'rrule':'FREQ=DAILY;INTERVAL=2', 'time': '17:30'}], + 'interpolation': {'interval': 5, 'type': 'cubic', 'initialized': False, 'itemtype': 'num', 'initage': 0}, 'sunrise': '07:45', 'sunset': '17:23', 'SunCalculated': {'sunrise': + {'TU': '07:36', 'WE': '07:38', 'TH': '07:34', 'FR': '07:32', 'SA': '07:30', 'SU': '07:28', 'MO': '07:26'}, + 'sunset': {'TU': '17:16', 'WE': '17:18', 'TH': '17:20', 'FR': '17:22', 'SA': '17:23', 'SU': '17:25', 'MO': '17:27'}}, + 'plugin_version': '1.6.1'}) + + +Datenformat +=========== + +Jedes USZU Item wird als dict-Typ gespeichert. Jeder Listen-Eintrag ist wiederum ein dict, das aus Key und Value-Paaren besteht. Im Folgenden werden die möglichen Dictionary-Keys gelistet. Nutzt man das USZU Widget der SmartVISU, muss man sich um diese Einträge nicht kümmern. + +- **dtstart**: Ein datetime Objekt, das den exakten Startwert für den rrule Algorithmus bestimmt. Dieser Parameter ist besonders bei FREQ=MINUTELY rrules relevant. + +- **value**: Der Wert, auf den das uzsu_item gesetzt werden soll. + +- **active**: ``True`` wenn die UZSU aktiviert ist, ``False`` wenn keine Aktualisierungen vorgenommen werden sollen. Dieser Wert kann über die Pluginfunktion activate gesteuert werden. + +- **time**: Zeit als String. Entweder eine direkte Zeitangabe wie ``17:00`` oder eine Kombination mit Sonnenauf- und Untergang wie bei einem crontab, z.B. ``17:008:00``, ``17:00`_ beschrieben festgelegt werden. diff --git a/uzsu/_pv_1_6_6/user_doc_en.rst b/uzsu/_pv_1_6_6/user_doc_en.rst new file mode 100755 index 000000000..612f49cc4 --- /dev/null +++ b/uzsu/_pv_1_6_6/user_doc_en.rst @@ -0,0 +1,129 @@ +.. index:: Plugins; uzsu +.. index:: uzsu + +uzsu +#### + +Configuration +============= + +.. important:: + + Please find detailed information on the configuration in the user documentation. + + +.. code-block:: yaml + + # etc/plugin.yaml + uzsu: + plugin_name: uzsu + #remove_duplicates: True + + +.. code-block:: yaml + + # items/my.yaml + someroom: + + someitem: + type: num + + UZSU: + type: dict + uzsu_item: someroom.someitem #With SmarthomeNG 1.6+ you can use '..' instead + cache: 'True' + + active: # You can use this item to easily (de)activate your UZSU via logics or visu. + type: bool + eval: sh...activate(value) + visu_acl: rw + +SmartVISU +========= + +In the latest SmartVISU 2.9 there is a widget called *device.uzsu* available which gives an interface to the UZSU. If you have problems please consult the corresponding forum. It is recommended to use the widget to create UZSU entries. The following information on data format can be skipped. + +Data format +=========== + +Each UZSU item is of type list. Each list entry has to be a dict with specific key and value pairs. Here are the possible keys and what their for: + +- **dtstart**: a datetime object. Exact datetime as start value for the rrule algorithm. Important e.g. for FREQ=MINUTELY rrules (optional). + +- **value**: the value which will be set to the item. + +- **active**: `True` if the entry is activated, `False` if not. A deactivated entry is stored to the database but doesn't trigger the setting of the value. It can be enabled with the `activate` function. + +- **time**: time as string to use sunrise/sunset arithmetics like in the crontab eg. `17:008:00`, `17:00`_ for more examples and getting started info. diff --git a/uzsu/_pv_1_6_6/webif/__init__.py b/uzsu/_pv_1_6_6/webif/__init__.py new file mode 100755 index 000000000..b929636e2 --- /dev/null +++ b/uzsu/_pv_1_6_6/webif/__init__.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + pagelength = self.plugin.get_parameter_value('webif_pagelength') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=len(self.plugin._items)) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + data = self.plugin._webdata + try: + return json.dumps(data) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + return {} diff --git a/uzsu/_pv_1_6_6/webif/static/img/plugin_logo.svg b/uzsu/_pv_1_6_6/webif/static/img/plugin_logo.svg new file mode 100755 index 000000000..3c629289e --- /dev/null +++ b/uzsu/_pv_1_6_6/webif/static/img/plugin_logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/uzsu/_pv_1_6_6/webif/static/img/readme.txt b/uzsu/_pv_1_6_6/webif/static/img/readme.txt new file mode 100755 index 000000000..1a7c55eef --- /dev/null +++ b/uzsu/_pv_1_6_6/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/uzsu/_pv_1_6_6/webif/templates/index.html b/uzsu/_pv_1_6_6/webif/templates/index.html new file mode 100755 index 000000000..b3edbd801 --- /dev/null +++ b/uzsu/_pv_1_6_6/webif/templates/index.html @@ -0,0 +1,259 @@ +{% extends "base_plugin.html" %} +{% set update_interval = 5000 %} +{% block pluginstyles %} + +{% endblock pluginstyles %} +{% block pluginscripts %} + + + +{% endblock pluginscripts %} +{% set logo_frame = false %} + +{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} +{% set tabcount = 1 %} + +{% block headtable %} +{% set sun = namespace() %} +{% set sun.sunrise_text = '' %} +{% set sun.sunset_text = '' %} +{% set entries_rise = p._webdata['sunCalculated']['sunrise'] %} +{% set entries_set = p._webdata['sunCalculated']['sunset'] %} +{% for i in entries_rise %} + {% set sun.sunrise_text = sun.sunrise_text ~ i ~ ': ' %} + {% set sun.sunrise_text = sun.sunrise_text ~ p._webdata['sunCalculated']['sunrise'][i] %} + {% set sun.sunset_text = sun.sunset_text ~ i ~ ': ' %} + {% set sun.sunset_text = sun.sunset_text ~ p._webdata['sunCalculated']['sunset'][i] %} + {% if not loop.last %} + {% set sun.sunrise_text = sun.sunrise_text ~ ', ' %} + {% set sun.sunset_text = sun.sunset_text ~ ', ' %} + {% endif %} +{% endfor %} + + + + + + + + + + + + + +
{{ _('Sonnenaufgang') }}
+ {{ sun.sunrise_text }}
{{ _('Sonnenuntergang') }}
+ {{ sun.sunset_text }}
Items: {{ item_count }}
+{% endblock headtable %} + +{% block bodytab1 %} +
+
+ + {{ _('Die folgenden Items sind dem UZSU Plugin zugewiesen') }}. +
+ + + + + + + + + + + + + + + + + {% for item in p._webdata['items'] %} + {% set planned = p._webdata['items'][item]['planned']['value'] %} + {% if (p._webdata['items'][item]['active'] == 'True' and planned == '-') or + p._webdata['items'][item]['interpolation'] is not defined or p._webdata['items'][item]['interpolation']['itemtype'] == none %} + {% set color = 'red' %} + {% elif p._webdata['items'][item]['planned'] == '-' or p._webdata['items'][item]['active'] == 'False' %} + {% set color = 'gray' %} + {% else %} + {% set color = 'green' %} + {% endif %} + + + + + + + + + + + + + {% endfor %} + +
{{ _('UZSU Item') }}{{ _('Abhängige Items (mit Typ)') }}{{ _('Wert') }}{{ _('Nächster Wert') }}{{ _('Nächstes Update') }}{{ _('Letzter Wert') }}{{ _('Interpolation (Intervall)') }}{{ _('Init') }}{{ _('Dictionary') }}
{{ item }} + {% if p._webdata['items'][item]['interpolation'] is not defined %} + + {% elif p._webdata['items'][item]['interpolation']['itemtype'] == none %} + {{ _('Item existiert nicht!') }} + {% else %}{{ p._webdata['items'][item]['depend']['item'] }} ({{ p._webdata['items'][item]['interpolation']['itemtype'] }}) + {% endif %} + + {% if p._webdata['items'][item]['depend']['value'] is defined %} + {{ p._webdata['items'][item]['depend']['value'] }} + {% endif %} + + {{ p._webdata['items'][item]['planned']['value'] }} + + {% if p._webdata['items'][item]['planned']['time'] == '-' %} + - + {% else %} + ‪{{ p._webdata['items'][item]['planned']['time'] }}‬ + {% endif %} + + {{ p._webdata['items'][item]['lastvalue'] }} + + {% if p._webdata['items'][item]['interpolation'] is not defined %} + {{ _('fehlt!') }} + {% elif p._webdata['items'][item]['interpolation']['type'] %} + {{ p._webdata['items'][item]['interpolation']['type'] }} + {% if p._webdata['items'][item]['interpolation']['type'] != 'none' and p._webdata['items'][item]['interpolation']['interval'] %} + ({{ p._webdata['items'][item]['interpolation']['interval'] }}) + {% endif %} + {% else %}- + {% endif %} + + {% if p._webdata['items'][item]['interpolation'] is not defined %} + {{ _('fehlt!') }} + {% elif p._webdata['items'][item]['interpolation']['initage'] is defined %} + {{ p._webdata['items'][item]['interpolation']['initage'] }} + {% else %}- + {% endif %} + + {{ p._webdata['items'][item]['dict'] }} +
+
+{% endblock bodytab1 %} diff --git a/uzsu/plugin.yaml b/uzsu/plugin.yaml index 29bc5e70e..79056da47 100755 --- a/uzsu/plugin.yaml +++ b/uzsu/plugin.yaml @@ -18,35 +18,13 @@ plugin: Furthermore the interpolation function allows the calculation of values between two manual settings. You can use this feature for smooth light curves based on the time of the day. ' - requirements: - de: 'SciPy python Modul' - en: 'SciPy python module' - requirements_long: - de: 'Das Plugin benötigt die folgende Software:\n - \n - - libatlas-base-dev: Zumindest auf einem Raspberry Pi mit Debian Stretch ist der Befehl nötig: ``sudo apt install libatlas-base-dev``\n - - Bei neueren SciPy Versionen kann auf einem Raspi der Build trotzdem scheitern. Es ist dann empfohlen, die aktuellste passende Datei (armv6 = Raspi1, armv7 = Rest) - von hier herunterzuladen und ``pip3 install scipy*.whl`` zu starten: https://www.piwheels.org/simple/scipy/\n - - Python Modul scipy: ``pip3 install scipy``. Es wird empfohlen, zuerst die Pythonmodule zu aktualisieren, - aber unbedingt darauf zu achten, dass die Requirements von SmarthomeNG erfüllt bleiben! - Sollte die Installation via pip nicht funktionieren: ``sudo apt update && sudo apt install -y python3-scipy``\n - ' - en: 'This plugin needs the following software to be installed and running:\n - \n - - libatlas-base-dev: On Raspberry Pi debian stretch you also have to run ``sudo apt install libatlas-base-dev``\n - - With newer SciPy versions build can still fail on Raspis. It is recommended to download the most recent file (armv6 = Raspi1, armv7 = others) from here and run - ``pip3 install scipy*.whl``: https://www.piwheels.org/simple/scipy/\n - - Python module scipy: ``pip3 install scipy``. Update your Python packages first - (but make sure they still meet the requirements for smarthomeng)! - If that does not work you can use: ``sudo apt update && sudo apt install -y python3-scipy``\n - ' - maintainer: cmalo, bmxp, onkelandy, andrek - tester: Sandman60, cmalo, schuma + maintainer: cmalo, bmxp, onkelandy, andrek, morg42 + tester: Sandman60, cmalo, schuma, morg42 state: ready keywords: scheduler uzsu trigger series support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1364692-supportthread-für-uzsu-plugin - version: 1.6.5 # Plugin version + version: 2.0.0 # Plugin version sh_minversion: 1.6 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/uzsu/webif/templates/index.html b/uzsu/webif/templates/index.html index 9f9eec53b..b3edbd801 100755 --- a/uzsu/webif/templates/index.html +++ b/uzsu/webif/templates/index.html @@ -20,22 +20,9 @@ +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + +
ClientID{{ p.clientID }}-{{ _('Anzahl Devices') }}{{ p.nr_devices }}
Gerätemodell{{ p.modelId }} ({{ p.deviceType }})Onlinestatus{% if p.onlineStatus %}{{ _('Online') }}{% else %}{{ _('Offline') }}{% endif %}
+{% endblock headtable %} + + + +{% block buttons %} +{% endblock %} + + +{% set tabcount = 2 %} + + + + +{% set start_tab = 1 %} + + +{% set tab1title = "" ~_('Mit Vicare verbinden')~ "" %} + + +{% block bodytab1 %} + +{% if generateURLSuccessfull %} + +{% endif %} +{% if generateURLSuccessfull == False%} + +{% endif %} +{% if tokenRequestCompleted %} + +{% endif %} +{% if tokenRequestCompleted == False%} + +{% endif %} + +
+ {{ _('Viessmann Explanation') }} +
+ +
+
+
+
+ 1) {{ _('URL für Authentifizierung') }}: +
+
+ +
+
+ +
+
+
+
+ 2) {{ _('URL in Browser kopieren und login durchführen. Anschließend innerhalb von 20 Sekunden den in der URL enthaltenen Code unter 3) einfügen und unter 4) den Token abrufen.') }}: +
+
+ +
+ +
+ 3) {{ _('Einmaligen Freischaltcode eingeben') }}: +
+
+ +
+
+
+ +
+ 4) {{ _('AccessToken abrufen') }}: +
+
+ +
+
+
+
+ + +{% endblock bodytab1 %} + + + +{% set tab2title = "" ~_('Featureliste')~ "" %} +{% block bodytab2 %} +
+ {{ _('Die folgenden Features sind verfügbar') }}, Gesamtanzahl {{ p.featureListJson|length }}, nur aktive (enabled) Features werden angezeigt. +
+ + + + + + + + + + + {% for item in p.featureListJson %} + {% if item['isEnabled'] == True %} + + + + + + + + {% endif %} + {% endfor %} +
{{ _('Feature') }}{{ _('enabled') }}{{ _('Properties') }}{{ _('Commands') }}{{ _('ready') }}
{{ item['feature'] }}{{ item['isEnabled'] }} {{ item['properties'] }} {{ item['commands'] }}{{ item['isReady'] }}
+ +{% endblock bodytab2 %} + + + + + diff --git a/viessmann/user_doc.rst b/viessmann/user_doc.rst index c9147d140..788e1932e 100755 --- a/viessmann/user_doc.rst +++ b/viessmann/user_doc.rst @@ -16,7 +16,7 @@ Das Viessmann-Plugin ermöglicht die Verbindung zu einer Viessmann-Heizung über Derzeit sind das P300- und das KW-Protokoll unterstützt. Weitere Gerätetypen, die diese Protokolle unterstützen, können einfach hinzugefügt werden. Für weitere Protokolle (z.B. GWG) wird zusätzliche Entwicklungsarbeit notwendig sein. Details zu den betroffenen Geräten und Protokollen finden sich im -.. _OpenV-Wiki: https://github.com/openv/openv/wiki/vcontrold +`OpenV Wiki `_ Dieses Plugin nutzt eine separate Datei ``commands.py``, in der die Definitionen für Protokolle, Gerätetypen und Befehlssätze enthalten sind. Neue Geräte können hinzugefügt werden, indem die entsprechenden Informationen in der ``commands.py`` ergänzt werden. @@ -25,15 +25,15 @@ Das Plugin unterstützt die serielle Kommunikation mit dem Lesekopf (ggf. über Zur Identifizierung des Heizungstyps kann das Plugin auch im Standalone-Modus betrieben werden (s.u.) Changelog ---------- +========= 1.2.2 -~~~~~ +----- - Funktion zum manuellen Schreiben von Werten hinzugefügt 1.2.0 -~~~~~ +----- - Komplette Überarbeitung von Code und Webinterface (AJAX) - Code refaktorisiert und besser strukturiert @@ -43,12 +43,12 @@ Changelog - Webinterface mit der Möglichkeit, Adressen manuell auszulesen 1.1.0 -~~~~~ +----- - Unterstützung für das KW-Protokoll 1.0.0 -~~~~~ +----- - Erste Version @@ -58,7 +58,7 @@ Anforderungen Das Plugin benötigt die ``pyserial``-Bibliothek und einen seriellen IR-Adapter. Unterstützte Geräte -------------------- +=================== Jede Viessmann-Heizung mit Optolink-Anschluss wird grundsätzlich unterstützt. @@ -77,7 +77,6 @@ Konfiguration Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/viessmann` beschrieben. - plugin.yaml ----------- @@ -98,44 +97,44 @@ Die Verknüfpung von SmartHomeNG-Items und Heizungsparametern ist vollständig f Die folgenden Attribute werden unterstützt: -viess\_read -~~~~~~~~~~~ +viess_read +~~~~~~~~~~ Der Wert des angegebenen Parameters wird gelesen und dem Item zugewiesen. -.. code:: yaml +.. code-block:: yaml item: viess_read: Raumtemperatur_Soll_Normalbetrieb_A1M1 -viess\_send -~~~~~~~~~~~ +viess_send +~~~~~~~~~~ Der angegebene Parameter wird bei Änderungen an diesem Item an die Heizung gesendet. -.. code:: yaml +.. code-block:: yaml item: viess_send: Raumtemperatur_Soll_Normalbetrieb_A1M1 Sofern das Item sowohl zum Lesen als auch zum Schreiben eines Parameters konfiguriert wird, kann die vereinfachte Konfiguration mit ``true`` erfolgen: -.. code:: yaml +.. code-block:: yaml item: viess_read: Raumtemperatur_Soll_Normalbetrieb_A1M1 viess_send: true -viess\_read\_afterwrite -~~~~~~~~~~~~~~~~~~~~~~~ +viess_read_afterwrite +~~~~~~~~~~~~~~~~~~~~~ Wenn dieses Attribut mit einer Dauer in Sekunden angegeben ist, wird nach eine Schreibvorgang die angegebene Anzahl an Sekunden gewartet und ein erneuter Lesevorgang ausgelöst. Damit dieses Attribut verwendet werden kann, muss das Item sowohl die Attribute ``viess_read`` als auch ``viess_send`` enthalten. -.. code:: yaml +.. code-block:: yaml item: viess_read: Raumtemperatur_Soll_Normalbetrieb_A1M1 @@ -143,33 +142,33 @@ Damit dieses Attribut verwendet werden kann, muss das Item sowohl die Attribute viess_read_afterwrite: 1 # seconds -viess\_read\_cycle -~~~~~~~~~~~~~~~~~~ +viess_read_cycle +~~~~~~~~~~~~~~~~ Mit einer Angabe in Sekunden wird ein periodisches Lesen angefordert. ``viess_read`` muss zusätzlich konfiguriert sein. -.. code:: yaml +.. code-block:: yaml item: viess_read: Raumtemperatur_Soll_Normalbetrieb_A1M1 viess_read_cycle: 3600 # every hour -viess\_init -~~~~~~~~~~~ +viess_init +~~~~~~~~~~ Wenn dieses Attribut vorhanden und auf ``true`` gesetzt ist, wird das Item nach dem Start von SmartHomeNG einmalig gelesen. ``viess_read`` muss zusätzlich konfiguriert sein. -.. code:: yaml +.. code-block:: yaml item: viess_read: Raumtemperatur_Soll_Normalbetrieb_A1M1 viess_init: true -viess\_trigger -~~~~~~~~~~~~~~ +viess_trigger +~~~~~~~~~~~~~ Enthält eine Liste von Parametern. Wenn dieses Item aktualisiert wird, wird ein Lesevorgang für jeden Eintrag in der Liste angestoßen. ``viess_send`` muss zusätzlich konfiguriert sein. @@ -177,7 +176,7 @@ Zwischen dem Schreibvorgang und den folgenden Lesevorgängen ist standardmäßig Beispiel: wenn der Betriebsmodus geändert wird, können neue Sollwerte für Raum- und Wassertemperaturen gelesen werden. -.. code:: yaml +.. code-block:: yaml item: viess_send: Betriebsart_A1M1 @@ -186,14 +185,14 @@ Beispiel: wenn der Betriebsmodus geändert wird, können neue Sollwerte für Rau - Wassertemperatur_Soll -viess\_trigger\_afterwrite -~~~~~~~~~~~~~~~~~~~~~~~~~~ +viess_trigger_afterwrite +~~~~~~~~~~~~~~~~~~~~~~~~ Wenn ein ``viess_trigger`` konfiguriert ist, kann mit diesem Attribut die Verzögerung zwischen Schreib- und Lesevorgang verändert werden. Standardmäßig beträgt diese Verzögerung 5 Sekunden. -.. code:: yaml +.. code-block:: yaml item: viess_send: Betriebsart_A1M1 @@ -203,41 +202,44 @@ Standardmäßig beträgt diese Verzögerung 5 Sekunden. viess_trigger_afterwrite: 10 # seconds -viess\_update -~~~~~~~~~~~~~ +viess_update +~~~~~~~~~~~~ + Das Zuweisen von ``true`` an ein Item mit diesem Attribut löst den Lesevorgang aller konfigurierter Items mit ``viess_read`` aus. Der in der Itemkonfiguration angegebene Wert wird nicht ausgewertet. -.. code:: yaml +.. code-block:: yaml item: viess_update: 'egal' -viess\_timer -~~~~~~~~~~~~ +viess_timer +~~~~~~~~~~~ + Das Item mit diesem Attribut übergibt als Attributwert den Namen einer Anwendung, z.B. Heizkreis_A1M1, und das Plugin gibt ein UZSU-formatiertes dict mit allen zugehörigen Timern der Heizung zurück Beim Schreiben wird das UZSU-dict in die einzelnen Tagestimer aufgeteilt und an die Heizung gesendet. -.. code:: yaml +.. code-block:: yaml item: viess_timer: 'Heizkreis_A1M1' -viess\_ba\_list -~~~~~~~~~~~~~~~ +viess_ba_list +~~~~~~~~~~~~~ + Das Item mit diesem Attribut erhält einmalig beim Start des Plugins die Liste der für den konfigurierten Heizungstyp gültigen Betriebsarten. Diese kann z.B. in SmartVISU wie folgt eingebunden werden: -.. code:: yaml +.. code-block:: yaml item: viess_ba_list: 'egal' -.. code:: +.. code-block:: html {{ basic.select('heizen_ba_item', 'heizung.betriebsart', 'menu', '', '', '', '', '', 'heizung.ba_list') }} @@ -250,7 +252,7 @@ Beispiel Here you can find a configuration sample using the commands for V200KO1B: -.. code:: yaml +.. code-block:: yaml viessmann: viessmann_update: @@ -379,38 +381,40 @@ V200KO1B: Funktionen ========== -update\_all\_read\_items() --------------------------- +update_all_read_items() +----------------------- Diese Funktion stößt den Lesevorgang aller konfigurierten Items mit ``viess_read``-Attribut an. -read\_addr(addr) ----------------- +read_addr(addr) +--------------- Diese Funktion löst das Lesen des Parameters mit der übergebenen Adresse ``addr`` aus. Die Adresse muss als vierstellige Hex-Zahl im String-Format übergeben werden. Es können nur Adressen ausgelesen werden, die im Befehlssatz für den aktiven Heizungstyp enthalten sind. Unabhängig von der Itemkonfiguration werden durch ``read_addr()`` keine Werte an Items zugewiesen. Der Rückgabewert ist das Ergebnis des Lesevorgangs oder None, wenn ein Fehler aufgetreten ist. -read\_temp\_addr(addr, length, unit) ------------------------------------- +read_temp_addr(addr, length, unit) +---------------------------------- Diese Funktion versucht, den Parameter an der Adresse ``addr`` zu lesen und einen Wert von ``length`` Bytes in die Einheit ``unit`` zu konvertieren. Die Adresse muss als vierstellige Hex-Zahl im String-Format übergeben werden, im Gegensatz zu ``read_addr()`` aber nicht im Befehlssatz definiert sein. ``length`` ist auf Werte zwischen 1 und 8 (Bytes) beschränkt. ``unit`` muss im aktuellen Befehlssatz definiert sein. Der Rückgabewert ist das Ergebnis des Lesevorgangs oder None, wenn ein Fehler aufgetreten ist. -write\_addr(addr, value) ------------------------- +write_addr(addr, value) +----------------------- Diese Funktion versucht, den Wert ``value`` an die angegebene Adresse zu schreiben. Die Adresse muss als vierstellige Hex-Zahl im String-Format übergeben werden. Es können nur Adressen beschrieben werden, die im Befehlssatz für den aktiven Heizungstyp enthalten sind. Durch ``write_addr`` werden Itemwerte nicht direkt geändert; wenn die geschriebenen Werte von der Heizung wieder ausgelesen werden (z.B. durch zyklisches Lesen), werden die geänderten Werte in die entsprechenden Items übernommen. +.. warning:: -:Warning: Das Schreiben von beliebigen Werten oder Werten, deren Bedeutung nicht klar ist, kann im Heizungsgerät möglicherweise unerwartete Folgen haben. Auch eine Beschädigung der Heizung ist nicht auszuschließen. + Das Schreiben von beliebigen Werten oder Werten, deren Bedeutung nicht klar ist, kann im Heizungsgerät möglicherweise unerwartete Folgen haben. Auch eine Beschädigung der Heizung ist nicht auszuschließen. +.. hint:: -:Note: Wenn eine der Plugin-Funktionen in einer Logik verwendet werden sollen, kann dies in der folgenden Form erfolgen: + Wenn eine der Plugin-Funktionen in einer Logik verwendet werden sollen, kann dies in der folgenden Form erfolgen: -.. code::yaml +.. code-block:: yaml result = sh.plugins.return_plugin('viessmann').read_temp_addr('00f8', 2, 'DT') @@ -440,4 +444,4 @@ Der serielle Port ist dabei die Gerätedatei bzw. der entsprechende Port, an dem Das optionale zweite Argument `-v` weist das Plugin an, zusätzliche Debug-Ausgaben zu erzeugen. Solange keine Probleme beim Aufruf auftreten, ist das nicht erforderlich. -Sollte die Datei sich nicht starten lassen, muss ggf. der Dateimodus angepasst werden. Mit ``chmod u+x __init__.py`` kann die z.B. unter Linux erfolgen. \ No newline at end of file +Sollte die Datei sich nicht starten lassen, muss ggf. der Dateimodus angepasst werden. Mit ``chmod u+x __init__.py`` kann die z.B. unter Linux erfolgen. diff --git a/vr100/README.md b/vr100/README.md deleted file mode 100755 index edc0fa72f..000000000 --- a/vr100/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# VR100 - -## Requirements - -bluez - -install by -```bash -$ apt-get install bluez -$ hcitool scan -Scanning ... - -$ simple-agent hci0 -RequestPinCode (/org/bluez/3070/hci0/dev_bt_addr_underscores) -Enter PIN Code: -Release -New device (/org/bluez/3070/hci0/dev_bt_addr_underscores) -$ bluez-test-device trusted yes -$ bluez-test-device list -``` - -## Supported Hardware - -A Vorwerk Kobold VR100 robotic vacuum cleaner with a retrofitted bluetooth module. - -## Configuration - -### plugin.yaml - -``` -vr100: - class_name: VR100 - class_path: plugins.vr100 - bt_addr: 07:12:07:xx:xx:xx - # update_cycle: 60 -``` - -Description of the attributes: - -* __bt_addr__: MAC-address of the robot (find out with 'hcitool scan') -* __update_cycle__: interval in seconds how often the data is read from the robot (default 60) - -### items.yaml - -You can use all commands available by the serial interface. - -For a explanation of all available commands type 'help' when connected to robot - -Attributes: -* __vr100_cmd__: used to set a comand string -* __vr100_info__: used to get data from the robot - all but the last strings are send as a comand, the last string is read to get the value - -Fields: -* __{}__: the value of the item is written to this placeholder (don't use if a fixed/no value is required) - -You should verify all your commands manually by using the serial interface. - -```yaml -VR100: - - Reinigung: - type: bool - vr100_cmd: Clean - - Spot: - type: bool - vr100_cmd: Clean Spot - - Batterie: - - Fuellstand: - type: num - sqlite: 'true' - vr100_info: GetCharger FuelPercent - - Ladung_aktiv: - type: bool - vr100_info: GetCharger ChargingActive - - leer: - type: bool - vr100_info: GetCharger EmptyFuel - - Spannung: - type: num - sqlite: 'true' - vr100_info: GetCharger VBattV -``` diff --git a/vr100/__init__.py b/vr100/__init__.py deleted file mode 100755 index 1b385761e..000000000 --- a/vr100/__init__.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -######################################################################### -# Copyright 2013 Robert Budde robert@projekt131.de -######################################################################### -# VR100/Neato plugin for SmartHomeNG. https://github.com/smarthomeNG// -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import logging -import socket -import sys - -logger = logging.getLogger('VR100') - - -class VR100(): - - def __init__(self, smarthome, bt_addr, update_cycle="300"): - self._sh = smarthome - self._update_cycle = int(update_cycle) - self._query_items = {} - self._bt_addr = bt_addr - self._terminator = bytes('\r\n\x1a\r\n\x1a', 'utf-8') - - def _update_values(self): - #logger.debug("vr100: update") - for query_cmd, fields in self._query_items.items(): - #logger.debug("vr100: requesting \'{}\'".format(query_cmd)) - self._send(query_cmd) - for line in self._recv().splitlines(): - field, _, value = line.partition(',') - #logger.debug("vr100: {}={}".format(field, value)) - field = field.upper() - if field in self._query_items[query_cmd]: - for item in self._query_items[query_cmd][field]['items']: - item(value, 'VR100', "field \'{}\'".format(field)) - - def run(self): - self.alive = True - if True: - try: - self._btsocket = socket.socket( - socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) - self._btsocket.connect((self._bt_addr, 1)) - logger.info( - "vr100: via bluetooth connected to {}".format(self._bt_addr)) - except: - logger.error( - "vr100: establishing connection to robot failed - {}".format(sys.exc_info())) - return - self._sh.scheduler.add('VR100', self._update_values, - prio=5, cycle=self._update_cycle) - - def stop(self): - self.alive = False - try: - self._sh.scheduler.remove('VR100') - except: - logger.error( - "vr100: removing VR100 from scheduler failed - {}".format(sys.exc_info())) - try: - self._btsocket.close() - except: - logger.error( - "vr100: closing connection to robot failed - {}".format(sys.exc_info())) - - def parse_item(self, item): - if 'vr100_cmd' in item.conf: - cmd = item.conf['vr100_cmd'] - logger.debug("vr100: {0} will send cmd \'{1}\'".format(item, cmd)) - return self.update_item - if 'vr100_info' in item.conf: - info = item.conf['vr100_info'].rsplit(' ', 1) - query_cmd = info[0] - field = info[1].upper() - if not query_cmd in self._query_items: - self._query_items[query_cmd] = {} - if not field in self._query_items[query_cmd]: - self._query_items[query_cmd][field] = { - 'items': [], 'logics': []} - if not item in self._query_items[query_cmd][field]['items']: - self._query_items[query_cmd][field]['items'].append(item) - logger.debug("vr100: {0} will be updated by querying \'{1}\' and extracting \'{2}\'".format( - item, query_cmd, field)) - return None - - def update_item(self, item, caller=None, source=None, dest=None): - try: - cmd = item.conf['vr100_cmd'] - value = item() - if isinstance(value, bool): - value = 'on' if value else 'off' - if cmd.lower().startswith('clean') and not value: - # allow stopping cleaning by setting item to false - cmd = 'clean stop' - self._send(cmd.format(value)) - except: - pass - - def _recv(self, timeout=1.0): - try: - msg = bytearray() - self._btsocket.settimeout(timeout) - while ((len(msg) < len(self._terminator)) or (msg[-len(self._terminator):] != self._terminator)): - msg += self._btsocket.recv(1000) - except socket.timeout: - logger.warning("vr100: rx: timeout after {}s".format(timeout)) - return '' - except: - logger.warning("vr100: rx: exception - {}".format(sys.exc_info())) - return '' - try: - msg = msg[:-len(self._terminator)].decode() - except: - msg = '' - #logger.debug("vr100: rx: msg: len={} / str={}".format(len(msg), msg)) - return msg - - def _send(self, msg): - #logger.debug("vr100: tx: len={} / str={}".format(len(msg), msg)) - try: - self._btsocket.send(bytes(msg + '\r\n', 'utf-8')) - except OSError as e: - if e.errno == 107: # Der Socket ist nicht verbunden - self.run() - except: - logger.warning("vr100: rx: exception - {}".format(sys.exc_info())) diff --git a/vr100/plugin.yaml b/vr100/plugin.yaml deleted file mode 100755 index 8e07e7b48..000000000 --- a/vr100/plugin.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Metadata for the classic-plugin -plugin: - # Global plugin attributes - type: interface # plugin type (gateway, interface, protocol, system, web) - description: - de: 'Anbindung eines Vorwerk Kobold VR100 Staubsaugers. Der Kobold muss mit einem Bluetooth Modul ausgerüstet sein' - en: '' - maintainer: '? (Robert Budde)' -# tester: efgh # Who tests this plugin? - keywords: bluetooth - state: deprecated # No user or tester for SmartPlugin conversion could be found -# documentation: https://github.com/smarthomeNG/plugins/blob/develop/mqtt/README.md # url of documentation (wiki) page -# support: https://knx-user-forum.de/forum/supportforen/smarthome-py - -# Following entries are for Smart-Plugins: -# version: 1.3.3 # Plugin version -# sh_minversion: 1.3 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) -# multi_instance: False - classname: VR100 # class containing the plugin - -#parameters: - # Definition of parameters to be configured in etc/plugin.yaml - -#item_attributes: - # Definition of item attributes defined by this plugin - diff --git a/webpush/__init__.py b/webpush/__init__.py old mode 100644 new mode 100755 diff --git a/webpush/locale.yaml b/webpush/locale.yaml old mode 100644 new mode 100755 diff --git a/webpush/plugin.yaml b/webpush/plugin.yaml old mode 100644 new mode 100755 diff --git a/webpush/requirements.txt b/webpush/requirements.txt old mode 100644 new mode 100755 diff --git a/webpush/sv_widgets/webpush.html b/webpush/sv_widgets/webpush.html old mode 100644 new mode 100755 diff --git a/webpush/sv_widgets/webpush.js b/webpush/sv_widgets/webpush.js old mode 100644 new mode 100755 diff --git a/webpush/sv_widgets/webpush_serviceworker.js b/webpush/sv_widgets/webpush_serviceworker.js old mode 100644 new mode 100755 diff --git a/webpush/sv_widgets/widget_webpush.config.html b/webpush/sv_widgets/widget_webpush.config.html old mode 100644 new mode 100755 diff --git a/webpush/user_doc.rst b/webpush/user_doc.rst old mode 100644 new mode 100755 diff --git a/webpush/webif/static/img/plugin_logo.png b/webpush/webif/static/img/plugin_logo.png old mode 100644 new mode 100755 diff --git a/webpush/webif/static/img/readme.txt b/webpush/webif/static/img/readme.txt old mode 100644 new mode 100755 diff --git a/webpush/webif/templates/index.html b/webpush/webif/templates/index.html old mode 100644 new mode 100755 diff --git a/webservices/__init__.py b/webservices/__init__.py index e63f16fbd..95290c807 100755 --- a/webservices/__init__.py +++ b/webservices/__init__.py @@ -34,7 +34,7 @@ class WebServices(SmartPlugin): - PLUGIN_VERSION = '1.6.3' + PLUGIN_VERSION = '1.6.4' ALLOWED_FOO_PATHS = ['env.location.moonrise', 'env.location.moonset', 'env.location.sunrise', 'env.location.sunset'] def __init__(self, sh, *args, **kwargs): @@ -263,7 +263,8 @@ def assemble_item_data(self, item, webservices_data='full'): 'eval_trigger': str(item._eval_trigger), 'cycle': str(cycle), 'crontab': str(crontab), - 'autotimer': str(item._autotimer), + 'autotimer_value': str(item._autotimer_value), + 'autotimer_time': str(item._autotimer_time), 'threshold': str(item._threshold), 'config': item_conf_sorted, 'logics': logics, @@ -316,7 +317,7 @@ def itemset(self, set_id=None, mode=None): @cherrypy.tools.json_out() def items(self, item_path=None, value=None, mode=None): """ - Simpole WS functions for item + Simple WS functions for item """ if item_path is None: self.logger.debug(cherrypy.request.method) diff --git a/webservices/plugin.yaml b/webservices/plugin.yaml index fee6ac088..09bd4ba4d 100755 --- a/webservices/plugin.yaml +++ b/webservices/plugin.yaml @@ -13,7 +13,7 @@ plugin: #documentation: http://smarthomeng.de/user/plugins/webservices/user_doc.html support: https://knx-user-forum.de/node/1163886 - version: 1.6.3 # Plugin version + version: 1.6.4 # Plugin version sh_minversion: 1.6 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/webservices/user_doc.rst b/webservices/user_doc.rst index ae6cfb1ad..5bc884e2c 100755 --- a/webservices/user_doc.rst +++ b/webservices/user_doc.rst @@ -3,7 +3,7 @@ .. index:: Webservices .. index:: REST Interface -webservicew +webservices =========== Das Webservices Plugin stellt ein REST basiertes API für SmartHomeNG bereit. diff --git a/withings_health/README.md b/withings_health/README.md deleted file mode 100755 index e5442f99b..000000000 --- a/withings_health/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# Withings Health - -## Description - -This plugin allows to retrieve data from the Withings (former Nokia) Health API (https://developer.withings.com/api). Currently it -only has support for "Withings WS-50 Smart Body Analyzer", a wifi capabale scale. - -Support Thread: https://knx-user-forum.de/forum/supportforen/smarthome-py/1141179-nokia-health-plugin - -## Requirements - -This plugin requires lib withings-api. You can install this lib with: - -```bash -sudo pip3 install withings-api --upgrade -``` - -You have to register at https://account.withings.com/partner/add_oauth2. -The callback URL to enter when registering is shown via the plugin's web interface and can be added as soon as client_id and consumer_secret have been set in etc/plugin.yaml - -The OAuth2 process can then be triggered via the Web Interface of the plugin. Therefore, at least the first four items of the example below need to exist (access_token, token_expiry, token_type, refresh_token). - -In case your SmartHomeNG instance is offline for too long, the tokens expire. You then have to start the OAuth2 process via the Web Interface again. Errors will be logged in this case! - -## Configuration - -### plugin.yaml -```yaml -withings_health: - plugin_name: withings_health - user_id: - client_id: - consumer_secret: - cycle: 300 - instance: withings_health - -``` - -### items.yaml - -Please be aware that there are dependencies for the values. E.g. the body measurement index will only be calculated if a -height exists. From what i saw so far is, that the height is transmitted only one time, the first time the scale -communicates with the Withings (former Nokia) servers. In case you miss it, set the item value manually! - -The first four items are mandatory, as they are needed for OAuth2 data! - -```yaml -body: - - access_token: - type: str - visu_acl: ro - cache: yes - withings_type@withings_health: access_token - - token_expiry: - type: num - visu_acl: ro - cache: yes - withings_type@withings_health: token_expiry - - token_type: - type: str - visu_acl: ro - cache: yes - withings_type@withings_health: token_type - - refresh_token: - type: str - visu_acl: ro - cache: yes - withings_type@withings_health: refresh_token - - weight: - type: num - visu_acl: ro - withings_type@withings_health: weight - - height: - type: num - visu_acl: ro - withings_type@withings_health: height - - bmi: - type: num - visu_acl: ro - withings_type@withings_health: bmi - - bmi_text: - type: str - visu_acl: ro - withings_type@withings_health: bmi_text - - fat_ratio: - type: num - visu_acl: ro - withings_type@withings_health: fat_ratio - - fat_free_mass: - type: num - visu_acl: ro - withings_type@withings_health: fat_free_mass - - fat_mass_weight: - type: num - visu_acl: ro - withings_type@withings_health: fat_mass_weight - - heart_rate: - type: num - visu_acl: ro - withings_type@withings_health: heart_rate -``` - diff --git a/withings_health/__init__.py b/withings_health/__init__.py index d7cb50ec7..b473f0fb2 100755 --- a/withings_health/__init__.py +++ b/withings_health/__init__.py @@ -7,8 +7,7 @@ # https://www.smarthomeNG.de # https://knx-user-forum.de/forum/supportforen/smarthome-py # -# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and -# upwards. +# Plugin for withings health devices # # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -37,7 +36,7 @@ class WithingsHealth(SmartPlugin): - PLUGIN_VERSION = "1.8.2" + PLUGIN_VERSION = "1.8.3" def __init__(self, sh): super().__init__() @@ -129,8 +128,10 @@ def _update(self): userid=self._user_id, client_id=self._client_id, consumer_secret=self._consumer_secret) - - self._client = WithingsApi(self._creds, refresh_cb=self._store_tokens) + try: + self._client = WithingsApi(self._creds, refresh_cb=self._store_tokens) + except Exception as e: + self.logger.error("Client can not be initialized.") else: self.logger.error( "Token is expired, run OAuth2 again from Web Interface (Expiry Date: {}).".format( diff --git a/withings_health/assets/withings_webif.png b/withings_health/assets/withings_webif.png new file mode 100644 index 000000000..44332de59 Binary files /dev/null and b/withings_health/assets/withings_webif.png differ diff --git a/withings_health/plugin.yaml b/withings_health/plugin.yaml index 233d720b4..8ada1217f 100755 --- a/withings_health/plugin.yaml +++ b/withings_health/plugin.yaml @@ -9,10 +9,9 @@ plugin: tester: 'psilo909' state: ready keywords: health - documentation: 'http://smarthomeng.de/user/plugins_doc/config/withings_health.html' support: 'https://knx-user-forum.de/forum/supportforen/smarthome-py/1141179-nokia-health-plugin' - version: 1.8.2 # Plugin version + version: 1.8.3 # Plugin version sh_minversion: 1.7 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance @@ -38,7 +37,6 @@ parameters: consumer_secret: type: str - default: 300 mandatory: True description: de: 'Consumer-Geheimnis von https://account.health.nokia.com/partner/dashboard_oauth2' diff --git a/withings_health/user_doc.rst b/withings_health/user_doc.rst new file mode 100644 index 000000000..5cada8c16 --- /dev/null +++ b/withings_health/user_doc.rst @@ -0,0 +1,152 @@ +.. index:: Plugins; withings_health +.. index:: withings_health + +=============== +withings_health +=============== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Dieses Plugin ermöglicht den Abruf von Daten aus der Withings (ehemals Nokia) +`Health API `_. Derzeit bietet es nur +Unterstützung für den "Withings WS-50 Smart Body Analyzer", eine WLAN-fähige Waage. + + +Vorbereitung +============ + +Dieses Plugin benötigt die withings-api. + +Sie müssen sich unter `Withings Account `_ registrieren und im Dashboard +eine Applikation anlegen. Der Name ist frei wählbar, die (lokale) Callback-URL wird über die Weboberfläche des Plugins angezeigt: http://:/plugin/withings_health. +Wenn Sie sich bei der `Withings App `_ einloggen, kann die achtstellige Zahl +in der URL ausgelesen und in der Pluginkonfiguration als user_id angegeben werden. + +Weiters muss das Plugin struct mittles ``struct: withings_health.body`` eingebunden werden. + +Der OAuth2-Prozess muss dann über die Weboberfläche des +Plugins ausgelöst werden. Daher müssen zumindest die ersten vier Elemente des folgenden Beispiels +vorhanden sein müssen (access_token, token_expiry, token_type, refresh_token). + +Falls Ihre SmartHomeNG-Instanz zu lange offline ist, verfallen die Token. +Sie müssen dann den OAuth2-Prozess über das Webinterface neu starten. In diesem Fall werden Fehler protokolliert! + +Konfiguration +============= + +.. important:: + + Detaillierte Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/withings_health` zu finden. + + +plugin.yaml +----------- + +.. code-block:: yaml + + withings_health: + plugin_name: withings_health + user_id: + client_id: + consumer_secret: + cycle: 300 + instance: withings_health + + +items.yaml +---------- + +Bitte beachten Sie, dass es Abhängigkeiten bei den Werten gibt. So wird z.B. der +Körpermaßindex nur berechnet, wenn eine Körpergröße vorhanden ist. Diese wird nur einmal übertragen, nämlich +wenn die Waage das erste Mal mit den Withings (ehemals Nokia) Servern kommuniziert. +Notfalls muss der Wert manuell hinterlegt werden. + +Die ersten vier Elemente sind obligatorisch, da sie für OAuth2-Daten benötigt werden! +Die sinnvollste Herangehensweise ist hier, das Plugin struct ``body`` komplett zu integrieren. + +.. code-block:: yaml + + body: + + access_token: + type: str + visu_acl: ro + cache: yes + withings_type@withings_health: access_token + + token_expiry: + type: num + visu_acl: ro + cache: yes + withings_type@withings_health: token_expiry + + token_type: + type: str + visu_acl: ro + cache: yes + withings_type@withings_health: token_type + + refresh_token: + type: str + visu_acl: ro + cache: yes + withings_type@withings_health: refresh_token + + weight: + type: num + visu_acl: ro + withings_type@withings_health: weight + + height: + type: num + visu_acl: ro + withings_type@withings_health: height + + bmi: + type: num + visu_acl: ro + withings_type@withings_health: bmi + + bmi_text: + type: str + visu_acl: ro + withings_type@withings_health: bmi_text + + fat_ratio: + type: num + visu_acl: ro + withings_type@withings_health: fat_ratio + + fat_free_mass: + type: num + visu_acl: ro + withings_type@withings_health: fat_free_mass + + fat_mass_weight: + type: num + visu_acl: ro + withings_type@withings_health: fat_mass_weight + + heart_rate: + type: num + visu_acl: ro + withings_type@withings_health: heart_rate + +Web Interface +============= + +Das Webinterface sollte zur erstmaligen Herstellung der Verbindung (Authentifizierung) genutzt werden. + +Außerdem werden die Informationen zu den passenden Items angezeigt. + +.. image:: assets/withings_webif.png + :height: 1656px + :width: 3328px + :scale: 25% + :alt: Web Interface + :align: center diff --git a/withings_health/webif/__init__.py b/withings_health/webif/__init__.py index e8422f71c..48c8ec737 100755 --- a/withings_health/webif/__init__.py +++ b/withings_health/webif/__init__.py @@ -105,11 +105,17 @@ def index(self, reload=None, state=None, code=None, error=None): self.plugin._client = None tmpl = self.tplenv.get_template('index.html') + try: + token_expiry_val = datetime.datetime.fromtimestamp( + self.plugin.get_item('token_expiry').property.value, tz=self.plugin.shtime.tzinfo()) + except Exception as e: + self.logger.error("Please integrate the plugin struct to make the plugin work correctly.") + token_expiry_val = 0 return tmpl.render(plugin_shortname=self.plugin.get_shortname(), plugin_version=self.plugin.get_version(), interface=None, item_count=len(self.plugin.get_items()), plugin_info=self.plugin.get_info(), tabcount=2, callback_url=self.plugin.get_callback_url(), tab1title="Withings Health Items (%s)" % len(self.plugin.get_items()), tab2title="OAuth2 Data", authorize_url=self._auth.get_authorize_url(), - p=self.plugin, token_expiry=datetime.datetime.fromtimestamp(self.plugin.get_item( - 'token_expiry')(), tz=self.plugin.shtime.tzinfo()), now=self.plugin.shtime.now(), code=code, + p=self.plugin, token_expiry=token_expiry_val, + now=self.plugin.shtime.now(), code=code, state=state, reload=reload, language=self.plugin.get_sh().get_defaultlanguage()) diff --git a/withings_health/webif/templates/index.html b/withings_health/webif/templates/index.html index 9c04c73e3..55d181a8d 100755 --- a/withings_health/webif/templates/index.html +++ b/withings_health/webif/templates/index.html @@ -93,7 +93,7 @@
{{ _('Withings Health Items') }} ({{ p.get_items()|length }})
{{ _('OAuth2 Authorization URL') }} - {{ _('Hier zuerst registrieren:') }} https://account.withings.com/partner/add_oauth2
+ {{ _('Hier zuerst registrieren') }}
{{ _('Hier klicken, um OAuth2 Prozess zu starten!') }} @@ -118,4 +118,4 @@
{{ _('Withings Health Items') }} ({{ p.get_items()|length }})
{{ p.get_item('refresh_token')() }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/xiaomi_vac/__init__.py b/xiaomi_vac/__init__.py index 6eff7cb9b..324ed57df 100755 --- a/xiaomi_vac/__init__.py +++ b/xiaomi_vac/__init__.py @@ -51,9 +51,10 @@ class Robvac(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.2.3" + PLUGIN_VERSION = "1.2.4" def __init__(self, smarthome): + super().__init__() self._ip = self.get_parameter_value("ip") self._token = self.get_parameter_value("token") self._cycle = self.get_parameter_value("read_cycle") diff --git a/xiaomi_vac/plugin.yaml b/xiaomi_vac/plugin.yaml index 96a37f94c..27ef287af 100755 --- a/xiaomi_vac/plugin.yaml +++ b/xiaomi_vac/plugin.yaml @@ -22,7 +22,7 @@ plugin: In newer Valetudo versions you find the token in the log as LocalSecret. Convert the token at https://gchq.github.io/CyberChef/#recipe=To_Hex to 32 characters. ' support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1453597-support-thread-f%C3%BCr-xiaomi-saugroboter-plugin - version: 1.2.3 # Plugin version + version: 1.2.4 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin multi_instance: False # plugin supports multi instance classname: Robvac # class containing the plugin diff --git a/xiaomi_vac/requirements.txt b/xiaomi_vac/requirements.txt index bf915d485..b32c8cb9d 100755 --- a/xiaomi_vac/requirements.txt +++ b/xiaomi_vac/requirements.txt @@ -1,2 +1,3 @@ +zeroconf<=0.52.0 python-miio==0.4.6;python_version<'3.6' python-miio>=0.4.7;python_version>='3.6' diff --git a/xiaomi_vac/webif/templates/index.html b/xiaomi_vac/webif/templates/index.html index 9627a2c1e..9ffe59b9e 100755 --- a/xiaomi_vac/webif/templates/index.html +++ b/xiaomi_vac/webif/templates/index.html @@ -25,19 +25,8 @@ + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{_('Broker Host')}}{{ p.broker_config.host }}{{_('Broker Port')}}{{ p.broker_config.port }}
{{_('Benutzer')}}{{ p.broker_config.user }}{{_('Passwort')}} + {% if p.broker_config.password %} + {% for letter in p.broker_config.password %}*{% endfor %} + {% endif %} +
{{_('QoS')}}{{ p.broker_config.qos }}{{ webif_pagelength }}{{_('Zigbee2Mqtt')}}{{ 'GUI' }}
+{% endblock headtable %} + + +{% block buttons %} + +{% endblock %} + +{% set tabcount = 4 %} + +{% if not items %} + {% set start_tab = 2 %} +{% endif %} + + +{% set tab1title = "" ~ plugin_shortname ~ " Items (" ~ item_count ~ ")" %} +{% block bodytab1 %} +
+ + + + + + + + + + + + + {% for item in items %} + {% set item_id = item.id() %} + + + + + + + + + {% endfor %} + +
{{ _('Item') }}{{ _('Typ') }}{{ _('Wert') }}{{ _('Topic') }}{{ _('Letztes Update') }}{{ _('Letzter Change') }}
{{ item_id }}{{ item.property.type }}{{ item()}}{{ p.get_iattr_value(item.conf, 'zigbee2mqtt_topic') }}{{ item.last_update().strftime('%d.%m.%Y %H:%M:%S') }}{{ item.last_change().strftime('%d.%m.%Y %H:%M:%S') }}
+
+{% endblock %} + + +{% set tab2title = "" ~ plugin_shortname ~ " Devices" %} +{% block bodytab2 %} +
+ + + + + + + + + + + + + + + + + {% for device in p.zigbee2mqtt_devices %} + {% if p.zigbee2mqtt_devices[device]['meta'] %} + + + + + + + + + + + + + {% endif %} + {% endfor %} + +
{{ _('#') }}{{ _('Friendy Name') }}{{ _('IEEE Adresse') }}{{ _('Vendor') }}{{ _('Modell') }}{{ _('Description') }}{{ _('ModellID') }}{{ _('Last seen') }}{{ _('LQI') }}{{ _('bereitgestellte Daten') }}
{{ loop.index }}{{ device }}{{ p.zigbee2mqtt_devices[device]['meta']['ieeeAddr'] }}{{ p.zigbee2mqtt_devices[device]['meta']['vendor'] }}{{ p.zigbee2mqtt_devices[device]['meta']['model'] }}{{ p.zigbee2mqtt_devices[device]['meta']['description'] }}{{ p.zigbee2mqtt_devices[device]['meta']['modelID'] }}{{ _('Init...') }}{{ _('Init...') }}{{ _('Init...') }}
+
+{% endblock %} + + +{% set tab3title = "" ~ " Broker Information" %} +{% block bodytab3 %} + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if p.broker_monitoring %} + + + + + + + + + + + + {% endif %} +
{{ 'Broker Version' }}{{ p._broker.version }}{{ connection_result }}
{{ 'Active Clients' }}{{ p._broker.active_clients }}
{{ 'Subscriptions' }}{{ p._broker.subscriptions }}
{{ 'Messages stored' }}{{ p._broker.stored_messages }}
{{ 'Retained Messages' }}{{ p._broker.retained_messages }}
 
{{ _('Laufzeit') }}{{ p.broker_uptime() }}
 
+ +{% if p.broker_monitoring %} + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Message Durchsatz') }}{{ _('letzte Minute') }}{{ _('letzte 5 Min.') }}{{ _('letzte 15 Min.') }}
{{ _('Durchschnittlich Messages je Minute empfangen') }}{{ p._broker.msg_rcv_1min }}     {{ p._broker.msg_rcv_5min }}     {{ p._broker.msg_rcv_15min }}
{{ _('Durchschnittlich Messages je Minute gesendet') }}{{ p._broker.msg_snt_1min }}     {{ p._broker.msg_snt_5min }}     {{ p._broker.msg_snt_15min }}
+{% endif %} +{% endblock %} + + +{% set tab4title = "" ~ plugin_shortname ~ " " ~ _('Maintenance') ~ "" %} +{% block bodytab4 %} +
+ + + + + + + + + + {% for device in p.zigbee2mqtt_devices %} + + + + + + {% endfor %} + +
{{ _('Zigbee Device') }}{{ _('Meta') }}{{ _('Data') }}
{{ device }}{{ p.zigbee2mqtt_devices[device]['meta'] }}{{ p.zigbee2mqtt_devices[device]['data'] }}
+
+
+ + + + + + + + + {% for device in p.zigbee2mqtt_plugin_devices %} + + + + + {% endfor %} + +
{{ _('Plugin Device') }}{{ _('Meta Data') }}
{{ device }}{{ p.zigbee2mqtt_plugin_devices[device]}}
+
+{% endblock %} diff --git a/zigbee2mqtt/plugin.yaml b/zigbee2mqtt/plugin.yaml index 7df947ef6..a7e05769e 100755 --- a/zigbee2mqtt/plugin.yaml +++ b/zigbee2mqtt/plugin.yaml @@ -5,19 +5,19 @@ plugin: description: de: 'Plugin zur Steuerung von Geräten, die mit einem Zigbee Gateway mit der Zigbee2MQTT Firmware versehen sind. Die Kommunikation erfolgt über das MQTT Module von SmartHomeNG.' en: 'Plugin to control devices which are linked to Zigbee Gateway equipped with Zigbee2MQTT firmware. Communication is handled through the MQTT module of SmartHomeNG.' - maintainer: Michael Wenzel - tester: Michael Wenzel # Who tests this plugin? + maintainer: Sebastian Helms + tester: Sebastian Helms # Who tests this plugin? state: develop # change to ready when done with development keywords: iot documentation: '' support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1856775-support-thread-f%C3%BCr-das-zigbee2mqtt-plugin - version: 1.1.2 # Plugin version - sh_minversion: 1.8.2 # minimum shNG version to use this plugin + version: 2.0.0 # Plugin version + sh_minversion: 1.9.5.6 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) py_minversion: 3.8 # minimum Python version to use for this plugin multi_instance: True # plugin supports multi instance - restartable: unknown + restartable: True classname: Zigbee2Mqtt # class containing the plugin parameters: @@ -26,17 +26,17 @@ parameters: type: str default: 'zigbee2mqtt' description: - de: TopicLevel_1 um mit dem ZigBee2MQTT Gateway zu kommunizieren (%topic%) - en: TopicLevel_1 to be used to communicate with the ZigBee2MQTT Gateway (%topic%) + de: Topic, um mit dem ZigBee2MQTT-Gateway zu kommunizieren (%topic%) + en: Base topic for communicating with the ZigBee2MQTT gateway (%topic%) poll_period: type: int - default: 300 + default: 900 valid_min: 10 valid_max: 3600 description: - de: Zeitabstand in Sekunden in dem das Gateway Infos liefer soll - en: Timeperiod in seconds in which the Gateway shall send information + de: Zeitabstand in Sekunden, in dem der Status des Gateway abgefragt wird + en: Interval in seconds to poll the gateway status read_at_init: type: bool @@ -45,74 +45,330 @@ parameters: de: Einlesen aller Werte beim Start en: Read all values at init + suspend_item: + type: str + default: '' + description: + de: Pfad zum Suspend-Item + en: Path to suspend item + + z2m_gui: + type: str + default: 'localhost:8080' + description: + de: Web-Adresse des zigbee2mqtt-Web-GUI (Standard localhost:8080) + en: Web address of the zigbee2mqtt-web-GUI (default localhost:8080) item_attributes: # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) - zigbee2mqtt_topic: + z2m_topic: type: str - mandatory: true description: - de: TopicLevel_2 um mit dem ZigBee2MQTT Gateway zu kommunizieren; entspricht dem Friendly_Name oder Short_Name des Zigbee-Gerätes - en: TopicLevel_2 to be used to communicate with the ZigBee2MQTT Gateway + de: Name des anzusprechenden Gerätes, entweder die Seriennummer ('0xdeadbeef') oder der friendly_name + en: Name of the device to be addressed, either the serial number ('0xdeadbeef') or the friendly_name - zigbee2mqtt_attr: + z2m_attr: type: str - mandatory: true description: - de: "Zu lesendes/schreibendes Attribut des ZigBee2MQTT Devices. Achtung: Nicht jedes Attribut ist auf allen Device-Typen vorhanden." - en: "Attribute of ZigBee2MQTT device that shall be read/written. Note: Not every attribute is available on all device types" - valid_list_ci: - - online - - bridge_permit_join - - bridge_health_check - - bridge_restart - - bridge_networkmap_raw - - device_remove - # - device_ota_update_check - # - device_ota_update_update - - device_configure - - device_options - - device_rename - # - device_bind - # - device_unbind - - device_configure_reporting - - temperature - - humidity - - battery - - battery_low - - linkquality - - action - - vibration - - action_group - - voltage - - angle - - angle_x - - angle_x_absolute - - angle_y - - angle_y_absolute - - angle_z - - strength - - last_seen - - tamper - - sensitivity - - contact - - brightness # Helligkeit in % [0-100] - - color_temp # Farbtemperatur in Kelvin - - state - - hue - - saturation - - color_x - - color_y - - color_mode - - valid_list_description: - de: - - "" - - "Online Status des Zigbee Devices -> bool, r/o" - -item_structs: NONE - # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) + de: "Zu lesendes/schreibendes Attribut des ZigBee2MQTT Devices" + en: "Attribute of ZigBee2MQTT device that shall be read/written" + + z2m_readonly: + type: bool + description: + de: "Attribut wird nur gelesen" + en: "Attribute can only be read" + + z2m_writeonly: + type: bool + description: + de: "Attribut wird nur geschrieben. Wenn z2m_readonly auch gesetzt ist, hat readonly Vorrang" + en: "Attribute can only be written. Will be overridden by z2m_readonly" + + z2m_bool_values: + type: list + description: + de: Ersetzungwerte für bool-Werte (z.B. ['OFF', 'ON']) + en: substitution values for bool items (e.g. ['OFF', 'ON']) + + +item_structs: + dimmer: + action: + type: str + z2m_topic: ..:. + z2m_attr: action + enforce_updates: true + + battery: + type: num + z2m_topic: ..:. + z2m_attr: battery + + duration: + type: num + z2m_topic: ..:. + z2m_attr: action_duration + + last_seen: + type: foo + z2m_topic: ..:. + z2m_attr: last_seen + + linkquality: + type: num + z2m_topic: ..:. + z2m_attr: linkquality + + on: + type: bool + eval: True if sh...action() == 'on_press' else (False if sh...action() in ('on_press_release', 'on_hold_release') else None) + eval_trigger: ..action + + hold: + type: bool + eval: True if sh....action() == 'on_hold' else (False if sh....action() == 'on_hold_release' else None) + eval_trigger: ...action + + off: + type: bool + eval: True if sh...action() == 'off_press' else (False if sh...action() in ('off_press_release', 'off_hold_release') else None) + eval_trigger: ..action + + hold: + type: bool + eval: True if sh....action() == 'off_hold' else (False if sh....action() == 'off_hold_release' else None) + eval_trigger: ...action + + up: + type: bool + eval: True if sh...action() == 'up_press' else (False if sh...action() in ('up_press_release', 'up_hold_release') else None) + eval_trigger: ..action + + hold: + type: bool + eval: True if sh....action() == 'up_hold' else (False if sh....action() == 'up_hold_release' else None) + eval_trigger: ...action + + down: + type: bool + eval: True if sh...action() == 'down_press' else (False if sh...action() in ('down_press_release', 'down_hold_release') else None) + eval_trigger: ..action + + hold: + type: bool + eval: True if sh....action() == 'down_hold' else (False if sh....action() == 'down_hold_release' else None) + eval_trigger: ...action + + light_white_ambient_group: + type: bool + z2m_attr: state + z2m_bool_values: ['OFF', 'ON'] + + brightness: + type: num + z2m_topic: ..:. + z2m_attr: brightness + + percent: + type: num + z2m_topic: ..:. + z2m_attr: brightness_percent + + color_temp: + type: num + remark: 153..454, coolest, cool, neutral, warm, warmest + z2m_topic: ..:. + z2m_attr: color_temp + + kelvin: + type: num + z2m_topic: ..:. + z2m_attr: color_temp_kelvin + + color_mode: + type: str + z2m_topic: ..:. + z2m_attr: color_mode + + scene: + type: str + enforce_updates: true + z2m_topic: ..:. + z2m_attr: scene_recall + z2m_readonly: false + z2m_writeonly: true + + scenes: + type: list + z2m_topic: ..:. + z2m_attr: scenelist + z2m_readonly: true + z2m_writeonly: false + + color_startup: + type: num + remark: 153..454, coolest, cool, neutral, warm, warmest, previous + z2m_topic: ..:. + z2m_attr: color_temp_startup + + last_seen: + type: foo + z2m_topic: ..:. + z2m_attr: last_seen + + light_white_ambient: + struct: zigbee2mqtt.light_white_ambient_group + + linkquality: + type: num + z2m_topic: ..:. + z2m_attr: linkquality + + update: + type: dict + z2m_topic: ..:. + z2m_attr: update + + installed_version: + type: num + eval: sh...()['installed_version'] + eval_trigger: .. + + latest_version: + type: num + eval: sh...()['latest_version'] + eval_trigger: .. + + state: + type: str + eval: sh...()['state'] + eval_trigger: .. + + light_rgb_group: + struct: zigbee2mqtt.light_white_ambient_group + + color: + type: dict + z2m_topic: ..:. + z2m_attr: color + + r: + type: num + z2m_topic: ..:. + z2m_attr: color_r + + g: + type: num + z2m_topic: ..:. + z2m_attr: color_g + + b: + type: num + z2m_topic: ..:. + z2m_attr: color_b + + rgb: + type: str + z2m_topic: ..:. + z2m_attr: color_rgb + + color_startup: + type: dict + z2m_topic: ..:. + z2m_attr: color_temp_startup + + light_rgb: + struct: zigbee2mqtt.light_rgb_group + + linkquality: + type: num + z2m_topic: ..:. + z2m_attr: linkquality + + update: + type: dict + z2m_topic: ..:. + z2m_attr: update + + installed_version: + type: num + eval: sh...()['installed_version'] + eval_trigger: .. + + latest_version: + type: num + eval: sh...()['latest_version'] + eval_trigger: .. + + state: + type: str + eval: sh...()['state'] + eval_trigger: .. + + bridge: + z2m_topic: bridge + + online: + type: bool + z2m_topic: ..:. + z2m_attr: online + z2m_readonly: true + + config: + type: dict + z2m_topic: ..:. + z2m_attr: config + z2m_readonly: true + + coordinator: + type: dict + z2m_topic: ..:. + z2m_attr: coordinator + z2m_readonly: true + + config_schema: + type: dict + z2m_topic: ..:. + z2m_attr: config_schema + z2m_readonly: true + + network: + type: dict + z2m_topic: ..:. + z2m_attr: network + z2m_readonly: true + + permit_join: + type: bool + z2m_topic: ..:. + z2m_attr: permit_join + z2m_bool_values: [false, true] + + restart: + type: bool + z2m_topic: ..:. + z2m_attr: restart + z2m_bool_values: [false, true] + z2m_writeonly: true + autotimer: 1=False + + version: + type: str + z2m_topic: ..:. + z2m_attr: version + + devices: + type: list + z2m_topic: ..:. + z2m_attr: devices + + groups: + type: list + z2m_topic: ..:. + z2m_attr: groups + plugin_functions: NONE # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) diff --git a/zigbee2mqtt/rgbxy.py b/zigbee2mqtt/rgbxy.py new file mode 100755 index 000000000..9022168f1 --- /dev/null +++ b/zigbee2mqtt/rgbxy.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +""" +Library for RGB / CIE1931 "x, y" coversion. +Based on Philips implementation guidance: +http://www.developers.meethue.com/documentation/color-conversions-rgb-xy +Copyright (c) 2016 Benjamin Knight / MIT License. + +modifications by SH for zigbee2mqtt plugin in smarthomeNG +""" +import math +import random +from collections import namedtuple + +__version__ = '0.5.1a' + +# Represents a CIE 1931 XY coordinate pair. +XYPoint = namedtuple('XYPoint', ['x', 'y']) + +# LivingColors Iris, Bloom, Aura, LightStrips +GamutA = ( + XYPoint(0.704, 0.296), + XYPoint(0.2151, 0.7106), + XYPoint(0.138, 0.08), +) + +# Hue A19 bulbs +GamutB = ( + XYPoint(0.675, 0.322), + XYPoint(0.4091, 0.518), + XYPoint(0.167, 0.04), +) + +# Hue BR30, A19 (Gen 3), Hue Go, LightStrips plus +GamutC = ( + XYPoint(0.692, 0.308), + XYPoint(0.17, 0.7), + XYPoint(0.153, 0.048), +) + + +def get_light_gamut(modelId): + """Gets the correct color gamut for the provided model id. + Docs: https://developers.meethue.com/develop/hue-api/supported-devices/ + """ + if modelId in ('LST001', 'LLC005', 'LLC006', 'LLC007', 'LLC010', 'LLC011', 'LLC012', 'LLC013', 'LLC014'): + return GamutA + elif modelId in ('LCT001', 'LCT007', 'LCT002', 'LCT003', 'LLM001'): + return GamutB + elif modelId in ('LCT010', 'LCT011', 'LCT012', 'LCT014', 'LCT015', 'LCT016', 'LLC020', 'LST002'): + return GamutC + else: + raise ValueError + return None + + +class ColorHelper: + + def __init__(self, gamut=GamutB): + self.Red = gamut[0] + self.Lime = gamut[1] + self.Blue = gamut[2] + + def hex_to_red(self, hex): + """Parses a valid hex color string and returns the Red RGB integer value.""" + return int(hex[0:2], 16) + + def hex_to_green(self, hex): + """Parses a valid hex color string and returns the Green RGB integer value.""" + return int(hex[2:4], 16) + + def hex_to_blue(self, hex): + """Parses a valid hex color string and returns the Blue RGB integer value.""" + return int(hex[4:6], 16) + + def hex_to_rgb(self, h): + """Converts a valid hex color string to an RGB array.""" + rgb = (self.hex_to_red(h), self.hex_to_green(h), self.hex_to_blue(h)) + return rgb + + def rgb_to_hex(self, r, g, b): + """Converts RGB to hex.""" + return '%02x%02x%02x' % (r, g, b) + + def random_rgb_value(self): + """Return a random Integer in the range of 0 to 255, representing an RGB color value.""" + return random.randrange(0, 256) + + def cross_product(self, p1, p2): + """Returns the cross product of two XYPoints.""" + return (p1.x * p2.y - p1.y * p2.x) + + def check_point_in_lamps_reach(self, p): + """Check if the provided XYPoint can be recreated by a Hue lamp.""" + v1 = XYPoint(self.Lime.x - self.Red.x, self.Lime.y - self.Red.y) + v2 = XYPoint(self.Blue.x - self.Red.x, self.Blue.y - self.Red.y) + + q = XYPoint(p.x - self.Red.x, p.y - self.Red.y) + s = self.cross_product(q, v2) / self.cross_product(v1, v2) + t = self.cross_product(v1, q) / self.cross_product(v1, v2) + + return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0) + + def get_closest_point_to_line(self, A, B, P): + """Find the closest point on a line. This point will be reproducible by a Hue lamp.""" + AP = XYPoint(P.x - A.x, P.y - A.y) + AB = XYPoint(B.x - A.x, B.y - A.y) + ab2 = AB.x * AB.x + AB.y * AB.y + ap_ab = AP.x * AB.x + AP.y * AB.y + t = ap_ab / ab2 + + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + + return XYPoint(A.x + AB.x * t, A.y + AB.y * t) + + def get_closest_point_to_point(self, xy_point): + # Color is unreproducible, find the closest point on each line in the CIE 1931 'triangle'. + pAB = self.get_closest_point_to_line(self.Red, self.Lime, xy_point) + pAC = self.get_closest_point_to_line(self.Blue, self.Red, xy_point) + pBC = self.get_closest_point_to_line(self.Lime, self.Blue, xy_point) + + # Get the distances per point and see which point is closer to our Point. + dAB = self.get_distance_between_two_points(xy_point, pAB) + dAC = self.get_distance_between_two_points(xy_point, pAC) + dBC = self.get_distance_between_two_points(xy_point, pBC) + + lowest = dAB + closest_point = pAB + + if (dAC < lowest): + lowest = dAC + closest_point = pAC + + if (dBC < lowest): + lowest = dBC + closest_point = pBC + + # Change the xy value to a value which is within the reach of the lamp. + cx = closest_point.x + cy = closest_point.y + + return XYPoint(cx, cy) + + def get_distance_between_two_points(self, one, two): + """Returns the distance between two XYPoints.""" + dx = one.x - two.x + dy = one.y - two.y + return math.sqrt(dx * dx + dy * dy) + + def get_xy_point_from_rgb(self, red_i, green_i, blue_i): + """Returns an XYPoint object containing the closest available CIE 1931 x, y coordinates + based on the RGB input values.""" + + xy_point, _ = self.get_xy_point_from_rgb(red_i, green_i, blue_i) + return xy_point + + def get_xy_bri_from_rgb(self, red_i, green_i, blue_i): + """Returns an XYPoint object containing the closest available CIE 1931 x, y coordinates + based on the RGB input values.""" + + red = red_i / 255.0 + green = green_i / 255.0 + blue = blue_i / 255.0 + + r = ((red + 0.055) / (1.0 + 0.055))**2.4 if (red > 0.04045) else (red / 12.92) + g = ((green + 0.055) / (1.0 + 0.055))**2.4 if (green > 0.04045) else (green / 12.92) + b = ((blue + 0.055) / (1.0 + 0.055))**2.4 if (blue > 0.04045) else (blue / 12.92) + + X = r * 0.664511 + g * 0.154324 + b * 0.162028 + Y = r * 0.283881 + g * 0.668433 + b * 0.047685 + Z = r * 0.000088 + g * 0.072310 + b * 0.986039 + + try: + cx = X / (X + Y + Z) + cy = Y / (X + Y + Z) + except ZeroDivisionError: + cx = 0.167 + cy = 0.04 + Y = 0 + + # Check if the given XY value is within the colourreach of our lamps. + xy_point = XYPoint(cx, cy) + in_reach = self.check_point_in_lamps_reach(xy_point) + + if not in_reach: + xy_point = self.get_closest_point_to_point(xy_point) + + return xy_point, Y + + def get_rgb_from_xy_and_brightness(self, x, y, bri=1): + """Inverse of `get_xy_point_from_rgb`. Returns (r, g, b) for given x, y values. + Implementation of the instructions found on the Philips Hue iOS SDK docs: http://goo.gl/kWKXKl + """ + # The xy to color conversion is almost the same, but in reverse order. + # Check if the xy value is within the color gamut of the lamp. + # If not continue with step 2, otherwise step 3. + # We do this to calculate the most accurate color the given light can actually do. + xy_point = XYPoint(x, y) + + if not self.check_point_in_lamps_reach(xy_point): + # Calculate the closest point on the color gamut triangle + # and use that as xy value See step 6 of color to xy. + xy_point = self.get_closest_point_to_point(xy_point) + + # Calculate XYZ values Convert using the following formulas: + Y = bri + X = (Y / xy_point.y) * xy_point.x + Z = (Y / xy_point.y) * (1 - xy_point.x - xy_point.y) + + # Convert to RGB using Wide RGB D65 conversion + r = X * 1.656492 - Y * 0.354851 - Z * 0.255038 + g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152 + b = X * 0.051713 - Y * 0.121364 + Z * 1.011530 + + # Apply reverse gamma correction + r, g, b = map( + lambda x: (12.92 * x) if (x <= 0.0031308) else ((1.0 + 0.055) * pow(x, (1.0 / 2.4)) - 0.055), + [r, g, b] + ) + + # Bring all negative components to zero + r, g, b = map(lambda x: max(0, x), [r, g, b]) + + # If one component is greater than 1, weight components by that value. + max_component = max(r, g, b) + if max_component > 1: + r, g, b = map(lambda x: x / max_component, [r, g, b]) + + r, g, b = map(lambda x: int(x * 255), [r, g, b]) + + # Convert the RGB values to your color object The rgb values from the above formulas are between 0.0 and 1.0. + return (r, g, b) + + +class Converter: + + def __init__(self, gamut=GamutB): + self.color = ColorHelper(gamut) + + def hex_to_xy(self, h): + """Converts hexadecimal colors represented as a String to approximate CIE + 1931 x and y coordinates. + """ + rgb = self.color.hex_to_rgb(h) + return self.rgb_to_xy(rgb[0], rgb[1], rgb[2]) + + def rgb_to_xy(self, red, green, blue): + """Converts red, green and blue integer values to approximate CIE 1931 + x and y coordinates. + """ + point = self.color.get_xy_point_from_rgb(red, green, blue) + return (point.x, point.y) + + def rgb_to_xyb(self, red, green, blue): + """Converts red, green and blue integer values to approximate CIE 1931 + x and y coordinates. + """ + point, bri = self.color.get_xy_bri_from_rgb(red, green, blue) + return (point.x, point.y, bri) + + def xy_to_hex(self, x, y, bri=1): + """Converts CIE 1931 x and y coordinates and brightness value from 0 to 1 + to a CSS hex color.""" + r, g, b = self.color.get_rgb_from_xy_and_brightness(x, y, bri) + return self.color.rgb_to_hex(r, g, b) + + def xy_to_rgb(self, x, y, bri=1): + """Converts CIE 1931 x and y coordinates and brightness value from 0 to 1 + to a CSS hex color.""" + r, g, b = self.color.get_rgb_from_xy_and_brightness(x, y, bri) + return (r, g, b) + + def get_random_xy_color(self): + """Returns the approximate CIE 1931 x,y coordinates represented by the + supplied hexColor parameter, or of a random color if the parameter + is not passed.""" + r = self.color.random_rgb_value() + g = self.color.random_rgb_value() + b = self.color.random_rgb_value() + return self.rgb_to_xy(r, g, b) \ No newline at end of file diff --git a/zigbee2mqtt/user_doc.rst b/zigbee2mqtt/user_doc.rst index 43c5e44ea..e5bff39c6 100755 --- a/zigbee2mqtt/user_doc.rst +++ b/zigbee2mqtt/user_doc.rst @@ -2,133 +2,109 @@ zigbee2mqtt =========== -Das Plugin dienst zur Steuerung von Zigbee Devices via Zigbee2MQTT über MQTT. Notwendige Voraussetzung ist eine -funktionsfähige und laufende Installation von Zigbee2Mqtt. Die Installation, Konfiguration und der Betrieb ist hier -beschrieben: https://www.zigbee2mqtt.io/ -Dort findet man ebenfalls die unterstützten Zigbee Geräte. +Das Plugin dienst zur Steuerung von Zigbee Devices via Zigbee2MQTT über MQTT. +Notwendige Voraussetzung ist eine funktionsfähige und laufende Installation von +Zigbee2Mqtt. Dessen Installation, Konfiguration und der Betrieb ist hier +beschrieben: https://www.zigbee2mqtt.io/ Dort findet man ebenfalls die +unterstützten Zigbee Geräte. .. attention:: - Das Plugin kommuniziert über MQTT und benötigt das mqtt Modul, welches die Kommunikation mit dem MQTT Broker - durchführt. Dieses Modul muß geladen und konfiguriert sein, damit das Plugin funktioniert. + Das Plugin kommuniziert über MQTT und benötigt das mqtt-Modul, welches die + Kommunikation mit dem MQTT Broker durchführt. Dieses Modul muss geladen und + konfiguriert sein, damit das Plugin funktioniert. Getestet ist das Plugin mit folgenden Zigbee-Geräten: -- SONOFF SNZB-02 -- IKEA TRADFRI E1766 -- Aqara DJT11LM -- TuYa TS0210 -- Aqara Opple 3fach Taster +- Philips Hue white ambiance E27 800lm with Bluetooth +- Philips Hue white ambiance E26/E27 +- IKEA Tradfri LED1924G9 +- IKEA Tradfri LED1949C5 +- Philips Hue dimmer switch +Grundsätzlich kann jedes Gerät angebunden werden; für eine sinnvolle +Verarbeitung von Werten sollte ein entsprechendes struct erstellt werden, +ggfs. kann noch erweiterte Funktionalität mit zusätzlichem Code bereitgestellt +werden. + +.. hint:: + + Im Rahmen der Umstellung des Plugins auf Version 2 wurden die Attribute + umbenannt, d.h. von "zigbee2mqtt_foo" in "z2m_foo" geändert. + Das macht die Konfigurationsdateien übersichtlicher und einfacher zu + schreiben. Bestehende Dateien müssen entsprechend angepasst werden. Konfiguration ============= -Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/zigbee2mqtt` beschrieben. +Die Informationen zur Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/zigbee2mqtt` beschrieben. Nachfolgend noch einige Zusatzinformationen. -Konfiguration des Plugins -------------------------- +Konfiguration von Items +----------------------- -Die Konfigruation des Plugins erfolgt über das Admin-Interface. Dafür stehen die folgenden Einstellungen zur Verfügung: +Für die Nutzung eines Zigbee Devices können - sofern vorhanden - die +mitgelieferten structs verwendet werden: -- `base_topic`: MQTT TopicLevel_1, um mit dem ZigBee2MQTT Gateway zu kommunizieren (%topic%) -- `poll_period`: Zeitabstand in Sekunden in dem das Gateway Infos liefer soll +.. code-block:: yaml + lampe1: + struct: zigbee2mqtt.light_white_ambient + z2m_topic: friendlyname1 + + lampe2: + struct: zigbee2mqtt.light_rgb + z2m_topic: friendlyname2 -Konfiguration von Items ------------------------ -Für die Nutzung eines Zigbee Devices müssen in dem entsprechenden Item die zwei Attribute ``zigbee2mqtt_topic`` und -``zigbee2mqtt_attr`` konfiguriert werden, wie im folgenden Beispiel gezeigt: +Sofern für das entsprechende Gerät kein struct vorhanden ist, können einzelne +Datenpunkte des Geräts auch direkt angesprochen werden: .. code-block:: yaml sensor: temp: type: num - zigbee2mqtt_topic: SNZB02_01 - zigbee2mqtt_attr: temperature + z2m_topic: SNZB02_01 + z2m_attr: temperature hum: type: num - zigbee2mqtt_topic: SNZB02_01 - zigbee2mqtt_attr: humidity + z2m_topic: SNZB02_01 + z2m_attr: humidity + +Dabei entspricht das Attribute ``z2m_topic`` dem Zigbee ``Friendly Name`` des +Device bzw. dem MQTT Topic Level_2, um mit dem ZigBee2MQTT Gateway zu +kommunizieren. -Dabei entspricht das Attribute ``zigbee2mqtt_topic`` dem Zigbee ``Friendly Name`` des Device bzw. dem MQTT Topic Level_2, um mit dem ZigBee2MQTT Gateway zu kommunizieren. +Das Attribut ``z2m_attr`` entspricht dem jeweiligen Tag aus der Payload, der +verwendet werden soll. Welche Tags beim jeweiligen Device verfügbar sind, kann +man im WebIF des Plugins sehen. -Das Attribut ``zigbee2mqtt_attr`` entspricht dem jeweiligen Tag aus der Payload, der verwendet werden soll. Welche Tags beim jeweiligen Device verfügbar sind, kann man im WebIF des Pluigns sehen. +Die Informationen des Zigbee2MQTT-Gateways werden unter dem z2m_topic +(Gerätenamen) ``bridge`` bereitgestellt. -Die folgenden Tags des Attributes ``zigbee2mqtt_attr`` sind definiert und werden vom Plugin unterstützt: +Die folgenden Tags des Attributes ``z2m_attr`` sind definiert und werden vom +Plugin unterstützt: - online -- bridge_permit_join -- bridge_health_check -- bridge_restart -- bridge_networkmap_raw -- device_remove -- device_configure -- device_options -- device_rename -- device_configure_reporting -- temperature -- humidity +- permit_join (bridge) +- health_check (bridge) +- restart (bridge) +- networkmap_raw (bridge) +- device_remove (bridge) +- device_configure (bridge) +- device_options (bridge) +- device_rename (bridge) +- device_configure_reporting (bridge) - battery -- battery_low - linkquality - action -- vibration -- action_group -- voltage -- angle -- angle_x -- angle_x_absolute -- angle_y -- angle_y_absolute -- angle_z -- strength - last_seen -- tamper -- sensitivity -- contact - - -Web Interface des Plugins -========================= - -Zigbee2Mqtt Items ------------------ - -Das Webinterface zeigt die Items an, für die ein Zigbee2Mqtt Device konfiguriert ist. - -.. image:: user_doc/assets/webif_tab1.jpg - :class: screenshot - - -Zigbee2Mqtt Devices -------------------- - -Das Webinterface zeigt Informationen zu den konfigurierten Zigbee2Mqtt Devices an, sowie etwa hinzugekommen Devices die -in SmartHomeNG noch nicht konfiguriert (mit einem Item vebunden) sind. - -.. image:: user_doc/assets/webif_tab2.jpg - :class: screenshot - - -Zigbee2Mqtt Bridge Info ------------------------ - -Das Webinterface zeigt detaillierte Informationen der Zigbee2Mqtt Bridge zu jedem verbundenen Device an. - -.. image:: user_doc/assets/webif_tab3.jpg - :class: screenshot - -Broker Information ------------------- +Weitere Tags werden abhängig vom Gerät unterstützt. In den meisten Fällen können +auch unbekannte Tags bei direkter Konfiguration verwendet werden. -Das Webinterface zeigt Informationen zum genutzten MQTT Broker an. -.. image:: user_doc/assets/webif_tab6.jpg - :class: screenshot diff --git a/zigbee2mqtt/user_doc/assets/webif_tab1.jpg b/zigbee2mqtt/user_doc/assets/webif_tab1.jpg deleted file mode 100755 index a0b826a16..000000000 Binary files a/zigbee2mqtt/user_doc/assets/webif_tab1.jpg and /dev/null differ diff --git a/zigbee2mqtt/user_doc/assets/webif_tab2.jpg b/zigbee2mqtt/user_doc/assets/webif_tab2.jpg deleted file mode 100755 index 89e748cd8..000000000 Binary files a/zigbee2mqtt/user_doc/assets/webif_tab2.jpg and /dev/null differ diff --git a/zigbee2mqtt/user_doc/assets/webif_tab3.jpg b/zigbee2mqtt/user_doc/assets/webif_tab3.jpg deleted file mode 100755 index fdcd11cef..000000000 Binary files a/zigbee2mqtt/user_doc/assets/webif_tab3.jpg and /dev/null differ diff --git a/zigbee2mqtt/user_doc/assets/webif_tab4.jpg b/zigbee2mqtt/user_doc/assets/webif_tab4.jpg deleted file mode 100755 index 46976bf5f..000000000 Binary files a/zigbee2mqtt/user_doc/assets/webif_tab4.jpg and /dev/null differ diff --git a/zigbee2mqtt/user_doc/assets/webif_tab5.jpg b/zigbee2mqtt/user_doc/assets/webif_tab5.jpg deleted file mode 100755 index 61d8af207..000000000 Binary files a/zigbee2mqtt/user_doc/assets/webif_tab5.jpg and /dev/null differ diff --git a/zigbee2mqtt/user_doc/assets/webif_tab6.jpg b/zigbee2mqtt/user_doc/assets/webif_tab6.jpg deleted file mode 100755 index b2b405b3e..000000000 Binary files a/zigbee2mqtt/user_doc/assets/webif_tab6.jpg and /dev/null differ diff --git a/zigbee2mqtt/webif/__init__.py b/zigbee2mqtt/webif/__init__.py index 03f4f45f8..47a0e4122 100755 --- a/zigbee2mqtt/webif/__init__.py +++ b/zigbee2mqtt/webif/__init__.py @@ -29,7 +29,6 @@ import cherrypy from lib.item import Items from lib.model.smartplugin import SmartPluginWebIf -from jinja2 import Environment, FileSystemLoader class WebInterface(SmartPluginWebIf): @@ -72,8 +71,8 @@ def index(self, reload=None, action=None): return tmpl.render(plugin_shortname=self.plugin.get_shortname(), plugin_version=self.plugin.get_version(), plugin_info=self.plugin.get_info(), - items=sorted(self.plugin.zigbee2mqtt_items, key=lambda k: str.lower(k['_path'])), - item_count=len(self.plugin.zigbee2mqtt_items), + items=sorted([i for i in self.plugin.get_item_list()], key=lambda x: x.path().lower()), + item_count=len(self.plugin.get_item_list()), p=self.plugin, webif_pagelength=pagelength, ) @@ -96,23 +95,24 @@ def get_data_html(self, dataSet=None): data['broker_uptime'] = self.plugin.broker_uptime() data['item_values'] = {} - for item in self.plugin.zigbee2mqtt_items: + for item in self.plugin.get_item_list(): data['item_values'][item.id()] = {} data['item_values'][item.id()]['value'] = item.property.value data['item_values'][item.id()]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') data['item_values'][item.id()]['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') data['device_values'] = {} - for device in self.plugin.zigbee2mqtt_devices: - data['device_values'][device] = {} - if 'data' in self.plugin.zigbee2mqtt_devices[device]: - data['device_values'][device]['lqi'] = str(self.plugin.zigbee2mqtt_devices[device]['data'].get('linkquality', '-')) - data['device_values'][device]['data'] = ", ".join(list(self.plugin.zigbee2mqtt_devices[device]['data'].keys())) + for device in self.plugin._devices: + if 'data' in self.plugin._devices[device]: + data['device_values'][device] = { + 'lqi': str(self.plugin._devices[device]['data'].get('linkquality', '-')), + 'data': ", ".join(list(self.plugin._devices[device]['data'].keys())) + } else: - data['device_values'][device]['lqi'] = '-' - data['device_values'][device]['data'] = '-' - if 'meta' in self.plugin.zigbee2mqtt_devices[device]: - last_seen = self.plugin.zigbee2mqtt_devices[device]['meta'].get('lastSeen', None) + data['device_values'][device] = {'lqi': '-', 'data': '-'} + + if 'meta' in self.plugin._devices[device]: + last_seen = self.plugin._devices[device]['meta'].get('lastSeen', None) if last_seen: data['device_values'][device]['last_seen'] = last_seen.strftime('%d.%m.%Y %H:%M:%S') else: diff --git a/zigbee2mqtt/webif/templates/index.html b/zigbee2mqtt/webif/templates/index.html index f819f7e1f..74d59e9e9 100755 --- a/zigbee2mqtt/webif/templates/index.html +++ b/zigbee2mqtt/webif/templates/index.html @@ -168,7 +168,7 @@ {{ p.broker_config.qos }} {{ webif_pagelength }} {{_('Zigbee2Mqtt')}} - {{ 'GUI' }} + {{ 'GUI' }} @@ -193,6 +193,7 @@ + @@ -205,7 +206,8 @@ {% for item in items %} {% set item_id = item.id() %} - + + @@ -238,16 +240,16 @@ - {% for device in p.zigbee2mqtt_devices %} - {% if p.zigbee2mqtt_devices[device]['meta'] %} + {% for device in p._devices %} + {% if p._devices[device]['meta'] %} - - - - - + + + + +
{{ _('Item') }} {{ _('Typ') }} {{ _('Wert') }}
{{ item_id }}{{ item.property.path }} {{ item.property.type }} {{ item()}} {{ p.get_iattr_value(item.conf, 'zigbee2mqtt_topic') }}
{{ loop.index }} {{ device }}{{ p.zigbee2mqtt_devices[device]['meta']['ieeeAddr'] }}{{ p.zigbee2mqtt_devices[device]['meta']['vendor'] }}{{ p.zigbee2mqtt_devices[device]['meta']['model'] }}{{ p.zigbee2mqtt_devices[device]['meta']['description'] }}{{ p.zigbee2mqtt_devices[device]['meta']['modelID'] }}{{ p._devices[device]['meta']['ieee_address'] }}{{ p._devices[device]['meta']['manufacturer'] }}{{ p._devices[device]['meta']['model'] }}{{ p._devices[device]['meta']['description'] }}{{ p._devices[device]['meta']['model_id'] }} {{ _('Init...') }} {{ _('Init...') }} {{ _('Init...') }}