diff --git a/CODEOWNERS b/CODEOWNERS
index 44604919af1a8..2eebae4bc7bcf 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -12,6 +12,7 @@
/bundles/org.openhab.automation.jythonscripting/ @HolgerHees
/bundles/org.openhab.automation.pidcontroller/ @fwolter
/bundles/org.openhab.automation.pwm/ @fwolter
+/bundles/org.openhab.automation.pythonscripting/ @HolgerHees
/bundles/org.openhab.binding.adorne/ @theiding
/bundles/org.openhab.binding.ahawastecollection/ @soenkekueper
/bundles/org.openhab.binding.airgradient/ @austvik
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 9952c007b0d73..923c3d5903f98 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -51,6 +51,11 @@
org.openhab.automation.pwm
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.automation.pythonscripting
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.adorne
diff --git a/bundles/org.openhab.automation.pythonscripting/NOTICE b/bundles/org.openhab.automation.pythonscripting/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.automation.pythonscripting/README.md b/bundles/org.openhab.automation.pythonscripting/README.md
new file mode 100644
index 0000000000000..aad2930b3a38a
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/README.md
@@ -0,0 +1,495 @@
+# Python 3 Binding
+
+This add-on provides support for Python 3 that can be used as a scripting language within automation rules.
+It is based on [GraalPy](https://www.graalvm.org/python/) from the [GraalVM project](https://www.graalvm.org/).
+
+Also included is [openhab-python](https://github.com/HolgerHees/openhab-python), a python library to support automation in openHAB. It provides convenient access to common core openHAB functions that make the full range of Java APIs easily accessible and usable
+
+[[toc]]
+
+## Creating Python Scripts
+
+When this add-on is installed, you can select Python3 as a scripting language when creating a script action within the rule editor of the UI.
+
+Alternatively, you can create scripts in the `automation/python` configuration directory. If you create an empty file called `test.py`, you will see a log line with information similar to:
+
+```text
+... [INFO ] [ort.loader.AbstractScriptFileWatcher]] - (Re-)Loading script '/openhab/conf/automation/python/test.py'
+```
+
+To enable debug logging, use the [console logging]({{base}}/administration/logging.html) commands to
+enable debug logging for the automation functionality:
+
+```text
+log:set DEBUG org.openhab.automation.pythonscripting
+```
+
+## Scripting Basics
+
+Lets start with a simple script
+
+```python
+from openhab import rule
+from openhab.triggers import GenericCronTrigger
+
+@rule( triggers = [ GenericCronTrigger("*/5 * * * * ?") ] )
+class Test:
+ def execute(self, module, input):
+ self.logger.info("Rule was triggered")
+```
+
+or another one, using the [scope module](#module-scope)
+
+```python
+from openhab import rule
+from openhab.triggers import ItemCommandTrigger
+
+import scope
+
+@rule( triggers = [ ItemCommandTrigger("Item1", scope.ON) ] )
+class Test:
+ def execute(self, module, input):
+ self.logger.info("Rule was triggered")
+```
+
+::: tip Note
+By default, the scope, Registry and logger is automatically imported for UI based rules
+:::
+
+
+## `PY3` Transformation
+
+openHAB provides several [data transformation services](https://www.openhab.org/addons/#transform) as well as the script transformations, that are available from the framework and need no additional installation.
+It allows transforming values using any of the available scripting languages, which means Python Scripting is supported as well.
+See the [transformation docs](https://openhab.org/docs/configuration/transformations.html#script-transformation) for more general information on the usage of script transformations.
+
+Use Python Scripting as script transformation by:
+
+1. Creating a script in the `$OPENHAB_CONF/transform` folder with the `.py` extension.
+ The script should take one argument `input` and return a value that supports `toString()` or `null`:
+
+ ```python
+ def calc(input):
+ # Do some data transformation here, e.g.
+ return "String has" + data.length + "characters";
+ calc(input)
+ ```
+
+2. Using `PY3(.py):%s` as Item state transformation.
+3. Passing parameters is also possible by using a URL like syntax: `PY3(.py?arg=value)`.
+ Parameters are injected into the script and can be referenced like variables.
+
+Simple transformations can aso be given as an inline script: `PY3(|...)`, e.g. `PY3(|"String has " + input.length + "characters")`.
+It should start with the `|` character, quotes within the script may need to be escaped with a backslash `\` when used with another quoted string as in text configurations.
+
+::: tip Note
+By default, the scope, Registry and logger is automatically imported for `PY3` Transformation scripts
+:::
+
+## Examples
+
+### Simple rule
+
+```python
+from openhab import rule, Registry
+from openhab.triggers import GenericCronTrigger, ItemStateUpdateTrigger, ItemCommandTrigger, EphemerisCondition, when, onlyif
+
+import scope
+
+@rule()
+@when("Time cron */5 * * * * ?")
+def test1(module, input):
+ test1.logger.info("Rule 1 was triggered")
+
+@rule()
+@when("Item Item1 received command")
+@when("Item Item1 received update")
+@onlyif("Today is a holiday")
+def test2(module, input):
+ Registry.getItem("Item2").sendCommand(scope.ON)
+
+@rule(
+ triggers = [ GenericCronTrigger("*/5 * * * * ?") ]
+)
+class Test3:
+ def execute(self, module, input):
+ self.logger.info("Rule 3 was triggered")
+
+@rule(
+ triggers = [
+ ItemStateUpdateTrigger("Item1"),
+ ItemCommandTrigger("Item1", scope.ON)
+ ],
+ conditions = [
+ EphemerisCondition("notholiday")
+ ]
+)
+class Test4:
+ def execute(self, module, input):
+ if Registry.getItem("Item2").postUpdateIfDifferent(scope.OFF):
+ self.logger.info("Item2 was updated")
+```
+
+### Query thing status info
+
+```python
+from openhab import logger, Registry
+
+info = Registry.getThing("zwave:serial_zstick:512").getStatusInfo()
+logger.info(info.toString());
+```
+
+### Query historic item
+
+```python
+from openhab import logger, Registry
+from datetime import datetime
+
+historicItem = Registry.getItem("Item1").getPersistence().persistedState( datetime.now().astimezone() )
+logger.info( historicItem.getState().toString() );
+
+historicItem = Registry.getItem("Item2").getPersistence("jdbc").persistedState( datetime.now().astimezone() )
+logger.info( historicItem.getState().toString() );
+```
+
+### Using scope
+
+Simple usage of jsr223 scope objects
+
+```python
+from openhab import Registry
+
+from scope import ON
+
+Registry.getItem("Item1").sendCommand(ON)
+```
+
+### Logging
+
+There are 3 ways of logging.
+
+1. using normal print statements. In this case they are redirected to the default openhab logfile and marked with log level INFO or ERROR
+
+```python
+import sys
+
+print("log message")
+
+print("error message", file=sys.stderr)
+
+```
+
+2. using the logging module. Here you get a logging object, already initialized with the prefix "org.openhab.core.automation.pythonscripting"
+
+```python
+from openhab import logging
+
+logging.info("info message")
+
+logging.error("error message")
+```
+
+3. using the rule based logging module. Here you get a logging object, already initialized with the prefix "org.openhab.core.automation.pythonscripting."
+
+```python
+from openhab import rule
+from openhab.triggers import GenericCronTrigger
+
+@rule( triggers = [ GenericCronTrigger("*/5 * * * * ?") ] )
+class Test:
+ def execute(self, module, input):
+ self.logger.info("Rule was triggered")
+```
+
+## Decorators
+
+### decorator @rule
+
+the decorator will register the decorated class as a rule. It will wrap and extend the class with the following functionalities
+
+- Register the class or function as a rule
+- If name is not provided, a fallback name in the form "{filename}.{function_or_classname}" is created
+- Triggers can be added with argument "triggers", with a function called "buildTriggers" (only classes) or with an [@when decorator](#decorator-when)
+- Conditions can be added with argument "conditions", with a function called "buildConditions" (only classes) or with an [@onlyif decorator](#decorator-onlyif)
+- The execute function is wrapped within a try / except to provide meaningful error logs
+- A logger object (self.logger or {functionname}.logger) with the prefix "org.automation.pythonscripting.{filename}.{function_or_classname}" is available
+- You can enable a profiler to analyze runtime with argument "profile=1"
+- Every run is logging total runtime and trigger reasons
+
+```python
+from openhab import rule
+from openhab.triggers import GenericCronTrigger
+
+@rule( triggers = [ GenericCronTrigger("*/5 * * * * ?") ] )
+class Test:
+ def execute(self, module, input):
+ self.logger.info("Rule 3 was triggered")
+```
+
+```
+2025-01-09 09:35:11.002 [INFO ] [tomation.pythonscripting.demo1.Test2] - Rule executed in 0.1 ms [Item: Item1]
+2025-01-09 09:35:15.472 [INFO ] [tomation.pythonscripting.demo1.Test1] - Rule executed in 0.1 ms [Other: TimerEvent]
+```
+
+**'execute'** callback **'input'** parameter
+
+Depending on which trigger type is used, corresponding [event objects](https://www.openhab.org/javadoc/latest/org/openhab/core/items/events/itemevent) are passed via the "input" parameter
+
+The type of the event can also be queried via [AbstractEvent.getTopic](https://www.openhab.org/javadoc/latest/org/openhab/core/events/abstractevent)
+
+### decorator @when
+
+```python
+@when("Item Test_String_1 changed from 'old test string' to 'new test string'")
+@when("Item gTest_Contact_Sensors changed")
+@when("Member of gTest_Contact_Sensors changed from ON to OFF")
+@when("Descendent of gTest_Contact_Sensors changed from OPEN to CLOSED")
+
+@when("Item Test_Switch_2 received update ON")
+@when("Member of gTest_Switches received update")
+
+@when("Item Test_Switch_1 received command")
+@when("Item Test_Switch_2 received command OFF")
+
+@when("Thing hue:device:default:lamp1 received update ONLINE")
+
+@when("Thing hue:device:default:lamp1 changed from ONLINE to OFFLINE")
+
+@when("Channel hue:device:default:lamp1:color triggered START")
+
+@when("System started")
+@when("System reached start level 50")
+
+@when("Time cron 55 55 5 * * ?")
+@when("Time is midnight")
+@when("Time is noon")
+
+@when("Time is 10:50")
+
+@when("Datetime is Test_Datetime_1")
+@when("Datetime is Test_Datetime_2 time only")
+
+@when("Item added")
+@when("Item removed")
+@when("Item updated")
+
+@when("Thing added")
+@when("Thing removed")
+@when("Thing updated")
+```
+
+### decorator @onlyif
+
+```python
+@onlyif("Item Test_Switch_2 equals ON")
+@onlyif("Today is a holiday")
+@onlyif("It's not a holiday")
+@onlyif("Tomorrow is not a holiday")
+@onlyif("Today plus 1 is weekend")
+@onlyif("Today minus 1 is weekday")
+@onlyif("Today plus 3 is a weekend")
+@onlyif("Today offset -3 is a weekend")
+@onylyf("Today minus 3 is not a holiday")
+@onlyif("Yesterday was in dayset")
+@onlyif("Time 9:00 to 14:00")
+```
+
+## Modules
+
+### module scope
+
+The scope module encapsulates all [default jsr223 objects/presents](https://www.openhab.org/docs/configuration/jsr223.html#default-preset-importpreset-not-required) into a new object. You can use it like below
+
+```python
+from scope import * # this makes all jsr223 objects available
+
+print(ON)
+```
+
+```python
+from scope import ON, OFF # this imports specific jsr223 objects
+
+print(ON)
+```
+
+```python
+import scope # this imports just the module
+
+print(scope.ON)
+```
+
+You can also import additional [jsr223 presents](https://www.openhab.org/docs/configuration/jsr223.html#rulesimple-preset) like
+
+```python
+from scope import RuleSimple
+from scope import RuleSupport
+from scope import RuleFactories
+from scope import ScriptAction
+from scope import cache
+from scope import osgi
+```
+
+Additionally you can import all java classes from 'org.openhab' package like
+
+
+```python
+from org.openhab.core import OpenHAB
+
+print(str(OpenHAB.getVersion()))
+```
+
+### module openhab
+
+| Class | Usage | Description |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| rule | @rule( name=None, description=None, tags=None, triggers=None, conditions=None, profile=None) | [Rule decorator](#decorator-rule) to wrap a custom class into a rule |
+| logger | logger.info, logger.warn ... | Logger object with prefix 'org.automation.pythonscripting.{filename}' |
+| Registry | see [Registry class](#class-registry) | Static Registry class used to get items, things or channels |
+| Timer | see [Timer class](#class-timer) | Static Timer class to create, start and stop timers |
+| Set | see [Set class](#class-set) | Set object |
+
+### module openhab.actions
+
+| Class | Usage | Description |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| Audio | see [openhab Audio api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/audio) | |
+| BusEvent | see [openhab BusEvent api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/busevent) | |
+| Ephemeris | see [openhab Ephemeris api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/ephemeris) | |
+| Exec | see [openhab Exec api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/exec) | e.g. Exec.executeCommandLine(timedelta(seconds=1), "whoami") |
+| HTTP | see [openhab HTTP api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/http) | |
+| Log | see [openhab Log api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/log) | |
+| Ping | see [openhab Ping api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/ping) | |
+| ScriptExecution | see [openhab ScriptExecution api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/scriptexecution) | |
+| Semantic | see [openhab Semantic api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/semantic) | |
+| ThingAction | see [openhab ThingAction api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/things) | |
+| Transformation | see [openhab Transformation api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/transformation) | |
+| Voice | see [openhab Voice api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/voice) | |
+| NotificationAction | | e.g. NotificationAction.sendNotification("test@test.org", "Window is open") |
+
+### module openhab.triggers
+
+| Class | Usage | Description |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| when | @when(term_as_string) | [When trigger decorator](#decorator-when) to create a trigger by a term |
+| onlyif | @onlyif(term_as_string) | [Onlyif condition decorator](#decorator-onlyif) to create a condition by a term |
+| ChannelEventTrigger | ChannelEventTrigger(channel_uid, event=None, trigger_name=None) | |
+| ItemStateUpdateTrigger | ItemStateUpdateTrigger(item_name, state=None, trigger_name=None) | |
+| ItemStateChangeTrigger | ItemStateChangeTrigger(item_name, state=None, previous_state=None, trigger_name=None) | |
+| ItemCommandTrigger | ItemCommandTrigger(item_name, command=None, trigger_name=None) | |
+| GroupStateUpdateTrigger | GroupStateUpdateTrigger(group_name, state=None, trigger_name=None) | |
+| GroupStateChangeTrigger | GroupStateChangeTrigger(group_name, state=None, previous_state=None, trigger_name=None)| |
+| GroupCommandTrigger | GroupCommandTrigger(group_name, command=None, trigger_name=None) | |
+| ThingStatusUpdateTrigger | ThingStatusUpdateTrigger(thing_uid, status=None, trigger_name=None) | |
+| ThingStatusChangeTrigger | ThingStatusChangeTrigger(thing_uid, status=None, previous_status=None, trigger_name=None)| |
+| SystemStartlevelTrigger | SystemStartlevelTrigger(startlevel, trigger_name=None) | for startlevel see [openHAB StartLevelService API](https://www.openhab.org/javadoc/latest/org/openhab/core/service/startlevelservice#) |
+| GenericCronTrigger | GenericCronTrigger(cron_expression, trigger_name=None) | |
+| TimeOfDayTrigger | TimeOfDayTrigger(time, trigger_name=None) | |
+| DateTimeTrigger | DateTimeTrigger(cron_expression, trigger_name=None) | |
+| PWMTrigger | PWMTrigger(cron_expression, trigger_name=None) | |
+| GenericEventTrigger | GenericEventTrigger(event_source, event_types, event_topic="*/*", trigger_name=None) | |
+| ItemEventTrigger | ItemEventTrigger(event_types, item_name=None, trigger_name=None) | |
+| ThingEventTrigger | ThingEventTrigger(event_types, thing_uid=None, trigger_name=None) | |
+| | | |
+| ItemStateCondition | ItemStateCondition(item_name, operator, state, condition_name=None) | |
+| EphemerisCondition | EphemerisCondition(dayset, offset=0, condition_name=None) | |
+| TimeOfDayCondition | TimeOfDayCondition(start_time, end_time, condition_name=None) | |
+| IntervalCondition | IntervalCondition(min_interval, condition_name=None) | |
+
+## Classes
+
+### class Registry
+
+| Function | Usage | Return Value |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| getThing | getThing(uid) | [Thing](#class-thing) |
+| getChannel | getChannel(uid) | [Channel](#class-channel) |
+| getItem | getItem(item_name) | [Item](#class-item) or [GroupItem](#class-groupitem) |
+| resolveItem | resolveItem(item_or_item_name) | [Item](#class-item) or [GroupItem](#class-groupitem) |
+| getItemState | getItemState(item_name, default = None) | [openHAB State](https://www.openhab.org/javadoc/latest/org/openhab/core/types/state) |
+| getItemMetadata | getItemMetadata(item_or_item_name, namespace) | [openHAB Metadata](https://www.openhab.org/javadoc/latest/org/openhab/core/items/metadata) |
+| setItemMetadata | setItemMetadata(item_or_item_name, namespace, value, configuration=None) | [openHAB Metadata](https://www.openhab.org/javadoc/latest/org/openhab/core/items/metadata) |
+| removeItemMetadata | removeItemMetadata(item_or_item_name, namespace = None) | [openHAB Metadata](https://www.openhab.org/javadoc/latest/org/openhab/core/items/metadata) |
+
+
+### class Item
+
+Item is a wrapper around [openHAB Item](https://www.openhab.org/javadoc/latest/org/openhab/core/items/item) with additional functionality.
+
+| Function | Usage | Return Value |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| postUpdate | postUpdate(state) | |
+| postUpdateIfDifferent | postUpdateIfDifferent(state) | |
+| sendCommand | sendCommand(command) | |
+| sendCommandIfDifferent | sendCommandIfDifferent(command) | |
+| getPersistence | getPersistence(service_id = None) | [ItemPersistence](#class-itempersistence) |
+| getSemantic | getSemantic() | [ItemSemantic](#class-itemsemantic) |
+| <...> | see [openHAB Item api](https://www.openhab.org/javadoc/latest/org/openhab/core/items/item) | |
+
+### class GroupItem
+
+GroupItem is an extended [Item](#class-item) which wraps results from getAllMembers & getMembers into [Items](#class-item)
+
+### class ItemPersistence
+
+ItemPersistence is a wrapper around [openHAB PersistenceExtensions](https://www.openhab.org/javadoc/latest/org/openhab/core/persistence/extensions/persistenceextensions). The parameters 'item' and 'serviceId', as part of the Wrapped Java API, are not needed, because they are inserted automatically.
+
+| Function | Usage | Description |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| getStableMinMaxState | getStableMinMaxState(time_slot, end_time = None) | Average calculation which takes into account the values depending on their duration |
+| getStableState | getStableState(time_slot, end_time = None) | Average calculation which takes into account the values depending on their duration |
+| <...> | see [openHAB PersistenceExtensions api](https://www.openhab.org/javadoc/latest/org/openhab/core/persistence/extensions/persistenceextensions) | |
+
+### class ItemSemantic
+
+ItemSemantic is a wrapper around [openHAB Semantics](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/semantics). The parameters 'item', as part of the Wrapped Java API, is not needed because it is inserted automatically.
+
+| Function | Usage |
+| ------------------------ | ------------------------------------------------------------------------------------- |
+| <...> | see [openHAB Semantics api](https://www.openhab.org/javadoc/latest/org/openhab/core/model/script/actions/semantics) |
+
+### class Thing
+
+Thing is a wrapper around [openHAB Thing](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/thing).
+
+| Function | Usage |
+| ------------------------ | ------------------------------------------------------------------------------------- |
+| <...> | see [openHAB Thing api](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/thing) |
+
+### class Channel
+
+Channel is a wrapper around [openHAB Channel](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/type/channelgrouptype).
+
+| Function | Usage |
+| ------------------------ | ------------------------------------------------------------------------------------- |
+| <...> | see [openHAB Channel api](https://www.openhab.org/javadoc/latest/org/openhab/core/thing/type/channelgrouptype) |
+
+### class Timer
+
+| Function | Usage | Description |
+| ------------------------ | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| createTimeout | createTimeout(duration, callback, args=[], kwargs={}, old_timer = None, max_count = 0 ) | Create a timer that will run callback with arguments args and keyword arguments kwargs, after duration seconds have passed. If old_timer from e.g previous call is provided, it will be stopped if not already triggered. If max_count together with old_timer is provided, then 'max_count' times the old timer will be stopped and recreated, before the callback will be triggered immediately |
+
+### class Set
+
+This is a helper class which makes it possible to use a python 'set' as an argument for java class method calls
+
+## Others
+
+### python <=> java conversion
+
+Conversion occurs in both directions
+
+| Python class | Java class |
+| ------------------------- | ------------- |
+| datetime with timezone | ZonedDateTime |
+| datetime without timezone | Instant |
+| timedelta | Duration |
+| list | Collection |
+| Set(set) | Set |
+| Item | Item |
+
+### limitations
+
+- graalby can't handle arguments in constructors of java objects. Means you can't instantiate a javaobject in python with a parameter. https://github.com/oracle/graalpython/issues/367
+- graalpy does not really support python 'set' types as arguments of function calls to java objects https://github.com/oracle/graalpython/issues/260
+ - The reason is that Java is not able to distinguish what is a python list and what is a python set. A workaround is to use the class [Set](#class-set)
diff --git a/bundles/org.openhab.automation.pythonscripting/TODO.md b/bundles/org.openhab.automation.pythonscripting/TODO.md
new file mode 100644
index 0000000000000..6b37c2882a1ea
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/TODO.md
@@ -0,0 +1,30 @@
+TODO
+- fix binding reload
+- fix eclipse build
+- fix dependency watcher for libs as symlinks
+ - is currently not possible, because core openhab filewatcher is not able to handle it
+ - also the "multi lib path" implementation from jruby dows not work, because the core watcher allowes only path objects inside the base path "/openhab/conf/"
+- extend SimpleRule => instead of wrapping in helper class (needs upcoming graalpy 24.2.0 release => register_interop_type) => expected March 18, 2025
+ - not high prio. maybe not needed
+
+DONE
+- @when and @onlyif support
+- better error messages if error is not catched by "helper.py => handle_exception". In java stack trace we do not get correct file and line number
+ - stdout and stderr redirect to Logger => syntax errors
+ - sys.excepthook in helper.py => handle import errors => "Failed to execute script: ModuleNotFoundError: No module named 'Time'"
+ - sys.excepthook in helper.py => handle name errors => "Failed to execute script: NameError: name 'txf' is not defined"
+- implement metadata access
+- implement getStableState (average calculation which takes into account the values depending on their duration)
+- check why assigning tag 'Schedule' to rules with GenericCronTrigger is freezing webui schedule page
+ - reason was the cron expression syntax which was not possible to visualize (unlimited and every 5 minutes)
+- implement dependency tracker and reloader
+- implement persistence access
+- implement semantic access
+- implement caching
+- find a good concept for handling datetime conversion
+- find a way to use SET or Collection as arguments in python for native java objects
+ - https://github.com/oracle/graalpython/issues/260
+ - In python I try to add tags to SimpleRule. The problem is that SimpleRule.setTags expects a SET as a parameter which is currently not supported in graalpy
+ RESOLUTION: list is mapped to Collection and a set must be wrapped into a 'GraalWrapperSet' python object. This will force java to convert it to a SET
+ (not perfect but it works)
+
diff --git a/bundles/org.openhab.automation.pythonscripting/bnd.bnd b/bundles/org.openhab.automation.pythonscripting/bnd.bnd
new file mode 100644
index 0000000000000..fa3abf956d0eb
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/bnd.bnd
@@ -0,0 +1,21 @@
+Bundle-SymbolicName: ${project.artifactId}
+DynamicImport-Package: *
+Import-Package: org.openhab.core.automation.module.script,org.openhab.core.items,org.openhab.core.library.types,javax.management,javax.script,javax.xml.datatype,javax.xml.stream;version="[1.0,2)",org.osgi.framework;version="[1.8,2)",org.slf4j;version="[1.7,2)"
+Require-Capability:
+ osgi.extender:=
+ filter:="(osgi.extender=osgi.serviceloader.processor)",
+ osgi.serviceloader:=
+ filter:="(osgi.serviceloader=org.graalvm.polyglot.impl.AbstractPolyglotImpl)";
+ cardinality:=multiple
+Require-Bundle: org.graalvm.sdk.collections;bundle-version="24.1.2",\
+ org.graalvm.sdk.jniutils;bundle-version="24.1.2",\
+ org.graalvm.sdk.nativeimage;bundle-version="24.1.2",\
+ org.graalvm.sdk.word;bundle-version="24.1.2",\
+ org.graalvm.shadowed.icu4j;bundle-version="24.1.2",\
+ org.graalvm.truffle.truffle-compiler;bundle-version="24.1.2",\
+ org.graalvm.truffle.truffle-runtime;bundle-version="24.1.2"
+
+SPI-Provider: *
+SPI-Consumer: *
+
+-fixupmessages "Classes found in the wrong directory"; restrict:=error; is:=warning
diff --git a/bundles/org.openhab.automation.pythonscripting/pom.xml b/bundles/org.openhab.automation.pythonscripting/pom.xml
new file mode 100644
index 0000000000000..da03ad59da245
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/pom.xml
@@ -0,0 +1,262 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 5.0.0-SNAPSHOT
+
+
+ org.openhab.automation.pythonscripting
+
+ openHAB Add-ons :: Bundles :: Automation :: Python Scripting
+
+
+ !sun.misc.*,
+ !sun.reflect.*,
+ !com.sun.management.*,
+ !jdk.internal.reflect.*,
+ !jdk.vm.ci.services
+
+ 24.1.2
+
+
+
+
+
+ org.graalvm.polyglot
+ polyglot
+ ${graalpy.version}
+
+
+
+ org.graalvm.regex
+ regex
+ ${graalpy.version}
+
+
+
+ org.graalvm.polyglot
+ python-community
+ ${graalpy.version}
+ pom
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+ embed-dependencies
+
+ unpack-dependencies
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-scm-plugin
+ 2.1.0
+
+
+ jgit
+
+
+
+
+ org.apache.maven.scm
+ maven-scm-provider-jgit
+ 2.1.0
+
+
+
+
+ checkout-openhab-python
+
+ checkout
+
+ generate-resources
+
+ scm:git:https://github.com/HolgerHees/openhab-python
+ ${project.build.directory}/python
+ main
+ branch
+
+
+
+
+
+ maven-resources-plugin
+ 3.3.1
+
+
+ copy-openhab-python
+
+ copy-resources
+
+ process-resources
+
+ ${project.build.directory}/classes/lib/openhab/
+
+
+ ${project.build.directory}/python/src/
+ true
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.6.0
+
+
+
+ shade
+
+ package
+
+
+
+ org.lastnpe.eea:eea-all
+ org.apache.karaf.features:framework
+
+ org.graalvm.sdk:collections
+ org.graalvm.sdk:jniutils
+ org.graalvm.sdk:nativeimage
+ org.graalvm.sdk:word
+ org.graalvm.shadowed:icu4j
+ org.graalvm.truffle:truffle-compiler
+ org.graalvm.truffle:truffle-runtime
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+ org.openhab.tools.sat
+ sat-plugin
+
+ ${project.basedir}/suppressions.properties
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/feature/feature.xml b/bundles/org.openhab.automation.pythonscripting/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..e81fe5a236484
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/feature/feature.xml
@@ -0,0 +1,16 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.osgiify/org.graalvm.sdk.collections/24.1.2
+ mvn:org.openhab.osgiify/org.graalvm.sdk.jniutils/24.1.2
+ mvn:org.openhab.osgiify/org.graalvm.sdk.nativeimage/24.1.2
+ mvn:org.openhab.osgiify/org.graalvm.sdk.word/24.1.2
+ mvn:org.openhab.osgiify/org.graalvm.shadowed.icu4j/24.1.2
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-compiler/24.1.2
+ mvn:org.openhab.osgiify/org.graalvm.truffle.truffle-runtime/24.1.2
+ mvn:org.openhab.addons.bundles/org.openhab.automation.pythonscripting/${project.version}
+
+
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngine.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngine.java
new file mode 100644
index 0000000000000..25eb89654e8de
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngine.java
@@ -0,0 +1,477 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal;
+
+import static org.openhab.core.automation.module.script.ScriptEngineFactory.*;
+import static org.openhab.core.automation.module.script.ScriptTransformationService.OPENHAB_TRANSFORMATION_SCRIPT;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.AccessMode;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+import javax.script.ScriptContext;
+import javax.script.ScriptException;
+
+import org.eclipse.jdt.annotation.Nullable;
+import org.graalvm.polyglot.Context;
+import org.graalvm.polyglot.Engine;
+import org.graalvm.polyglot.HostAccess;
+import org.graalvm.polyglot.Source;
+import org.graalvm.polyglot.Value;
+import org.graalvm.polyglot.io.IOAccess;
+import org.openhab.automation.pythonscripting.internal.fs.DelegatingFileSystem;
+import org.openhab.automation.pythonscripting.internal.fs.watch.PythonDependencyTracker;
+import org.openhab.automation.pythonscripting.internal.graal.GraalPythonScriptEngine;
+import org.openhab.automation.pythonscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable;
+import org.openhab.automation.pythonscripting.internal.scriptengine.helper.LifecycleTracker;
+import org.openhab.automation.pythonscripting.internal.scriptengine.helper.LogOutputStream;
+import org.openhab.automation.pythonscripting.internal.wrapper.ScriptExtensionModuleProvider;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
+import org.openhab.core.items.Item;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.event.Level;
+
+/**
+ * GraalPython ScriptEngine implementation
+ *
+ * @author Holger Hees - Initial contribution
+ * @author Jeff James - Initial contribution
+ */
+public class PythonScriptEngine
+ extends InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable
+ implements Lock {
+ private final Logger logger = LoggerFactory.getLogger(PythonScriptEngine.class);
+
+ private static final String PYTHON_OPTION_EXECUTABLE = "python.Executable";
+ private static final String PYTHON_OPTION_PYTHONHOME = "python.PythonHome";
+ private static final String PYTHON_OPTION_PYTHONPATH = "python.PythonPath";
+ private static final String PYTHON_OPTION_INPUTFILEPATH = "python.InputFilePath";
+ private static final String PYTHON_OPTION_EMULATEJYTHON = "python.EmulateJython";
+ private static final String PYTHON_OPTION_POSIXMODULEBACKEND = "python.PosixModuleBackend";
+ private static final String PYTHON_OPTION_DONTWRITEBYTECODEFLAG = "python.DontWriteBytecodeFlag";
+ private static final String PYTHON_OPTION_FORCEIMPORTSITE = "python.ForceImportSite";
+ private static final String PYTHON_OPTION_CHECKHASHPYCSMODE = "python.CheckHashPycsMode";
+ private static final String PYTHON_OPTION_ALWAYSRUNEXCEPTHOOK = "python.AlwaysRunExcepthook";
+
+ private static final String PYTHON_OPTION_CACHEDIR = "python.PyCachePrefix";
+ private static final String PYTHON_CACHEDIR_PATH = Paths
+ .get(OpenHAB.getUserDataFolder(), "cache", PythonScriptEngine.class.getPackageName(), "cachedir")
+ .toString();
+
+ private static final int STACK_TRACE_LENGTH = 5;
+
+ public static final String LOGGER_INIT_NAME = "__logger_init__";
+
+ /** Shared Polyglot {@link Engine} across all instances of {@link PythonScriptEngine} */
+ private static final Engine ENGINE = Engine.newBuilder().allowExperimentalOptions(true)
+ .option("engine.WarnInterpreterOnly", "false").build();
+
+ /** Provides unlimited host access as well as custom translations from Python to Java Objects */
+ private static final HostAccess HOST_ACCESS = HostAccess.newBuilder(HostAccess.ALL)
+ // Translate python datetime with timezone to java.time.ZonedDateTime
+ .targetTypeMapping(Value.class, ZonedDateTime.class,
+ v -> v.hasMember("ctime") && v.hasMember("isoformat") && v.hasMember("tzinfo")
+ && !v.getMember("tzinfo").isNull(),
+ v -> ZonedDateTime.parse(v.invokeMember("isoformat").asString()),
+ HostAccess.TargetMappingPrecedence.LOW)
+
+ // Translate python datetime without timezone to java.time.Instant
+ .targetTypeMapping(Value.class, Instant.class,
+ v -> v.hasMember("ctime") && v.hasMember("isoformat") && v.hasMember("tzinfo")
+ && v.getMember("tzinfo").isNull(),
+ v -> Instant.parse(v.invokeMember("isoformat").asString()), HostAccess.TargetMappingPrecedence.LOW)
+
+ // Translate python timedelta to java.time.Duration
+ .targetTypeMapping(Value.class, Duration.class,
+ // picking two members to check as Duration has many common function names
+ v -> v.hasMember("total_seconds") && v.hasMember("total_seconds"),
+ v -> Duration.ofNanos(v.invokeMember("total_seconds").asLong() * 10000000),
+ HostAccess.TargetMappingPrecedence.LOW)
+
+ // Translate python item to org.openhab.core.items.Item
+ .targetTypeMapping(Value.class, Item.class, v -> v.hasMember("raw_item"),
+ v -> v.getMember("raw_item").as(Item.class), HostAccess.TargetMappingPrecedence.LOW)
+
+ // Translate python GraalWrapperSet to java.util.Set
+ .targetTypeMapping(Value.class, Set.class, v -> v.hasMember("isSetType"),
+ PythonScriptEngine::transformGraalWrapperSet, HostAccess.TargetMappingPrecedence.LOW)
+
+ // Translate python list to java.util.Collection
+ .targetTypeMapping(Value.class, Collection.class, (v) -> v.hasArrayElements(),
+ (v) -> v.as(Collection.class), HostAccess.TargetMappingPrecedence.LOW)
+
+ .build();
+
+ /** {@link Lock} synchronization of multi-thread access */
+ private final Lock lock = new ReentrantLock();
+
+ // these fields start as null because they are populated on first use
+ private @Nullable Consumer scriptDependencyListener;
+ private final ScriptExtensionModuleProvider scriptExtensionModuleProvider;
+ private final LifecycleTracker lifecycleTracker;
+
+ private PythonScriptEngineConfiguration pythonScriptEngineConfiguration;
+
+ private boolean initialized = false;
+
+ private final LogOutputStream scriptOutputStream;
+ private final LogOutputStream scriptErrorStream;
+
+ /**
+ * Creates an implementation of ScriptEngine {@code (& Invocable)}, wrapping the contained engine,
+ * that tracks the script lifecycle and provides hooks for scripts to do so too.
+ *
+ * @param pythonDependencyTracker
+ * @param injectionEnabled
+ * @param scopeEnabled
+ * @param cachingEnabled
+ * @param jythonEmulation
+ */
+ public PythonScriptEngine(PythonDependencyTracker pythonDependencyTracker,
+ PythonScriptEngineConfiguration pythonScriptEngineConfiguration) {
+ this.pythonScriptEngineConfiguration = pythonScriptEngineConfiguration;
+
+ scriptOutputStream = new LogOutputStream(logger, Level.INFO);
+ scriptErrorStream = new LogOutputStream(logger, Level.ERROR);
+
+ scriptExtensionModuleProvider = new ScriptExtensionModuleProvider();
+ lifecycleTracker = new LifecycleTracker();
+
+ Context.Builder contextConfig = Context.newBuilder(GraalPythonScriptEngine.LANGUAGE_ID) //
+ .out(scriptOutputStream) //
+ .err(scriptErrorStream) //
+ .allowIO(IOAccess.newBuilder() //
+ .allowHostSocketAccess(true) //
+ .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {
+ @Override
+ public void checkAccess(Path path, Set extends AccessMode> modes,
+ LinkOption... linkOptions) throws IOException {
+ if (pythonScriptEngineConfiguration.isDependencyTrackingEnabled()) {
+ if (path.startsWith(PythonScriptEngineFactory.PYTHON_LIB_PATH)) {
+ Consumer localScriptDependencyListener = scriptDependencyListener;
+ if (localScriptDependencyListener != null) {
+ localScriptDependencyListener.accept(path.toString());
+ }
+ }
+ }
+ super.checkAccess(path, modes, linkOptions);
+ }
+ }).build()) //
+ .allowHostAccess(HOST_ACCESS) //
+ // usage of .allowAllAccess(true) includes
+ // - allowCreateThread(true)
+ // - allowCreateProcess(true)
+ // - allowHostClassLoading(true)
+ // - allowHostClassLookup(true)
+ // - allowPolyglotAccess(PolyglotAccess.ALL)
+ // - allowIO(true)
+ // - allowEnvironmentAccess(EnvironmentAccess.INHERIT)
+ .allowAllAccess(true) //
+ // allow class lookup like "org.slf4j.LoggerFactory" from inline scripts
+ .hostClassLoader(getClass().getClassLoader()) //
+ // allow running Python native extensions
+ .allowNativeAccess(true) //
+ // allow experimental options
+ .allowExperimentalOptions(true) //
+ // choose the backend for the POSIX module
+ .option(PYTHON_OPTION_POSIXMODULEBACKEND, "java") //
+ // Force to automatically import site.py module, to make Python packages available
+ .option(PYTHON_OPTION_FORCEIMPORTSITE, Boolean.toString(true)) //
+ // The sys.executable path, a virtual path that is used by the interpreter
+ // to discover packages
+ .option(PYTHON_OPTION_EXECUTABLE,
+ PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.resolve("bin").resolve("python").toString())
+ // Set the python home to be read from the embedded resources
+ .option(PYTHON_OPTION_PYTHONHOME, PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString()) //
+ // Set python path to point to sources stored in
+ .option(PYTHON_OPTION_PYTHONPATH,
+ PythonScriptEngineFactory.PYTHON_LIB_PATH.toString() + File.pathSeparator
+ + PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString())
+ // pass the path to be executed
+ .option(PYTHON_OPTION_INPUTFILEPATH, PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString()) //
+ // make sure the TopLevelExceptionHandler calls the excepthook to print Python exceptions
+ .option(PYTHON_OPTION_ALWAYSRUNEXCEPTHOOK, Boolean.toString(true)) //
+ // emulate jython behavior (will slowdown the engine)
+ .option(PYTHON_OPTION_EMULATEJYTHON,
+ String.valueOf(pythonScriptEngineConfiguration.isJythonEmulation()));
+
+ if (pythonScriptEngineConfiguration.isCachingEnabled()) {
+ contextConfig.option(PYTHON_OPTION_DONTWRITEBYTECODEFLAG, Boolean.toString(false)) //
+ .option(PYTHON_OPTION_CACHEDIR, PYTHON_CACHEDIR_PATH);
+ } else {
+ contextConfig.option(PYTHON_OPTION_DONTWRITEBYTECODEFLAG, Boolean.toString(true)) //
+ // causes the interpreter to always assume hash-based pycs are valid
+ .option(PYTHON_OPTION_CHECKHASHPYCSMODE, "never");
+ }
+
+ delegate = GraalPythonScriptEngine.create(ENGINE, contextConfig);
+ }
+
+ @Override
+ protected void beforeInvocation() {
+ lock.lock();
+ logger.debug("Lock acquired before invocation.");
+
+ if (initialized) {
+ return;
+ }
+
+ logger.debug("Initializing GraalPython script engine...");
+
+ ScriptContext ctx = getScriptContext();
+
+ // these are added post-construction, so we need to fetch them late
+ String engineIdentifier = (String) ctx.getAttribute(CONTEXT_KEY_ENGINE_IDENTIFIER);
+ if (engineIdentifier == null) {
+ throw new IllegalStateException("Failed to retrieve engine identifier from engine bindings");
+ }
+
+ ScriptExtensionAccessor scriptExtensionAccessor = (ScriptExtensionAccessor) ctx
+ .getAttribute(CONTEXT_KEY_EXTENSION_ACCESSOR);
+ if (scriptExtensionAccessor == null) {
+ throw new IllegalStateException("Failed to retrieve script extension accessor from engine bindings");
+ }
+
+ Consumer scriptDependencyListener = (Consumer) ctx
+ .getAttribute(CONTEXT_KEY_DEPENDENCY_LISTENER);
+ if (scriptDependencyListener == null) {
+ logger.warn(
+ "Failed to retrieve script script dependency listener from engine bindings. Script dependency tracking will be disabled.");
+ }
+ this.scriptDependencyListener = scriptDependencyListener;
+
+ if (pythonScriptEngineConfiguration.isScopeEnabled()) {
+ // Wrap the "import" function to also allow loading modules from the ScriptExtensionModuleProvider
+ BiFunction, Object> wrapImportFn = (name, fromlist) -> scriptExtensionModuleProvider
+ .locatorFor(delegate.getPolyglotContext(), engineIdentifier, scriptExtensionAccessor)
+ .locateModule(name, fromlist);
+ delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(ScriptExtensionModuleProvider.IMPORT_PROXY_NAME,
+ wrapImportFn);
+ try {
+ String wrapperContent = new String(
+ Files.readAllBytes(PythonScriptEngineFactory.PYTHON_WRAPPER_FILE_PATH));
+ delegate.getPolyglotContext().eval(Source.newBuilder(GraalPythonScriptEngine.LANGUAGE_ID,
+ wrapperContent, PythonScriptEngineFactory.PYTHON_WRAPPER_FILE_PATH.toString()).build());
+
+ // inject scope, Registry and logger
+ if (!pythonScriptEngineConfiguration.isInjection(PythonScriptEngineConfiguration.INJECTION_DISABLED)
+ && (ctx.getAttribute("javax.script.filename") == null || pythonScriptEngineConfiguration
+ .isInjection(PythonScriptEngineConfiguration.INJECTION_ENABLED_FOR_ALL_SCRIPTS))) {
+ String injectionContent = "import scope\nfrom openhab import Registry, logger";
+ delegate.getPolyglotContext().eval(Source
+ .newBuilder(GraalPythonScriptEngine.LANGUAGE_ID, injectionContent, "").build());
+ }
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Failed to generate import wrapper", e);
+ }
+ }
+
+ // logger initialization, for non file based scripts, has to be delayed, because ruleUID is not available yet
+ if (ctx.getAttribute("javax.script.filename") == null) {
+ Runnable wrapperLoggerFn = () -> setScriptLogger();
+ delegate.getBindings(ScriptContext.ENGINE_SCOPE).put(LOGGER_INIT_NAME, wrapperLoggerFn);
+ } else {
+ setScriptLogger();
+ }
+
+ initialized = true;
+ }
+
+ @Override
+ protected String beforeInvocation(String source) {
+ source = super.beforeInvocation(source);
+
+ // Happens for Transform and UI based rules (eval and compile)
+ // and has to be evaluate every time, because of changing and late injected ruleUID
+ if (delegate.getBindings(ScriptContext.ENGINE_SCOPE).get(LOGGER_INIT_NAME) != null) {
+ return LOGGER_INIT_NAME + "()\n" + source;
+ }
+
+ return source;
+ }
+
+ @Override
+ protected Object afterInvocation(Object obj) {
+ lock.unlock();
+ logger.debug("Lock released after invocation.");
+ return super.afterInvocation(obj);
+ }
+
+ @Override
+ protected Exception afterThrowsInvocation(Exception e) {
+ // OPS4J Pax Logging holds a reference to the exception, which causes the OpenhabGraalJSScriptEngine to not be
+ // removed from heap by garbage collection and causing a memory leak.
+ // Therefore, don't pass the exceptions itself to the logger, but only their message!
+ if (e instanceof ScriptException) {
+ // PolyglotException will always be wrapped into ScriptException and they will be visualized in
+ // org.openhab.core.automation.module.script.internal.ScriptEngineManagerImpl
+ if (scriptErrorStream.getLogger().isDebugEnabled()) {
+ scriptErrorStream.getLogger().debug("Failed to execute script (PolyglotException): {}",
+ stringifyThrowable(e.getCause()));
+ }
+ } else if (e.getCause() instanceof IllegalArgumentException) {
+ scriptErrorStream.getLogger().error("Failed to execute script (IllegalArgumentException): {}",
+ stringifyThrowable(e.getCause()));
+ }
+
+ lock.unlock();
+
+ return super.afterThrowsInvocation(e);
+ }
+
+ @Override
+ // collect JSR223 (scope) variables separately, because they are delivered via 'import scope'
+ public void put(String key, Object value) {
+ if ("javax.script.filename".equals(key)) {
+ // super.put("__file__", value);
+ super.put(key, value);
+ } else {
+ // use a custom lifecycleTracker to handle dispose hook before polyglot context is closed
+ // original lifecycleTracker is handling it when polyglot context is already closed
+ if ("lifecycleTracker".equals(key)) {
+ value = lifecycleTracker;
+ }
+ if (pythonScriptEngineConfiguration.isScopeEnabled()) {
+ scriptExtensionModuleProvider.put(key, value);
+ } else {
+ super.put(key, value);
+ }
+ }
+ }
+
+ @Override
+ public void lock() {
+ lock.lock();
+ logger.debug("Lock acquired.");
+ }
+
+ @Override
+ public void lockInterruptibly() throws InterruptedException {
+ lock.lockInterruptibly();
+ }
+
+ @Override
+ public boolean tryLock() {
+ boolean acquired = lock.tryLock();
+ if (acquired) {
+ logger.debug("Lock acquired.");
+ } else {
+ logger.debug("Lock not acquired.");
+ }
+ return acquired;
+ }
+
+ @Override
+ public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException {
+ boolean acquired = lock.tryLock(l, timeUnit);
+ if (acquired) {
+ logger.debug("Lock acquired.");
+ } else {
+ logger.debug("Lock not acquired.");
+ }
+ return acquired;
+ }
+
+ @Override
+ public void unlock() {
+ lock.unlock();
+ logger.debug("Lock released.");
+ }
+
+ @Override
+ public void close() throws Exception {
+ this.lifecycleTracker.dispose();
+ super.close();
+ }
+
+ @Override
+ public Condition newCondition() {
+ return lock.newCondition();
+ }
+
+ /**
+ * Initializes the logger.
+ * This cannot be done on script engine creation because the context variables are not yet initialized.
+ * Therefore, the logger needs to be initialized on the first use after script engine creation.
+ */
+ private void setScriptLogger() {
+ ScriptContext ctx = getScriptContext();
+ Object fileName = ctx.getAttribute("javax.script.filename");
+ Object ruleUID = ctx.getAttribute("ruleUID");
+ Object ohEngineIdentifier = ctx.getAttribute("oh.engine-identifier");
+
+ String identifier = "stack";
+ if (fileName != null) {
+ identifier = fileName.toString().replaceAll("^.*[/\\\\]", "");
+ } else if (ruleUID != null) {
+ identifier = ruleUID.toString();
+ } else if (ohEngineIdentifier != null) {
+ if (ohEngineIdentifier.toString().startsWith(OPENHAB_TRANSFORMATION_SCRIPT)) {
+ identifier = ohEngineIdentifier.toString().replaceAll(OPENHAB_TRANSFORMATION_SCRIPT, "transformation.");
+ }
+ }
+
+ Logger scriptLogger = LoggerFactory.getLogger("org.openhab.automation.pythonscripting." + identifier);
+
+ scriptOutputStream.setLogger(scriptLogger);
+ scriptErrorStream.setLogger(scriptLogger);
+ }
+
+ private ScriptContext getScriptContext() {
+ ScriptContext ctx = delegate.getContext();
+ if (ctx == null) {
+ throw new IllegalStateException("Failed to retrieve script context");
+ }
+ return ctx;
+ }
+
+ private String stringifyThrowable(Throwable throwable) {
+ String message = throwable.getMessage();
+ StackTraceElement[] stackTraceElements = throwable.getStackTrace();
+ String stackTrace = Arrays.stream(stackTraceElements).limit(STACK_TRACE_LENGTH)
+ .map(t -> " at " + t.toString()).collect(Collectors.joining(System.lineSeparator()))
+ + System.lineSeparator() + " ... " + stackTraceElements.length + " more";
+ return (message != null) ? message + System.lineSeparator() + stackTrace : stackTrace;
+ }
+
+ private static Set transformGraalWrapperSet(Value value) {
+ Set set = new HashSet();
+ for (int i = 0; i < value.getArraySize(); ++i) {
+ Value element = value.getArrayElement(i);
+ set.add(element.asString());
+ }
+ return set;
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineConfiguration.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineConfiguration.java
new file mode 100644
index 0000000000000..5fd03957a31dc
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineConfiguration.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.config.core.ConfigParser;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Processes Python Configuration Parameters.
+ *
+ * @author Holger Hees - Initial contribution
+ */
+@NonNullByDefault
+public class PythonScriptEngineConfiguration {
+
+ private final Logger logger = LoggerFactory.getLogger(PythonScriptEngineConfiguration.class);
+
+ private static final String CFG_INJECTION_ENABLED = "injectionEnabled";
+ private static final String CFG_HELPER_ENABLED = "helperEnabled";
+ private static final String CFG_SCOPE_ENABLED = "scopeEnabled";
+ private static final String CFG_DEPENDENCY_TRACKING_ENABLED = "dependencyTrackingEnabled";
+ private static final String CFG_CACHING_ENABLED = "cachingEnabled";
+ private static final String CFG_JYTHON_EMULATION = "jythonEmulation";
+
+ public static final int INJECTION_DISABLED = 0;
+ public static final int INJECTION_ENABLED_FOR_ALL_SCRIPTS = 1;
+ public static final int INJECTION_ENABLED_FOR_NON_FILE_BASED_SCRIPTS = 2;
+ private int injectionEnabled = 0;
+ private boolean helperEnabled = false;
+ private boolean scopeEnabled = false;
+ private boolean dependencyTrackingEnabled = false;
+ private boolean cachingEnabled = false;
+ private boolean jythonEmulation = false;
+
+ /**
+ * Update configuration
+ *
+ * @param config Configuration parameters to apply to ScriptEngine
+ */
+ void update(Map config) {
+ logger.trace("Python Script Engine Configuration: {}", config);
+
+ this.scopeEnabled = ConfigParser.valueAsOrElse(config.get(CFG_SCOPE_ENABLED), Boolean.class, true);
+ this.helperEnabled = ConfigParser.valueAsOrElse(config.get(CFG_HELPER_ENABLED), Boolean.class, true);
+ this.injectionEnabled = ConfigParser.valueAsOrElse(config.get(CFG_INJECTION_ENABLED), Integer.class,
+ INJECTION_ENABLED_FOR_NON_FILE_BASED_SCRIPTS);
+ this.dependencyTrackingEnabled = ConfigParser.valueAsOrElse(config.get(CFG_DEPENDENCY_TRACKING_ENABLED),
+ Boolean.class, true);
+ this.cachingEnabled = ConfigParser.valueAsOrElse(config.get(CFG_CACHING_ENABLED), Boolean.class, true);
+ this.jythonEmulation = ConfigParser.valueAsOrElse(config.get(CFG_JYTHON_EMULATION), Boolean.class, false);
+ }
+
+ public boolean isScopeEnabled() {
+ return scopeEnabled;
+ }
+
+ public boolean isHelperEnabled() {
+ return helperEnabled;
+ }
+
+ public boolean isInjection(int type) {
+ return injectionEnabled == type;
+ }
+
+ public boolean isDependencyTrackingEnabled() {
+ return dependencyTrackingEnabled;
+ }
+
+ public boolean isCachingEnabled() {
+ return cachingEnabled;
+ }
+
+ public boolean isJythonEmulation() {
+ return jythonEmulation;
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineFactory.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineFactory.java
new file mode 100644
index 0000000000000..f0fc9fb4e6ba7
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/PythonScriptEngineFactory.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.module.ModuleDescriptor.Version;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.script.ScriptEngine;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.automation.pythonscripting.internal.fs.watch.PythonDependencyTracker;
+import org.openhab.core.OpenHAB;
+import org.openhab.core.automation.module.script.ScriptDependencyTracker;
+import org.openhab.core.automation.module.script.ScriptEngineFactory;
+import org.openhab.core.config.core.ConfigurableService;
+import org.osgi.framework.Constants;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is an implementation of {@link ScriptEngineFactory} for Python.
+ *
+ * @author Holger Hees - initial contribution
+ * @author Jeff James - initial contribution
+ */
+@Component(service = ScriptEngineFactory.class, configurationPid = "org.openhab.automation.pythonscripting", property = Constants.SERVICE_PID
+ + "=org.openhab.automation.pythonscripting")
+@ConfigurableService(category = "automation", label = "Python Scripting", description_uri = "automation:pythonscripting")
+@NonNullByDefault
+public class PythonScriptEngineFactory implements ScriptEngineFactory {
+ private final Logger logger = LoggerFactory.getLogger(PythonScriptEngineFactory.class);
+
+ public static final Path PYTHON_DEFAULT_PATH = Paths.get(OpenHAB.getConfigFolder(), "automation", "python");
+ public static final Path PYTHON_LIB_PATH = PYTHON_DEFAULT_PATH.resolve("lib");
+
+ private static final Path PYTHON_OPENHAB_LIB_PATH = PYTHON_LIB_PATH.resolve("openhab");
+
+ public static final Path PYTHON_WRAPPER_FILE_PATH = PYTHON_OPENHAB_LIB_PATH.resolve("__wrapper__.py");
+ private static final Path PYTHON_INIT_FILE_PATH = PYTHON_OPENHAB_LIB_PATH.resolve("__init__.py");
+
+ public static final String SCRIPT_TYPE = "application/x-python3";
+ private final List scriptTypes = Arrays.asList(PythonScriptEngineFactory.SCRIPT_TYPE, "PY3");
+
+ private final PythonDependencyTracker pythonDependencyTracker;
+ private final PythonScriptEngineConfiguration pythonScriptEngineConfiguration;
+
+ @Activate
+ public PythonScriptEngineFactory(final @Reference PythonDependencyTracker pythonDependencyTracker,
+ Map config) {
+ logger.debug("Loading PythonScriptEngineFactory");
+
+ this.pythonDependencyTracker = pythonDependencyTracker;
+ this.pythonScriptEngineConfiguration = new PythonScriptEngineConfiguration();
+
+ modified(config);
+
+ if (this.pythonScriptEngineConfiguration.isHelperEnabled()) {
+ initHelperLib();
+ }
+ }
+
+ @Deactivate
+ public void cleanup() {
+ logger.debug("Unloading PythonScriptEngineFactory");
+ }
+
+ @Modified
+ protected void modified(Map config) {
+ this.pythonScriptEngineConfiguration.update(config);
+ }
+
+ @Override
+ public List getScriptTypes() {
+ return scriptTypes;
+ }
+
+ @Override
+ public void scopeValues(ScriptEngine scriptEngine, Map scopeValues) {
+ for (Entry entry : scopeValues.entrySet()) {
+ scriptEngine.put(entry.getKey(), entry.getValue());
+ }
+ }
+
+ @Override
+ public @Nullable ScriptEngine createScriptEngine(String scriptType) {
+ if (!scriptTypes.contains(scriptType)) {
+ return null;
+ }
+ return new PythonScriptEngine(pythonDependencyTracker, pythonScriptEngineConfiguration);
+ }
+
+ @Override
+ public @Nullable ScriptDependencyTracker getDependencyTracker() {
+ return pythonDependencyTracker;
+ }
+
+ private void initHelperLib() {
+ try {
+ String resourceLibPath = PYTHON_OPENHAB_LIB_PATH.toString()
+ .substring(PYTHON_DEFAULT_PATH.toString().length()) + "/";
+
+ Enumeration resourceFiles = FrameworkUtil.getBundle(PythonScriptEngineFactory.class)
+ .findEntries(resourceLibPath, "*.py", true);
+
+ if (Files.exists(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH)
+ && Files.list(PYTHON_OPENHAB_LIB_PATH).count() > 0) {
+ Pattern pattern = Pattern.compile("__version__\\s*=\\s*\"([0-9]+\\.[0-9]+\\.[0-9]+)\"",
+ Pattern.CASE_INSENSITIVE);
+
+ Version includedVersion = null;
+ try (InputStream is = PythonScriptEngineFactory.class.getClassLoader()
+ .getResourceAsStream(resourceLibPath + PYTHON_INIT_FILE_PATH.getFileName().toString())) {
+ try (InputStreamReader isr = new InputStreamReader(is);
+ BufferedReader reader = new BufferedReader(isr)) {
+ String fileContent = reader.lines().collect(Collectors.joining(System.lineSeparator()));
+ Matcher includedMatcher = pattern.matcher(fileContent);
+ if (includedMatcher.find()) {
+ includedVersion = Version.parse(includedMatcher.group(1));
+ }
+ }
+ }
+
+ Version currentVersion = null;
+ String fileContent = Files.readString(PYTHON_INIT_FILE_PATH, StandardCharsets.UTF_8);
+ Matcher currentMatcher = pattern.matcher(fileContent);
+ if (currentMatcher.find()) {
+ currentVersion = Version.parse(currentMatcher.group(1));
+ }
+
+ if (currentVersion == null) {
+ logger.warn("Unable to detect installed helper lib version. Skip installing helper libs.");
+ return;
+ } else if (includedVersion == null) {
+ logger.error("Unable to detect provided helper lib version. Skip installing helper libs.");
+ return;
+ } else if (currentVersion.compareTo(includedVersion) >= 0) {
+ logger.info("Newest helper lib version is deployed.");
+ return;
+ }
+ }
+
+ logger.info("Deploy helper libs into {}.", PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH.toString());
+
+ if (Files.exists(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH)) {
+ try (Stream paths = Files.walk(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH)) {
+ paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+ }
+ }
+
+ Files.createDirectories(PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH,
+ PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-xr-x")));
+
+ while (resourceFiles.hasMoreElements()) {
+ URL resourceFile = resourceFiles.nextElement();
+ String resourcePath = resourceFile.getPath();
+
+ try (InputStream is = PythonScriptEngineFactory.class.getClassLoader()
+ .getResourceAsStream(resourcePath)) {
+ Path target = PythonScriptEngineFactory.PYTHON_OPENHAB_LIB_PATH
+ .resolve(resourcePath.substring(resourcePath.lastIndexOf('/') + 1));
+
+ Files.copy(is, target);
+ Files.setPosixFilePermissions(target, PosixFilePermissions.fromString("rw-r--r--"));
+ }
+ }
+ } catch (Exception e) {
+ logger.error("Exception during helper lib initialisation", e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/DelegatingFileSystem.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/DelegatingFileSystem.java
new file mode 100644
index 0000000000000..104a02bc40c1d
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/DelegatingFileSystem.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.fs;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.AccessMode;
+import java.nio.file.DirectoryStream;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.Map;
+import java.util.Set;
+
+import org.graalvm.polyglot.io.FileSystem;
+
+/**
+ * Delegate wrapping a {@link FileSystem}
+ *
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+public class DelegatingFileSystem implements FileSystem {
+ private FileSystemProvider delegate;
+
+ public DelegatingFileSystem(FileSystemProvider delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Path parsePath(URI uri) {
+ return Paths.get(uri);
+ }
+
+ @Override
+ public Path parsePath(String path) {
+ return Paths.get(path);
+ }
+
+ @Override
+ public void checkAccess(Path path, Set extends AccessMode> modes, LinkOption... linkOptions) throws IOException {
+ delegate.checkAccess(path, modes.toArray(new AccessMode[0]));
+ }
+
+ @Override
+ public void createDirectory(Path dir, FileAttribute>... attrs) throws IOException {
+ delegate.createDirectory(dir, attrs);
+ }
+
+ @Override
+ public void delete(Path path) throws IOException {
+ delegate.delete(path);
+ }
+
+ @Override
+ public SeekableByteChannel newByteChannel(Path path, Set extends OpenOption> options, FileAttribute>... attrs)
+ throws IOException {
+ return delegate.newByteChannel(path, options, attrs);
+ }
+
+ @Override
+ public DirectoryStream newDirectoryStream(Path dir, DirectoryStream.Filter super Path> filter)
+ throws IOException {
+ return delegate.newDirectoryStream(dir, filter);
+ }
+
+ @Override
+ public Path toAbsolutePath(Path path) {
+ return path.toAbsolutePath();
+ }
+
+ @Override
+ public Path toRealPath(Path path, LinkOption... linkOptions) throws IOException {
+ return path.toRealPath(linkOptions);
+ }
+
+ @Override
+ public Map readAttributes(Path path, String attributes, LinkOption... options) throws IOException {
+ return delegate.readAttributes(path, attributes, options);
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonDependencyTracker.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonDependencyTracker.java
new file mode 100644
index 0000000000000..0be4ff9c4e2dc
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonDependencyTracker.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.fs.watch;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.automation.pythonscripting.internal.PythonScriptEngineFactory;
+import org.openhab.core.automation.module.script.ScriptDependencyTracker;
+import org.openhab.core.automation.module.script.rulesupport.loader.AbstractScriptDependencyTracker;
+import org.openhab.core.service.WatchService;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+import org.osgi.service.component.annotations.ReferencePolicy;
+
+/**
+ * Tracks Python module dependencies
+ *
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+@Component(service = PythonDependencyTracker.class)
+@NonNullByDefault
+public class PythonDependencyTracker extends AbstractScriptDependencyTracker {
+
+ @Activate
+ public PythonDependencyTracker(@Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService) {
+ super(watchService, PythonScriptEngineFactory.PYTHON_LIB_PATH.toString());
+ }
+
+ @Deactivate
+ @Override
+ public void deactivate() {
+ super.deactivate();
+ }
+
+ @Override
+ @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC, unbind = "removeChangeTracker")
+ public void addChangeTracker(ScriptDependencyTracker.Listener listener) {
+ super.addChangeTracker(listener);
+ }
+
+ @Override
+ public void removeChangeTracker(ScriptDependencyTracker.Listener listener) {
+ super.removeChangeTracker(listener);
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonScriptFileWatcher.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonScriptFileWatcher.java
new file mode 100644
index 0000000000000..754ae7d9d59a9
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/fs/watch/PythonScriptFileWatcher.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.fs.watch;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.automation.pythonscripting.internal.PythonScriptEngineFactory;
+import org.openhab.core.automation.module.script.ScriptDependencyTracker;
+import org.openhab.core.automation.module.script.ScriptEngineManager;
+import org.openhab.core.automation.module.script.rulesupport.loader.AbstractScriptFileWatcher;
+import org.openhab.core.automation.module.script.rulesupport.loader.ScriptFileWatcher;
+import org.openhab.core.service.ReadyService;
+import org.openhab.core.service.StartLevelService;
+import org.openhab.core.service.WatchService;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * Monitors {@code /automation/python} for Python files, but not libraries
+ *
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+@Component(immediate = true, service = { ScriptFileWatcher.class, ScriptDependencyTracker.Listener.class })
+@NonNullByDefault
+public class PythonScriptFileWatcher extends AbstractScriptFileWatcher {
+ @Activate
+ public PythonScriptFileWatcher(
+ final @Reference(target = WatchService.CONFIG_WATCHER_FILTER) WatchService watchService,
+ final @Reference ScriptEngineManager manager, final @Reference ReadyService readyService,
+ final @Reference StartLevelService startLevelService) {
+ super(watchService, manager, readyService, startLevelService,
+ PythonScriptEngineFactory.PYTHON_DEFAULT_PATH.toString(), true);
+ }
+
+ @Override
+ protected Optional getScriptType(Path scriptFilePath) {
+ if (!scriptFilePath.startsWith(PythonScriptEngineFactory.PYTHON_LIB_PATH)) {
+ Optional scriptFileSuffix = super.getScriptType(scriptFilePath);
+ if (scriptFileSuffix.isPresent() && "py".equals(scriptFileSuffix.get())) {
+ return Optional.of(PythonScriptEngineFactory.SCRIPT_TYPE);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonBindings.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonBindings.java
new file mode 100644
index 0000000000000..a03c7c7a21f49
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonBindings.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.graal;
+
+import java.util.AbstractMap;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+
+import org.graalvm.polyglot.Context;
+import org.graalvm.polyglot.Value;
+
+/***
+ * @author Holger Hees - Initial contribution
+ * @author Jeff James - Initial contribution
+ */
+final class GraalPythonBindings extends AbstractMap implements javax.script.Bindings, AutoCloseable {
+ private Context context;
+ private Map global;
+
+ private Context.Builder contextBuilder;
+ // ScriptContext of the ScriptEngine where these bindings form ENGINE_SCOPE bindings
+ private ScriptContext scriptContext;
+ private ScriptEngine scriptEngine;
+
+ GraalPythonBindings(Context.Builder contextBuilder, ScriptContext scriptContext, ScriptEngine scriptEngine) {
+ this.contextBuilder = contextBuilder;
+ this.scriptContext = scriptContext;
+ this.scriptEngine = scriptEngine;
+ }
+
+ GraalPythonBindings(Context context, ScriptContext scriptContext, ScriptEngine scriptEngine) {
+ this.context = context;
+ this.scriptContext = scriptContext;
+ this.scriptEngine = scriptEngine;
+
+ initGlobal();
+ }
+
+ private void requireContext() {
+ if (context == null) {
+ context = GraalPythonScriptEngine.createDefaultContext(contextBuilder, scriptContext);
+
+ initGlobal();
+ }
+ }
+
+ private void initGlobal() {
+ this.global = new HashMap();
+
+ requireContext();
+
+ context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).putMember("__engine__", scriptEngine);
+ if (scriptContext != null) {
+ context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).putMember("__context__", scriptContext);
+ }
+ }
+
+ @Override
+ public Object put(String key, Object v) {
+ requireContext();
+
+ context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).putMember(key, v);
+ return global.put(key, v);
+ }
+
+ @Override
+ public void clear() {
+ if (context != null) {
+ Value binding = context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID);
+ for (var entry : global.entrySet()) {
+ binding.removeMember(entry.getKey());
+ }
+ }
+ }
+
+ @Override
+ public Object get(Object key) {
+ requireContext();
+ return global.get(key);
+ }
+
+ @Override
+ public Object remove(Object key) {
+ requireContext();
+ Object prev = get(key);
+ context.getBindings(GraalPythonScriptEngine.LANGUAGE_ID).removeMember((String) key);
+ global.remove(key);
+ return prev;
+ }
+
+ public Context getContext() {
+ requireContext();
+ return context;
+ }
+
+ @Override
+ public Set> entrySet() {
+ requireContext();
+ return global.entrySet();
+ }
+
+ @Override
+ public void close() {
+ if (context != null) {
+ context.close();
+ }
+ }
+
+ void updateEngineScriptContext(ScriptContext scriptContext) {
+ this.scriptContext = scriptContext;
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngine.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngine.java
new file mode 100644
index 0000000000000..fc6a394b9446a
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngine.java
@@ -0,0 +1,434 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.graal;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+
+import javax.script.AbstractScriptEngine;
+import javax.script.Bindings;
+import javax.script.Compilable;
+import javax.script.CompiledScript;
+import javax.script.Invocable;
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.ScriptException;
+
+import org.graalvm.polyglot.Context;
+import org.graalvm.polyglot.Context.Builder;
+import org.graalvm.polyglot.Engine;
+import org.graalvm.polyglot.HostAccess;
+import org.graalvm.polyglot.PolyglotException;
+import org.graalvm.polyglot.Source;
+import org.graalvm.polyglot.SourceSection;
+import org.graalvm.polyglot.Value;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A Graal.Python implementation of the script engine. It provides access to the polyglot context using
+ * {@link #getPolyglotContext()}.
+ *
+ * @author Holger Hees - Initial contribution
+ * @author Jeff James - Initial contribution
+ */
+public final class GraalPythonScriptEngine extends AbstractScriptEngine
+ implements Compilable, Invocable, AutoCloseable {
+
+ public static final String LANGUAGE_ID = "python";
+ private static final String POLYGLOT_CONTEXT = "polyglot.context";
+
+ private static final String PYTHON_OPTION_POSIXMODULEBACKEND = "python.PosixModuleBackend";
+ private static final String PYTHON_OPTION_DONTWRITEBYTECODEFLAG = "python.DontWriteBytecodeFlag";
+ private static final String PYTHON_OPTION_FORCEIMPORTSITE = "python.ForceImportSite";
+ private static final String PYTHON_OPTION_CHECKHASHPYCSMODE = "python.CheckHashPycsMode";
+
+ static final String MAGIC_OPTION_PREFIX = "polyglot.py.";
+
+ private final Logger logger = LoggerFactory.getLogger(GraalPythonScriptEngine.class);
+
+ interface MagicBindingsOptionSetter {
+
+ String getOptionKey();
+
+ Context.Builder setOption(Builder builder, Object value);
+ }
+
+ private final GraalPythonScriptEngineFactory factory;
+ private final Context.Builder contextConfig;
+
+ GraalPythonScriptEngine(GraalPythonScriptEngineFactory factory) {
+ this(factory, factory.getPolyglotEngine(), null);
+ }
+
+ GraalPythonScriptEngine(GraalPythonScriptEngineFactory factory, Engine engine, Context.Builder contextConfig) {
+ Engine engineToUse = (engine != null) ? engineToUse = engine : factory.getPolyglotEngine();
+ // this.factory = (factory == null) ? new GraalPythonScriptEngineFactory(engineToUse) : factory;
+
+ Context.Builder contextConfigToUse = contextConfig;
+ if (contextConfigToUse == null) {
+ contextConfigToUse = Context.newBuilder(LANGUAGE_ID) //
+ .allowExperimentalOptions(true) //
+ .allowAllAccess(true) //
+ .allowHostAccess(HostAccess.ALL) //
+ // allow creating python threads
+ .allowCreateThread(true) //
+ // allow running Python native extensions
+ .allowNativeAccess(true) //
+ // allow exporting Python values to polyglot bindings and accessing Java
+ // choose the backend for the POSIX module
+ .option(PYTHON_OPTION_POSIXMODULEBACKEND, "java") //
+ // equivalent to the Python -B flag
+ .option(PYTHON_OPTION_DONTWRITEBYTECODEFLAG, "true") //
+ // Force to automatically import site.py module, to make Python packages available
+ .option(PYTHON_OPTION_FORCEIMPORTSITE, "true") //
+ // causes the interpreter to always assume hash-based pycs are valid
+ .option(PYTHON_OPTION_CHECKHASHPYCSMODE, "never");
+ }
+ this.factory = (factory == null) ? new GraalPythonScriptEngineFactory(engineToUse) : factory;
+ this.contextConfig = contextConfigToUse.engine(engineToUse);
+ this.context.setBindings(new GraalPythonBindings(this.contextConfig, this.context, this),
+ ScriptContext.ENGINE_SCOPE);
+ }
+
+ static Context createDefaultContext(Context.Builder builder, ScriptContext ctxt) {
+ return builder.build();
+ }
+
+ /**
+ * Closes the current context and makes it unusable. Operations performed after closing will
+ * throw an {@link IllegalStateException}.
+ */
+ @Override
+ public void close() {
+ logger.debug("GraalPythonScriptEngine closed");
+
+ Bindings bindings = this.getBindings(ScriptContext.ENGINE_SCOPE);
+ if (bindings instanceof GraalPythonBindings pythonBingings) {
+ // Break circular reference. Not sure if this is really needed.
+ pythonBingings.updateEngineScriptContext(null);
+ pythonBingings.close();
+ }
+
+ getPolyglotContext().close();
+ }
+
+ /**
+ * Returns the polyglot engine associated with this script engine.
+ */
+ public Engine getPolyglotEngine() {
+ return factory.getPolyglotEngine();
+ }
+
+ public Context getPolyglotContext() {
+ return getOrCreateGraalPythonBindings(context).getContext();
+ }
+
+ static Value evalInternal(Context context, String script) {
+ return context.eval(Source.newBuilder(LANGUAGE_ID, script, "internal-script").internal(true).buildLiteral());
+ }
+
+ @Override
+ public Bindings createBindings() {
+ return new GraalPythonBindings(contextConfig, null, this);
+ }
+
+ @Override
+ public void setBindings(Bindings bindings, int scope) {
+ if (scope == ScriptContext.ENGINE_SCOPE) {
+ Bindings oldBindings = getBindings(scope);
+ if (oldBindings instanceof GraalPythonBindings) {
+ ((GraalPythonBindings) oldBindings).updateEngineScriptContext(null);
+ }
+ }
+ super.setBindings(bindings, scope);
+ if (scope == ScriptContext.ENGINE_SCOPE && (bindings instanceof GraalPythonBindings)) {
+ ((GraalPythonBindings) bindings).updateEngineScriptContext(getContext());
+ }
+ }
+
+ @Override
+ public Object eval(Reader reader, ScriptContext ctxt) throws ScriptException {
+ return eval(createSource(read(reader), ctxt), ctxt);
+ }
+
+ static String read(Reader reader) throws ScriptException {
+ final StringBuilder builder = new StringBuilder();
+ final char[] buffer = new char[1024];
+ try {
+ try {
+ while (true) {
+ final int count = reader.read(buffer);
+ if (count == -1) {
+ break;
+ }
+ builder.append(buffer, 0, count);
+ }
+ } finally {
+ reader.close();
+ }
+ return builder.toString();
+ } catch (IOException ioex) {
+ throw new ScriptException(ioex);
+ }
+ }
+
+ @Override
+ public Object eval(String script, ScriptContext ctxt) throws ScriptException {
+ return eval(createSource(script, ctxt), ctxt);
+ }
+
+ private static Source createSource(String script, ScriptContext ctxt) throws ScriptException {
+ final Object val = ctxt.getAttribute(ScriptEngine.FILENAME);
+ if (val == null) {
+ return Source.newBuilder(LANGUAGE_ID, script, "").buildLiteral();
+ } else {
+ try {
+ return Source.newBuilder(LANGUAGE_ID, new File(val.toString())).content(script).build();
+ } catch (IOException ioex) {
+ throw new ScriptException(ioex);
+ }
+ }
+ }
+
+ private Object eval(Source source, ScriptContext scriptContext) throws ScriptException {
+ GraalPythonBindings engineBindings = getOrCreateGraalPythonBindings(scriptContext);
+ Context polyglotContext = engineBindings.getContext();
+ try {
+ return polyglotContext.eval(source).as(Object.class);
+ } catch (PolyglotException e) {
+ throw toScriptException(e);
+ }
+ }
+
+ private static ScriptException toScriptException(PolyglotException ex) {
+ ScriptException sex;
+ if (ex.isHostException()) {
+ Throwable hostException = ex.asHostException();
+ // ScriptException (unlike almost any other exception) does not
+ // accept Throwable cause (requires the cause to be Exception)
+ Exception cause;
+ if (hostException instanceof Exception) {
+ cause = (Exception) hostException;
+ } else {
+ cause = new Exception(hostException);
+ }
+ // Make the host exception accessible through the cause chain
+ sex = new ScriptException(cause);
+ // Re-use the stack-trace of PolyglotException (with guest-language stack-frames)
+ sex.setStackTrace(ex.getStackTrace());
+ } else {
+ SourceSection sourceSection = ex.getSourceLocation();
+ if (sourceSection != null && sourceSection.isAvailable()) {
+ Source source = sourceSection.getSource();
+ String fileName = source.getPath();
+ if (fileName == null) {
+ fileName = source.getName();
+ }
+ int lineNo = sourceSection.getStartLine();
+ int columnNo = sourceSection.getStartColumn();
+ sex = new ScriptException(ex.getMessage(), fileName, lineNo, columnNo);
+ sex.initCause(ex);
+ } else {
+ sex = new ScriptException(ex);
+ }
+ }
+ return sex;
+ }
+
+ private GraalPythonBindings getOrCreateGraalPythonBindings(ScriptContext scriptContext) {
+ Bindings engineB = scriptContext.getBindings(ScriptContext.ENGINE_SCOPE);
+ if (engineB instanceof GraalPythonBindings) {
+ return ((GraalPythonBindings) engineB);
+ } else {
+ GraalPythonBindings bindings = new GraalPythonBindings(createContext(engineB), scriptContext, this);
+ bindings.putAll(engineB);
+ return bindings;
+ }
+ }
+
+ private Context createContext(Bindings engineB) {
+ Object ctx = engineB.get(POLYGLOT_CONTEXT);
+ if (!(ctx instanceof Context)) {
+ Context.Builder builder = contextConfig;
+ ctx = createDefaultContext(builder, context);
+ engineB.put(POLYGLOT_CONTEXT, ctx);
+ }
+ return (Context) ctx;
+ }
+
+ @Override
+ public GraalPythonScriptEngineFactory getFactory() {
+ return factory;
+ }
+
+ @Override
+ public Object invokeMethod(Object thiz, String name, Object... args) throws ScriptException, NoSuchMethodException {
+ if (thiz == null) {
+ throw new IllegalArgumentException("thiz is not a valid object.");
+ }
+ GraalPythonBindings engineBindings = getOrCreateGraalPythonBindings(context);
+ Value thisValue = engineBindings.getContext().asValue(thiz);
+
+ if (!thisValue.canInvokeMember(name)) {
+ if (!thisValue.hasMember(name)) {
+ throw noSuchMethod(name);
+ } else {
+ throw notCallable(name);
+ }
+ }
+ try {
+ return thisValue.invokeMember(name, args).as(Object.class);
+ } catch (PolyglotException e) {
+ throw toScriptException(e);
+ }
+ }
+
+ @Override
+ public Object invokeFunction(String name, Object... args) throws ScriptException, NoSuchMethodException {
+ GraalPythonBindings engineBindings = getOrCreateGraalPythonBindings(context);
+ Value function = engineBindings.getContext().getBindings(LANGUAGE_ID).getMember(name);
+
+ if (function == null) {
+ throw noSuchMethod(name);
+ } else if (!function.canExecute()) {
+ throw notCallable(name);
+ }
+ try {
+ return function.execute(args).as(Object.class);
+ } catch (PolyglotException e) {
+ throw toScriptException(e);
+ }
+ }
+
+ private static NoSuchMethodException noSuchMethod(String name) throws NoSuchMethodException {
+ throw new NoSuchMethodException(name);
+ }
+
+ private static NoSuchMethodException notCallable(String name) throws NoSuchMethodException {
+ throw new NoSuchMethodException(name + " is not a function");
+ }
+
+ @Override
+ public T getInterface(Class clasz) {
+ checkInterface(clasz);
+ return getInterfaceInner(evalInternal(getPolyglotContext(), "this"), clasz);
+ }
+
+ @Override
+ public T getInterface(Object thiz, Class clasz) {
+ if (thiz == null) {
+ throw new IllegalArgumentException("this cannot be null");
+ }
+ checkInterface(clasz);
+ Value thisValue = getPolyglotContext().asValue(thiz);
+ checkThis(thisValue);
+ return getInterfaceInner(thisValue, clasz);
+ }
+
+ private static void checkInterface(Class> clasz) {
+ if (clasz == null || !clasz.isInterface()) {
+ throw new IllegalArgumentException("interface Class expected in getInterface");
+ }
+ }
+
+ private static void checkThis(Value thiz) {
+ if (thiz.isHostObject() || !thiz.hasMembers()) {
+ throw new IllegalArgumentException("getInterface cannot be called on non-script object");
+ }
+ }
+
+ private static T getInterfaceInner(Value thiz, Class iface) {
+ if (!isInterfaceImplemented(iface, thiz)) {
+ return null;
+ }
+ return thiz.as(iface);
+ }
+
+ @Override
+ public CompiledScript compile(String script) throws ScriptException {
+ Source source = createSource(script, getContext());
+ return compile(source);
+ }
+
+ @Override
+ public CompiledScript compile(Reader reader) throws ScriptException {
+ Source source = createSource(read(reader), getContext());
+ return compile(source);
+ }
+
+ private CompiledScript compile(Source source) throws ScriptException {
+ checkSyntax(source);
+ return new CompiledScript() {
+ @Override
+ public ScriptEngine getEngine() {
+ return GraalPythonScriptEngine.this;
+ }
+
+ @Override
+ public Object eval(ScriptContext ctx) throws ScriptException {
+ return GraalPythonScriptEngine.this.eval(source, ctx);
+ }
+ };
+ }
+
+ private void checkSyntax(Source source) throws ScriptException {
+ try {
+ getPolyglotContext().parse(source);
+ } catch (PolyglotException pex) {
+ throw toScriptException(pex);
+ }
+ }
+
+ /**
+ * Creates a new GraalPython script engine from a polyglot Engine instance with a base configuration
+ * for new polyglot {@link Context} instances. Polyglot context instances can be accessed from
+ * {@link ScriptContext} instances using {@link #getPolyglotContext()}. The
+ * {@link Builder#out(OutputStream) out},{@link Builder#err(OutputStream) err} and
+ * {@link Builder#in(InputStream) in} stream configuration are not inherited from the provided
+ * polyglot context config. Instead {@link ScriptContext} output and input streams are used.
+ *
+ * @param engine the engine to be used for context configurations or null
if a
+ * default engine should be used.
+ * @param newContextConfig a base configuration to create new context instances or
+ * null
if the default configuration should be used to construct new
+ * context instances.
+ */
+ public static GraalPythonScriptEngine create(Engine engine, Context.Builder newContextConfig) {
+ return new GraalPythonScriptEngine(null, engine, newContextConfig);
+ }
+
+ private static boolean isInterfaceImplemented(final Class> iface, final Value obj) {
+ for (final Method method : iface.getMethods()) {
+ // ignore methods of java.lang.Object class
+ if (method.getDeclaringClass() == Object.class) {
+ continue;
+ }
+
+ // skip check for default methods - non-abstract, interface methods
+ if (!Modifier.isAbstract(method.getModifiers())) {
+ continue;
+ }
+
+ if (!obj.canInvokeMember(method.getName())) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngineFactory.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngineFactory.java
new file mode 100644
index 0000000000000..d9e2148dc5750
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/graal/GraalPythonScriptEngineFactory.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.graal;
+
+import java.lang.ref.WeakReference;
+import java.util.List;
+import java.util.Objects;
+
+import javax.script.ScriptEngine;
+import javax.script.ScriptEngineFactory;
+
+import org.graalvm.polyglot.Engine;
+import org.graalvm.polyglot.Language;
+
+/**
+ * @author Holger Hees - Initial contribution
+ * @author Jeff James - Initial contribution
+ */
+public final class GraalPythonScriptEngineFactory implements ScriptEngineFactory {
+ private WeakReference defaultEngine;
+ private final Engine userDefinedEngine;
+
+ private static final String ENGINE_NAME = "Graal.py";
+ private static final String NAME = "python3";
+
+ private static final String[] EXTENSIONS = { "py" };
+
+ public GraalPythonScriptEngineFactory() {
+ super();
+ this.userDefinedEngine = null;
+
+ defaultEngine = new WeakReference(createDefaultEngine());
+ }
+
+ public GraalPythonScriptEngineFactory(Engine engine) {
+ this.defaultEngine = null;
+ this.userDefinedEngine = engine;
+ }
+
+ private static Engine createDefaultEngine() {
+ return Engine.newBuilder() //
+ .allowExperimentalOptions(true) //
+ .option("engine.WarnInterpreterOnly", "false") //
+ .build();
+ }
+
+ /**
+ * Returns the underlying polyglot engine.
+ */
+ public Engine getPolyglotEngine() {
+ if (userDefinedEngine != null) {
+ return userDefinedEngine;
+ } else {
+ Engine engine = defaultEngine == null ? null : defaultEngine.get();
+ if (engine == null) {
+ engine = createDefaultEngine();
+ defaultEngine = new WeakReference<>(engine);
+ }
+ return engine;
+ }
+ }
+
+ @Override
+ public String getEngineName() {
+ return ENGINE_NAME;
+ }
+
+ @Override
+ public String getEngineVersion() {
+ return getPolyglotEngine().getVersion();
+ }
+
+ @Override
+ public List getExtensions() {
+ return List.of(EXTENSIONS);
+ }
+
+ @Override
+ public List getMimeTypes() {
+ Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID);
+ return List.copyOf(language.getMimeTypes());
+ }
+
+ @Override
+ public List getNames() {
+ Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID);
+ return List.of(language.getName(), GraalPythonScriptEngine.LANGUAGE_ID, language.getImplementationName());
+ }
+
+ @Override
+ public String getLanguageName() {
+ Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID);
+ return language.getName();
+ }
+
+ @Override
+ public String getLanguageVersion() {
+ Language language = getPolyglotEngine().getLanguages().get(GraalPythonScriptEngine.LANGUAGE_ID);
+ return language.getVersion();
+ }
+
+ @Override
+ public Object getParameter(String key) {
+ switch (key) {
+ case ScriptEngine.NAME:
+ return NAME;
+ case ScriptEngine.ENGINE:
+ return getEngineName();
+ case ScriptEngine.ENGINE_VERSION:
+ return getEngineVersion();
+ case ScriptEngine.LANGUAGE:
+ return getLanguageName();
+ case ScriptEngine.LANGUAGE_VERSION:
+ return getLanguageVersion();
+ default:
+ return null;
+ }
+ }
+
+ @Override
+ public GraalPythonScriptEngine getScriptEngine() {
+ return new GraalPythonScriptEngine(this);
+ }
+
+ @Override
+ public String getMethodCallSyntax(final String obj, final String method, final String... args) {
+ Objects.requireNonNull(obj);
+ Objects.requireNonNull(method);
+ final StringBuilder sb = new StringBuilder().append(obj).append('.').append(method).append('(');
+ final int len = args.length;
+
+ if (len > 0) {
+ Objects.requireNonNull(args[0]);
+ sb.append(args[0]);
+ }
+ for (int i = 1; i < len; i++) {
+ Objects.requireNonNull(args[i]);
+ sb.append(',').append(args[i]);
+ }
+ sb.append(')');
+
+ return sb.toString();
+ }
+
+ @Override
+ public String getOutputStatement(final String toDisplay) {
+ return "print(\"" + toDisplay + "\")";
+ }
+
+ @Override
+ public String getProgram(final String... statements) {
+ final StringBuilder sb = new StringBuilder();
+
+ for (final String statement : statements) {
+ Objects.requireNonNull(statement);
+ sb.append(statement).append(';');
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/package-info.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/package-info.java
new file mode 100644
index 0000000000000..ab104eb1d43f0
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+@org.osgi.annotation.bundle.Header(name = org.osgi.framework.Constants.DYNAMICIMPORT_PACKAGE, value = "*")
+package org.openhab.automation.pythonscripting.internal;
+
+/**
+ * Additional information for the Python Scripting package
+ *
+ * @author Holger Hees - Initial contribution
+ */
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/AbstractScriptExtensionProvider.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/AbstractScriptExtensionProvider.java
new file mode 100644
index 0000000000000..bd1dbf5f04c24
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/AbstractScriptExtensionProvider.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.scope;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.module.script.ScriptExtensionProvider;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Activate;
+
+/**
+ * Base class to offer support for script extension providers
+ *
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+@NonNullByDefault
+public abstract class AbstractScriptExtensionProvider implements ScriptExtensionProvider {
+ private Map> types = new HashMap<>();
+ private Map> idToTypes = new ConcurrentHashMap<>();
+
+ protected abstract String getPresetName();
+
+ protected abstract void initializeTypes(final BundleContext context);
+
+ protected void addType(String name, Function value) {
+ types.put(name, value);
+ }
+
+ @Activate
+ public void activate(final BundleContext context) {
+ types.clear();
+ initializeTypes(context);
+ }
+
+ @Override
+ public Collection getDefaultPresets() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public Collection getPresets() {
+ return Set.of(getPresetName());
+ }
+
+ @Override
+ public Collection getTypes() {
+ return types.keySet();
+ }
+
+ @Override
+ public @Nullable Object get(String scriptIdentifier, String type) throws IllegalArgumentException {
+ Map forScript = idToTypes.computeIfAbsent(scriptIdentifier, k -> new HashMap<>());
+ return forScript.computeIfAbsent(type,
+ k -> Objects.nonNull(types.get(k)) ? types.get(k).apply(scriptIdentifier) : null);
+ }
+
+ @Override
+ public Map importPreset(String scriptIdentifier, String preset) {
+ if (getPresetName().equals(preset)) {
+ Map results = new HashMap<>(types.size());
+ for (String type : types.keySet()) {
+ Object value = get(scriptIdentifier, type);
+ if (value != null) {
+ results.put(type, value);
+ }
+ }
+ return results;
+ }
+
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public void unload(String scriptIdentifier) {
+ // ignore by default
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/OSGiScriptExtensionProvider.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/OSGiScriptExtensionProvider.java
new file mode 100644
index 0000000000000..847da61e15549
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scope/OSGiScriptExtensionProvider.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.scope;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.automation.module.script.ScriptExtensionProvider;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * ScriptExtensionProvider which provides various functions to help scripts to work with OSGi
+ *
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+@NonNullByDefault
+@Component(immediate = true, service = ScriptExtensionProvider.class)
+public class OSGiScriptExtensionProvider extends AbstractScriptExtensionProvider {
+
+ @Override
+ protected String getPresetName() {
+ return "osgi";
+ }
+
+ @Override
+ protected void initializeTypes(final BundleContext context) {
+ addType("bundleContext", k -> context);
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable.java
new file mode 100644
index 0000000000000..ebc8daea361f1
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.scriptengine;
+
+import java.io.Reader;
+
+import javax.script.Bindings;
+import javax.script.Compilable;
+import javax.script.CompiledScript;
+import javax.script.Invocable;
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.ScriptEngineFactory;
+import javax.script.ScriptException;
+
+/**
+ * {@link ScriptEngine} implementation that delegates to a supplied ScriptEngine instance. Allows overriding specific
+ * methods.
+ *
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+public abstract class DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable
+ implements ScriptEngine, Invocable, Compilable, AutoCloseable {
+ protected T delegate;
+
+ @Override
+ public Object eval(String s, ScriptContext scriptContext) throws ScriptException {
+ return delegate.eval(s, scriptContext);
+ }
+
+ @Override
+ public Object eval(Reader reader, ScriptContext scriptContext) throws ScriptException {
+ return delegate.eval(reader, scriptContext);
+ }
+
+ @Override
+ public Object eval(String s) throws ScriptException {
+ return delegate.eval(s);
+ }
+
+ @Override
+ public Object eval(Reader reader) throws ScriptException {
+ return delegate.eval(reader);
+ }
+
+ @Override
+ public Object eval(String s, Bindings bindings) throws ScriptException {
+ return delegate.eval(s, bindings);
+ }
+
+ @Override
+ public Object eval(Reader reader, Bindings bindings) throws ScriptException {
+ return delegate.eval(reader, bindings);
+ }
+
+ @Override
+ public void put(String s, Object o) {
+ delegate.put(s, o);
+ }
+
+ @Override
+ public Object get(String s) {
+ return delegate.get(s);
+ }
+
+ @Override
+ public Bindings getBindings(int i) {
+ return delegate.getBindings(i);
+ }
+
+ @Override
+ public void setBindings(Bindings bindings, int i) {
+ delegate.setBindings(bindings, i);
+ }
+
+ @Override
+ public Bindings createBindings() {
+ return delegate.createBindings();
+ }
+
+ @Override
+ public ScriptContext getContext() {
+ return delegate.getContext();
+ }
+
+ @Override
+ public void setContext(ScriptContext scriptContext) {
+ delegate.setContext(scriptContext);
+ }
+
+ @Override
+ public ScriptEngineFactory getFactory() {
+ return delegate.getFactory();
+ }
+
+ @Override
+ public Object invokeMethod(Object o, String s, Object... objects)
+ throws ScriptException, NoSuchMethodException, IllegalArgumentException {
+ return delegate.invokeMethod(o, s, objects);
+ }
+
+ @Override
+ public Object invokeFunction(String s, Object... objects) throws ScriptException, NoSuchMethodException {
+ return delegate.invokeFunction(s, objects);
+ }
+
+ @Override
+ public T getInterface(Class aClass) {
+ return delegate.getInterface(aClass);
+ }
+
+ @Override
+ public T getInterface(Object o, Class aClass) {
+ return delegate.getInterface(o, aClass);
+ }
+
+ @Override
+ public CompiledScript compile(String s) throws ScriptException {
+ return delegate.compile(s);
+ }
+
+ @Override
+ public CompiledScript compile(Reader reader) throws ScriptException {
+ return delegate.compile(reader);
+ }
+
+ @Override
+ public void close() throws Exception {
+ delegate.close();
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.java
new file mode 100644
index 0000000000000..a10640e19e978
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.scriptengine;
+
+import java.io.Reader;
+import java.lang.reflect.UndeclaredThrowableException;
+
+import javax.script.Bindings;
+import javax.script.Compilable;
+import javax.script.CompiledScript;
+import javax.script.Invocable;
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.ScriptException;
+
+/**
+ * Delegate allowing AOP-style interception of calls, either before Invocation, or upon a {@link ScriptException} being
+ * thrown.
+ *
+ * @param The delegate class
+ * @author Holger Hees - Initial contribution (Reused from jsscripting)
+ */
+public abstract class InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable
+ extends DelegatingScriptEngineWithInvocableAndCompilableAndAutocloseable {
+
+ protected String beforeInvocation(String source) {
+ beforeInvocation();
+ return source;
+ }
+
+ protected Reader beforeInvocation(Reader reader) {
+ beforeInvocation();
+ return reader;
+ }
+
+ protected void beforeInvocation() {
+ }
+
+ protected Object afterInvocation(Object obj) {
+ return obj;
+ }
+
+ protected Exception afterThrowsInvocation(Exception e) {
+ return e;
+ }
+
+ @Override
+ public Object eval(String s, ScriptContext scriptContext) throws ScriptException {
+ try {
+ return afterInvocation(super.eval(beforeInvocation(s), scriptContext));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object eval(Reader reader, ScriptContext scriptContext) throws ScriptException {
+ try {
+ return afterInvocation(super.eval(beforeInvocation(reader), scriptContext));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object eval(String s) throws ScriptException {
+ try {
+ return afterInvocation(super.eval(beforeInvocation(s)));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object eval(Reader reader) throws ScriptException {
+ try {
+ return afterInvocation(super.eval(beforeInvocation(reader)));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object eval(String s, Bindings bindings) throws ScriptException {
+ try {
+ return afterInvocation(super.eval(beforeInvocation(s), bindings));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object eval(Reader reader, Bindings bindings) throws ScriptException {
+ try {
+ return afterInvocation(super.eval(beforeInvocation(reader), bindings));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object invokeMethod(Object o, String s, Object... objects)
+ throws ScriptException, NoSuchMethodException, NullPointerException, IllegalArgumentException {
+ try {
+ beforeInvocation();
+ return afterInvocation(super.invokeMethod(o, s, objects));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (NoSuchMethodException e) { // Make sure to unlock on exceptions from Invocable.invokeMethod to avoid
+ // deadlocks
+ throw (NoSuchMethodException) afterThrowsInvocation(e);
+ } catch (NullPointerException e) {
+ throw (NullPointerException) afterThrowsInvocation(e);
+ } catch (IllegalArgumentException e) {
+ throw (IllegalArgumentException) afterThrowsInvocation(e);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public Object invokeFunction(String s, Object... objects)
+ throws ScriptException, NoSuchMethodException, NullPointerException {
+ try {
+ beforeInvocation();
+ return afterInvocation(super.invokeFunction(s, objects));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (NoSuchMethodException e) { // Make sure to unlock on exceptions from Invocable.invokeFunction to avoid
+ // deadlocks
+ throw (NoSuchMethodException) afterThrowsInvocation(e);
+ } catch (NullPointerException e) {
+ throw (NullPointerException) afterThrowsInvocation(e);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public CompiledScript compile(String s) throws ScriptException {
+ try {
+ return wrapCompiledScript((CompiledScript) afterInvocation(super.compile(beforeInvocation(s))));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ @Override
+ public CompiledScript compile(Reader reader) throws ScriptException {
+ try {
+ return wrapCompiledScript((CompiledScript) afterInvocation(super.compile(beforeInvocation(reader))));
+ } catch (ScriptException se) {
+ throw (ScriptException) afterThrowsInvocation(se);
+ } catch (Exception e) {
+ throw new UndeclaredThrowableException(afterThrowsInvocation(e)); // Wrap and rethrow other exceptions
+ }
+ }
+
+ private CompiledScript wrapCompiledScript(CompiledScript script) throws ScriptException {
+ return new CompiledScript() {
+ @Override
+ public ScriptEngine getEngine() {
+ return InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable.this;
+ }
+
+ @Override
+ public Object eval(ScriptContext context) throws ScriptException {
+ return script.eval(context);
+ }
+ };
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LifecycleTracker.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LifecycleTracker.java
new file mode 100644
index 0000000000000..75fc6701d9ad1
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LifecycleTracker.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.scriptengine.helper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * LifecycleTracker implementation
+ *
+ * @author Holger Hees - Initial contribution
+ */
+@NonNullByDefault
+public class LifecycleTracker {
+ List disposables = new ArrayList<>();
+
+ public void addDisposeHook(Runnable disposable) {
+ disposables.add(disposable);
+ }
+
+ public void dispose() {
+ for (Runnable disposable : disposables) {
+ disposable.run();
+ }
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LogOutputStream.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LogOutputStream.java
new file mode 100644
index 0000000000000..bff0ab70864a0
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/scriptengine/helper/LogOutputStream.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.scriptengine.helper;
+
+import java.io.OutputStream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.event.Level;
+
+/**
+ * LogOutputStream implementation
+ *
+ * @author Holger Hees - Initial contribution
+ */
+@NonNullByDefault
+public class LogOutputStream extends OutputStream {
+ private static final int DEFAULT_BUFFER_LENGTH = 2048;
+ private static final String LINE_SEPARATOR = System.lineSeparator();
+ private static final int LINE_SEPARATOR_SIZE = LINE_SEPARATOR.length();
+
+ private Logger logger;
+ private Level level;
+
+ private int bufLength;
+ private byte[] buf;
+ private int count;
+
+ public LogOutputStream(Logger logger, Level level) {
+ this.logger = logger;
+ this.level = level;
+
+ bufLength = DEFAULT_BUFFER_LENGTH;
+ buf = new byte[DEFAULT_BUFFER_LENGTH];
+ count = 0;
+ }
+
+ public void setLogger(Logger logger) {
+ this.logger = logger;
+ }
+
+ public Logger getLogger() {
+ return logger;
+ }
+
+ @Override
+ public void write(int b) {
+ // don't log nulls
+ if (b == 0) {
+ return;
+ }
+
+ if (count == bufLength) {
+ growBuffer();
+ }
+
+ buf[count] = (byte) b;
+ count++;
+ }
+
+ @Override
+ public void flush() {
+ if (count == 0) {
+ return;
+ }
+
+ // don't print out blank lines;
+ if (count == LINE_SEPARATOR_SIZE) {
+ if (((char) buf[0]) == LINE_SEPARATOR.charAt(0)
+ && ((count == 1) || ((count == 2) && ((char) buf[1]) == LINE_SEPARATOR.charAt(1)))) {
+ reset();
+ return;
+ }
+ } else if (count > LINE_SEPARATOR_SIZE) {
+ // remove linebreaks at the end
+ if (((char) buf[count - 1]) == LINE_SEPARATOR.charAt(LINE_SEPARATOR_SIZE - 1)
+ && ((LINE_SEPARATOR_SIZE == 1) || ((LINE_SEPARATOR_SIZE == 2)
+ && ((char) buf[count - 1]) == LINE_SEPARATOR.charAt(LINE_SEPARATOR_SIZE - 2)))) {
+ count -= LINE_SEPARATOR_SIZE;
+ }
+ }
+
+ final byte[] line = new byte[count];
+ System.arraycopy(buf, 0, line, 0, count);
+ logger.atLevel(level).log(new String(line));
+ reset();
+ }
+
+ private void growBuffer() {
+ final int newBufLength = bufLength + DEFAULT_BUFFER_LENGTH;
+ final byte[] newBuf = new byte[newBufLength];
+ System.arraycopy(buf, 0, newBuf, 0, bufLength);
+ buf = newBuf;
+ bufLength = newBufLength;
+ }
+
+ private void reset() {
+ // don't shrink buffer. assuming that if it grew that it will likely grow similarly again
+ count = 0;
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ModuleLocator.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ModuleLocator.java
new file mode 100644
index 0000000000000..ccf80a73fd95e
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ModuleLocator.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.wrapper;
+
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Locates modules from a module name
+ *
+ * @author Holger Hees - Initial contribution
+ */
+@NonNullByDefault
+public interface ModuleLocator {
+ Map locateModule(String name, List fromlist);
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ScriptExtensionModuleProvider.java b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ScriptExtensionModuleProvider.java
new file mode 100644
index 0000000000000..277a78d0c450d
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/java/org/openhab/automation/pythonscripting/internal/wrapper/ScriptExtensionModuleProvider.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2010-2025 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.automation.pythonscripting.internal.wrapper;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.locks.Lock;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.graalvm.polyglot.Context;
+import org.openhab.core.automation.module.script.ScriptExtensionAccessor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class providing script extensions via CommonJS modules (with module name `@runtime`).
+ *
+ * @author Jonathan Gilbert - Initial contribution
+ * @author Florian Hotze - Pass in lock object for multi-thread synchronization; Switch to {@link Lock} for multi-thread
+ * synchronization
+ */
+
+@NonNullByDefault
+public class ScriptExtensionModuleProvider {
+
+ public static final String IMPORT_PROXY_NAME = "__import_proxy__";
+
+ public static final String OENHAB_MODULE_PREFIX = "org.openhab";
+ public static final String SCOPE_MODULE_PREFIX = "scope";
+
+ private final Logger logger = LoggerFactory.getLogger(ScriptExtensionModuleProvider.class);
+
+ private Map globals = new HashMap();
+
+ public ScriptExtensionModuleProvider() {
+ }
+
+ public ModuleLocator locatorFor(Context ctx, String engineIdentifier,
+ ScriptExtensionAccessor scriptExtensionAccessor) {
+ return (name, fromlist) -> {
+ Map symbols = new HashMap();
+ if (name.startsWith(OENHAB_MODULE_PREFIX)) {
+ List classList = new ArrayList();
+ if (!fromlist.isEmpty() && fromlist.contains("*")) {
+ logger.error("Wildcard support of java packages not supported");
+ } else {
+ if (fromlist.isEmpty()) {
+ classList.add(name);
+ } else {
+ for (String from : fromlist) {
+ classList.add(name + "." + from);
+ }
+ }
+ }
+ symbols.put("class_list", classList);
+ } else if (name.startsWith(SCOPE_MODULE_PREFIX)) {
+ String[] segments = name.split("\\.");
+ if (name.equals(SCOPE_MODULE_PREFIX)) {
+ Map possibleSymbols = new HashMap(this.globals);
+
+ if (fromlist.isEmpty() || fromlist.contains("*")) {
+ symbols = possibleSymbols;
+ } else {
+ symbols = possibleSymbols.entrySet().stream().filter(x -> fromlist.contains(x.getKey()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+
+ List filteredFromlist = fromlist.stream().filter(x -> !possibleSymbols.containsKey(x))
+ .toList();
+
+ if (!filteredFromlist.isEmpty()) {
+ for (String from : filteredFromlist) {
+ Map fromSymbols = scriptExtensionAccessor.findPreset(from,
+ engineIdentifier);
+ if (!fromSymbols.isEmpty()) {
+ symbols.put(from, fromSymbols);
+ }
+ }
+ }
+ }
+ } else {
+ return scriptExtensionAccessor.findPreset(segments[1], engineIdentifier);
+ }
+ }
+ return symbols;
+ };
+ }
+
+ public void put(String key, Object value) {
+ this.globals.put(key, value);
+ }
+}
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 0000000000000..dbd18acd28cdb
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,13 @@
+
+
+
+ automation
+ Python Scripting
+ This adds a Python script engine.
+ none
+
+ org.openhab.automation.pythonscripting
+
+
diff --git a/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/config/config.xml
new file mode 100644
index 0000000000000..fc785750b0d0a
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/src/main/resources/OH-INF/config/config.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+ This group defines Python's environment.
+ true
+
+
+
+
+ This group defines Python's system behavior.
+ true
+
+
+
+
+
+ An scope module is an encapsulated module containing all openhab jsr223 objects and can be imported with 'import scope'
+ Additionally you can run an import like 'from org.openhab.core import OpenHAB'
+ ]]>
+ true
+ true
+
+
+
+
+ If disabled, the openHAB Python helper module can be installed manually by copying it to /conf/automation/python/lib/openhab"
+ ]]>
+ true
+
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ Dependency tracking allows your scripts to automatically reload when one of its dependencies is updated.
+ You may want to disable dependency tracking if you plan on editing or updating a shared library, but don't want all
+ your scripts to reload until you can test it.
+ true
+ true
+
+
+
+
+ Disable this option will result in a slower startup performance, because scripts have to be recompiled every.
+ ]]>
+ true
+ true
+
+
+
+ Jython Migration Guide.
+ ]]>
+ false
+ true
+
+
+
diff --git a/bundles/org.openhab.automation.pythonscripting/suppressions.properties b/bundles/org.openhab.automation.pythonscripting/suppressions.properties
new file mode 100644
index 0000000000000..c423ca5862672
--- /dev/null
+++ b/bundles/org.openhab.automation.pythonscripting/suppressions.properties
@@ -0,0 +1,3 @@
+# Please check here how to add suppressions https://maven.apache.org/plugins/maven-pmd-plugin/examples/violation-exclusions.html
+org.openhab.automation.pythonscripting.internal.OpenhabGraalPythonScriptEngine=UnusedPrivateField
+org.openhab.automation.pythonscripting.internal.scriptengine.InvocationInterceptingScriptEngineWithInvocableAndCompilableAndAutoCloseable=AvoidThrowingNullPointerException,AvoidCatchingNPE
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 97e43d8a0ec55..49e6b08bdc35b 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -25,6 +25,7 @@
org.openhab.automation.jythonscripting
org.openhab.automation.pidcontroller
org.openhab.automation.pwm
+ org.openhab.automation.pythonscripting
org.openhab.io.homekit
org.openhab.io.hueemulation