[gemini] Initial contribution (#20922)

* [gemini] Add binding skeleton

Signed-off-by: Florian Hotze <dev@florianhotze.com>
This commit is contained in:
Florian Hotze
2026-06-19 20:45:13 +02:00
committed by GitHub
parent f363606478
commit 9be9822b9f
35 changed files with 2209 additions and 0 deletions
+1
View File
@@ -144,6 +144,7 @@
/bundles/org.openhab.binding.ftpupload/ @paulianttila
/bundles/org.openhab.binding.gardena/ @gerrieg @andrewfg
/bundles/org.openhab.binding.gce/ @clinique
/bundles/org.openhab.binding.gemini/ @florian-h05
/bundles/org.openhab.binding.generacmobilelink/ @digitaldan
/bundles/org.openhab.binding.globalcache/ @mhilbush
/bundles/org.openhab.binding.goecharger/ @SamuelBrucksch
+5
View File
@@ -706,6 +706,11 @@
<artifactId>org.openhab.binding.gce</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.gemini</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.generacmobilelink</artifactId>
+13
View File
@@ -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
@@ -0,0 +1,182 @@
# Google Gemini Binding
The openHAB Google Gemini Binding allows openHAB to communicate with the Gemini family of large language models (LLM) from Google.
Google Gemini is a powerful natural language processing (NLP) tool that can be used to understand and respond to a wide range of text-based commands and questions.
This binding provides the following features:
- Control openHAB with natural language commands: Turn on the lights, control the TV or check the status of the garage door.
- Multi-Language Support: Issue commands and ask questions in almost any language.
- Chat with a LLM: Ask questions and receive informational responses.
## Supported Things
The binding supports a single thing type `account`, which corresponds to the Google Gemini account that is to be used for the integration.
## Discovery
Discovery is not supported.
Things must be added manually.
## Thing Configuration
The `account` thing requires the API key that allows accessing the account.
API keys can be created and managed under Google AI Studio: <https://aistudio.google.com/app/apikey>.
| Name | Type | Description | Default | Required | Advanced |
|-----------------|---------|------------------------------------------------------------------------------------------------------------------------------------------|------------------|----------|----------|
| apiKey | text | The API key to authenticate against the Gemini API. | N/A | yes | no |
| requestTimeout | integer | Timeout in seconds for chat API requests. | 30 | no | yes |
| model | text | The model to be used ([Models](https://ai.google.dev/gemini-api/docs/models), [Pricing](https://ai.google.dev/gemini-api/docs/pricing)). | gemini-2.5-flash | no | no |
| temperature | decimal | A value between 0.0 and 1.0, where higher values make the output more random and lower values make it more focused and deterministic. | 1.0 | no | yes |
| topP | decimal | A value between 0.0 and 1.0 for nucleus sampling, where the model considers the results of the tokens with topP probability mass. | 1.0 | no | yes |
| maxOutputTokens | integer | The maximum number of tokens to include in a candidate. | 2048 | no | yes |
It is generally recommended to either alter temperature or topP, but not both.
For Gemini 3.x models, Google recommends keeping both values at their default.
## Channels
The `account` thing comes with a single `chat` channel of type `chat` by default.
Additional channels can be added to the thing, allowing specifying different models and parameters for each channel.
Channels of type `chat` take the following configuration parameters:
| Name | Type | Description | Required | Advanced |
|-----------------|---------|------------------------------------------------------------------------------------------------|----------|----------|
| model | text | The model to be used for the responses, overriding the thing-level model. | no | no |
| systemMessage | text | The system message helps set the behavior of the assistant. | no | no |
| temperature | decimal | A value between 0.0 and 2.0, which overrides the thing-level temperature. | no | yes |
| topP | decimal | A value between 0.0 and 1.0, which overrides the thing-level topP. | no | yes |
| maxOutputTokens | integer | The maximum number of tokens to include in a candidate, overriding the thing-level max tokens. | no | yes |
Channel configuration defaults to the [Thing configuration](#thing-configuration), except for `systemMessage`, which defaults to _You are a helpful assistant_.
## Thing Actions
The binding provides actions to interact with the Google Gemini API directly from rules.
The first parameter when retrieving actions must always be `gemini` and the second must be the full Thing UID of the Gemini account.
You can retrieve the actions as follows:
:::: tabs
::: tab DSL
```java
val geminiActions = getActions("gemini", "gemini:account:myaccount")
```
:::
::: tab JS
```javascript
var geminiActions = actions.thingActions("gemini", "gemini:account:myaccount");
```
:::
::::
### Available Actions
The `account` Thing provides the following actions:
| Action Signature | Return Type | Description |
|----------------------------------------------------------------------------------------------------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `sendMessage(String prompt)` | `String` | Sends a prompt to Gemini using the Thing's configured model and parameters, and returns the response text, or `null` if the request failed. |
| `sendMessage(String prompt, String model)` | `String` | Sends a prompt to Gemini using the specified model, falling back to default parameters, and returns the response text, or `null` if the request failed. |
| `sendMessage(String prompt, String model, String systemMessage, Double temperature, Double topP, Integer maxOutputTokens, Integer requestTimeout)` | `String` | Sends a prompt with detailed generation settings (where null values fall back to [Thing configuration](#thing-configuration)), and returns the response text, or `null` if the request failed. |
### Examples
:::: tabs
::: tab DSL
```java
val geminiActions = getActions("gemini", "gemini:account:myaccount")
// 1. Simple sendMessage
val response1 = geminiActions.sendMessage("What is the capital of France?")
logInfo("Gemini", "Response 1: " + response1)
// 2. sendMessage with model override
val response2 = geminiActions.sendMessage("Write a poem about openHAB.", "gemini-2.5-flash")
logInfo("Gemini", "Response 2: " + response2)
// 3. sendMessage with custom parameters (use null for default values)
val response3 = geminiActions.sendMessage(
"How does electricity work?",
"gemini-2.5-flash",
"Explain it to a 5-year-old.", // system message
0.7, // temperature
null, // topP (default)
500, // maxOutputTokens
60 // requestTimeout
)
logInfo("Gemini", "Response 3: " + response3)
```
:::
::: tab JS
```javascript
const geminiActions = actions.thingActions("gemini", "gemini:account:myaccount");
// 1. Simple sendMessage
const response1 = geminiActions.sendMessage("What is the capital of France?");
console.info("Gemini Response 1: " + response1);
// 2. sendMessage with model override
const response2 = geminiActions.sendMessage("Write a poem about openHAB.", "gemini-2.5-flash");
console.info("Gemini Response 2: " + response2);
// 3. sendMessage with custom parameters (use null for default values)
const response3 = geminiActions.sendMessage(
"How does electricity work?",
"gemini-2.5-flash",
"Explain it to a 5-year-old.", // system message
0.7, // temperature
null, // topP (default)
500, // maxOutputTokens
60 // requestTimeout
);
console.info("Gemini Response 3: " + response3);
```
:::
::::
## Human Language Interpreter
The `account` Thing automatically registers a human language interpreter implementation.
To configure the Gemini HLI as default, go to _Settings__Voice_ and select _Gemini Human Language Interpreter_ as default.
In that place, you can also configure the system prompt used to instruct the LLM on how to process the user's input.
The used model, temperature, topP and maximum output tokens parameters can be configured in the thing configuration.
For more information on human language interpreters, refer to the [Voice documentation]({{base}}/docs/configuration/multimedia.html#human-language-interpreter).
## Full Example
### Thing Configuration
```java
Thing gemini:account:myaccount [apiKey="xxx-yyy-zzz", model="gemini-2.5-flash", temperature=1.0, topP=1.0, maxOutputTokens=2048, requestTimeout=30] {
Channels:
Type chat : chat "Chat" [model="gemini-2.5-flash", temperature=1.0, topP=1.0, maxOutputTokens=2048, systemMessage="You are a helpful assistant."]
}
```
Additional channels of type `chat` can be added to the thing.
### Item Configuration
```java
String GeminiChat "Gemini Chat" { channel="gemini:account:myaccount:chat" }
```
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.gemini</artifactId>
<name>openHAB Add-ons :: Bundles :: Gemini Binding</name>
</project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.gemini-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.6.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-gemini" description="Gemini Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.gemini/${project.version}</bundle>
</feature>
</features>
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.type.ChannelTypeUID;
/**
* The {@link GeminiBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiBindingConstants {
public static final String BINDING_ID = "gemini";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
// List of all Channel ids
public static final String CHANNEL_CHAT = "chat";
public static final ChannelTypeUID CHANNEL_TYPE_UID_CHAT = new ChannelTypeUID(BINDING_ID, CHANNEL_CHAT);
// Default values for configuration parameters
public static final String DEFAULT_MODEL = "gemini-2.5-flash";
public static final double DEFAULT_TEMPERATURE = 1.0;
public static final double DEFAULT_TOP_P = 1.0;
public static final int DEFAULT_MAX_OUTPUT_TOKENS = 2048;
public static final String DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant.";
public static final int DEFAULT_REQUEST_TIMEOUT = 30;
// Default values as string needed for annotations
public static final String DEFAULT_TEMPERATURE_STR = "1.0";
public static final String DEFAULT_TOP_P_STR = "1.0";
public static final String MAX_OUTPUT_TOKENS_STR = "2048";
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link GeminiChannelConfiguration} class contains the configuration for a Gemini chat channel.
* Values can be null/empty, in which case they fall back to the defaults from the thing-type.xml.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiChannelConfiguration {
public @Nullable String model;
public @Nullable Double temperature;
public @Nullable Double topP;
public @Nullable Integer maxOutputTokens;
public @Nullable String systemMessage;
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MAX_OUTPUT_TOKENS;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MODEL;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_REQUEST_TIMEOUT;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_TEMPERATURE;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_TOP_P;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link GeminiConfiguration} class contains the configuration for the Gemini account thing.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiConfiguration {
// Authentication
public String apiKey = "";
// Connection
public int requestTimeout = DEFAULT_REQUEST_TIMEOUT;
// HLI
public String model = DEFAULT_MODEL;
public double temperature = DEFAULT_TEMPERATURE;
public double topP = DEFAULT_TOP_P;
public int maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS;
}
@@ -0,0 +1,236 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.gemini.internal.action.GeminiActions;
import org.openhab.binding.gemini.internal.api.GeminiApiClient;
import org.openhab.binding.gemini.internal.api.GeminiApiException;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiModel;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
import org.openhab.binding.gemini.internal.hli.GeminiHLIService;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link GeminiHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(GeminiHandler.class);
private final HttpClient httpClient;
private @Nullable GeminiConfiguration config;
private @Nullable GeminiApiClient apiClient;
private List<GeminiModel> models = List.of();
private @Nullable ScheduledFuture<?> initFuture;
public GeminiHandler(Thing thing, HttpClientFactory httpClientFactory) {
super(thing);
this.httpClient = httpClientFactory.getCommonHttpClient();
}
public @Nullable GeminiConfiguration getGeminiConfiguration() {
return this.config;
}
public @Nullable GeminiApiClient getApiClient() {
return apiClient;
}
public List<GeminiModel> getModels() {
return models;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
Channel channel = getThing().getChannel(channelUID);
if (channel != null && CHANNEL_TYPE_UID_CHAT.equals(channel.getChannelTypeUID())
&& command instanceof StringType stringCommand) {
String lastPrompt = stringCommand.toFullString();
GeminiConfiguration config = this.config;
GeminiApiClient client = apiClient;
if (client != null) {
int timeout = config != null ? config.requestTimeout : DEFAULT_REQUEST_TIMEOUT;
String model = (config != null && !config.model.isBlank()) ? config.model : DEFAULT_MODEL;
double temp = config != null ? config.temperature : DEFAULT_TEMPERATURE;
double topP = config != null ? config.topP : DEFAULT_TOP_P;
int maxTokens = config != null ? config.maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS;
String systemMessage = DEFAULT_SYSTEM_MESSAGE;
GeminiChannelConfiguration channelConfig = channel.getConfiguration()
.as(GeminiChannelConfiguration.class);
String channelModel = channelConfig.model;
if (channelModel != null && !channelModel.isBlank()) {
model = channelModel;
}
Double channelTemp = channelConfig.temperature;
if (channelTemp != null) {
temp = channelTemp;
}
Double channelTopP = channelConfig.topP;
if (channelTopP != null) {
topP = channelTopP;
}
Integer channelMaxTokens = channelConfig.maxOutputTokens;
if (channelMaxTokens != null) {
maxTokens = channelMaxTokens;
}
String channelSystemMessage = channelConfig.systemMessage;
if (channelSystemMessage != null && !channelSystemMessage.isBlank()) {
systemMessage = channelSystemMessage;
}
try {
GeminiResponse response = client.sendPrompt(model, lastPrompt, systemMessage, temp, topP, maxTokens,
timeout);
processChatResponse(channelUID, response);
updateStatus(ThingStatus.ONLINE);
} catch (GeminiApiException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Could not connect to Gemini API: " + e.getMessage());
logger.debug("Request to Gemini failed: {}", e.getMessage(), e);
}
}
}
}
private void processChatResponse(ChannelUID channelUID, GeminiResponse response) {
String msg = response.getFirstText();
if (msg != null) {
updateState(channelUID, new StringType(msg));
} else {
logger.warn("Didn't receive any chat response candidates from Gemini - this is unexpected.");
}
}
@Override
public void initialize() {
ScheduledFuture<?> future = initFuture;
if (future != null) {
future.cancel(true);
this.initFuture = null;
}
final GeminiConfiguration c = getConfigAs(GeminiConfiguration.class);
this.config = c;
String apiKey = c.apiKey;
if (apiKey.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.configuration-error");
return;
}
if (invalidTimeout(c.requestTimeout)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/requestTimeout.configuration-error");
return;
}
this.apiClient = new GeminiApiClient(httpClient, apiKey);
updateStatus(ThingStatus.UNKNOWN);
this.initFuture = scheduler.schedule(() -> {
try {
GeminiApiClient client = this.apiClient;
GeminiConfiguration currentConfig = this.config;
if (client == null || currentConfig == null) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.configuration-error");
return;
}
List<GeminiModel> apiModels = client.fetchModels(currentConfig.requestTimeout);
if (!apiModels.isEmpty()) {
List<GeminiModel> modelList = new ArrayList<>();
Set<String> seenNames = new HashSet<>();
for (GeminiModel model : apiModels) {
String name = model.name();
if (name != null) {
List<String> methods = model.supportedGenerationMethods();
if (methods != null && methods.contains("generateContent")) {
String cleanName = name.replace("models/", "");
String lowerName = cleanName.toLowerCase(Locale.ROOT);
if (lowerName.startsWith("gemini") || lowerName.startsWith("gemma")) {
if (seenNames.add(cleanName)) {
modelList.add(model);
}
}
}
}
}
this.models = List.copyOf(modelList);
updateStatus(ThingStatus.ONLINE);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.communication-error");
}
} catch (GeminiApiException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}, 0, TimeUnit.MILLISECONDS);
}
@Override
public void dispose() {
ScheduledFuture<?> future = initFuture;
if (future != null) {
future.cancel(true);
this.initFuture = null;
}
super.dispose();
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return List.of(GeminiModelOptionProvider.class, GeminiActions.class, GeminiHLIService.class);
}
private boolean invalidTimeout(int timeout) {
return timeout <= 0;
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.THING_TYPE_ACCOUNT;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link GeminiHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.gemini", service = ThingHandlerFactory.class)
public class GeminiHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT);
private HttpClientFactory httpClientFactory;
@Activate
public GeminiHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
this.httpClientFactory = httpClientFactory;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
return new GeminiHandler(thing, httpClientFactory);
}
return null;
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.CHANNEL_TYPE_UID_CHAT;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.THING_TYPE_ACCOUNT;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.config.core.ConfigOptionProvider;
import org.openhab.core.config.core.ParameterOption;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ServiceScope;
/**
* The {@link GeminiModelOptionProvider} provides the available models from Gemini as configuration options.
*
* @author Florian Hotze - Initial contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = { GeminiModelOptionProvider.class, ConfigOptionProvider.class })
@NonNullByDefault
public class GeminiModelOptionProvider implements ThingHandlerService, ConfigOptionProvider {
private @Nullable ThingHandler thingHandler;
@Override
public void setThingHandler(ThingHandler handler) {
this.thingHandler = handler;
}
@Override
public @Nullable ThingHandler getThingHandler() {
return thingHandler;
}
@Override
public @Nullable Collection<ParameterOption> getParameterOptions(URI uri, String param, @Nullable String context,
@Nullable Locale locale) {
if (CHANNEL_TYPE_UID_CHAT.getAsString().equals(uri.getSchemeSpecificPart())
|| THING_TYPE_ACCOUNT.getAsString().equals(uri.getSchemeSpecificPart())) {
if ("model".equals(param)) {
List<ParameterOption> options = new ArrayList<>();
if (thingHandler instanceof GeminiHandler geminiHandler) {
geminiHandler.getModels().forEach(model -> {
String name = model.name();
if (name != null) {
String cleanName = name.replace("models/", "");
String displayName = model.displayName();
String label = displayName != null ? displayName : cleanName;
options.add(new ParameterOption(cleanName, label));
}
});
}
return options;
}
}
return null;
}
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.action;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.*;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.gemini.internal.GeminiConfiguration;
import org.openhab.binding.gemini.internal.GeminiHandler;
import org.openhab.binding.gemini.internal.api.GeminiApiClient;
import org.openhab.binding.gemini.internal.api.GeminiApiException;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
import org.openhab.core.automation.annotation.ActionInput;
import org.openhab.core.automation.annotation.ActionOutput;
import org.openhab.core.automation.annotation.RuleAction;
import org.openhab.core.thing.binding.ThingActions;
import org.openhab.core.thing.binding.ThingActionsScope;
import org.openhab.core.thing.binding.ThingHandler;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ServiceScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link GeminiActions} class provides Thing Actions for the Gemini binding.
*
* @author Florian Hotze - Initial contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = GeminiActions.class)
@ThingActionsScope(name = "gemini")
@NonNullByDefault
public class GeminiActions implements ThingActions {
private final Logger logger = LoggerFactory.getLogger(GeminiActions.class);
private @Nullable GeminiHandler handler;
@Override
public void setThingHandler(@Nullable ThingHandler handler) {
if (handler instanceof GeminiHandler geminiHandler) {
this.handler = geminiHandler;
} else {
this.handler = null;
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return handler;
}
public @Nullable String sendMessage(@Nullable String prompt) {
return sendMessage(prompt, null);
}
public @Nullable String sendMessage(@Nullable String prompt, @Nullable String model) {
return sendMessage(prompt, model, null, null, null, null, null);
}
@RuleAction(label = "@text/action.sendMessage.label", description = "@text/action.sendMessage.description")
public @Nullable @ActionOutput(label = "@text/action.sendMessage.result.label", description = "@text/action.sendMessage.result.description", type = "java.lang.String") String sendMessage(
@ActionInput(name = "prompt", label = "@text/action.sendMessage.prompt.label", description = "@text/action.sendMessage.prompt.description", type = "java.lang.String", required = true) @Nullable String prompt,
@ActionInput(name = "model", label = "@text/action.sendMessage.model.label", description = "@text/action.sendMessage.model.description", type = "java.lang.String") @Nullable String model,
@ActionInput(name = "systemMessage", label = "@text/action.sendMessage.systemMessage.label", description = "@text/action.sendMessage.systemMessage.description", type = "java.lang.String") @Nullable String systemMessage,
@ActionInput(name = "temperature", label = "@text/action.sendMessage.temperature.label", description = "@text/action.sendMessage.temperature.description", type = "java.lang.Double") @Nullable Double temperature,
@ActionInput(name = "topP", label = "@text/action.sendMessage.topP.label", description = "@text/action.sendMessage.topP.description", type = "java.lang.Double") @Nullable Double topP,
@ActionInput(name = "maxOutputTokens", label = "@text/action.sendMessage.maxOutputTokens.label", description = "@text/action.sendMessage.maxOutputTokens.description", type = "java.lang.Integer") @Nullable Integer maxOutputTokens,
@ActionInput(name = "requestTimeout", label = "@text/action.sendMessage.requestTimeout.label", description = "@text/action.sendMessage.requestTimeout.description", type = "java.lang.Integer") @Nullable Integer requestTimeout) {
if (prompt == null || prompt.isBlank()) {
logger.warn("Cannot send message: prompt is null or blank.");
return null;
}
GeminiHandler geminiHandler = handler;
if (geminiHandler == null) {
logger.warn("Cannot send message: Gemini handler is not initialized.");
return null;
}
GeminiApiClient apiClient = geminiHandler.getApiClient();
if (apiClient == null) {
logger.warn("Cannot send message: Gemini API client is not initialized.");
return null;
}
GeminiConfiguration config = geminiHandler.getGeminiConfiguration();
String resolvedModel = (model != null && !model.isBlank()) ? model
: ((config != null && !config.model.isBlank()) ? config.model : DEFAULT_MODEL);
String resolvedSystemMessage = Objects.requireNonNullElse(systemMessage, DEFAULT_SYSTEM_MESSAGE);
double resolvedTemperature = Objects.requireNonNullElse(temperature,
config != null ? config.temperature : DEFAULT_TEMPERATURE);
double resolvedTopP = Objects.requireNonNullElse(topP, config != null ? config.topP : DEFAULT_TOP_P);
int resolvedMaxOutputTokens = Objects.requireNonNullElse(maxOutputTokens,
config != null ? config.maxOutputTokens : DEFAULT_MAX_OUTPUT_TOKENS);
int resolvedRequestTimeout = Objects.requireNonNullElse(requestTimeout,
config != null ? config.requestTimeout : DEFAULT_REQUEST_TIMEOUT);
try {
GeminiResponse response = apiClient.sendPrompt(resolvedModel, prompt, resolvedSystemMessage,
resolvedTemperature, resolvedTopP, resolvedMaxOutputTokens, resolvedRequestTimeout);
String text = response.getFirstText();
if (text != null) {
return text;
}
logger.warn("Didn't receive any chat response candidates from Gemini - this is unexpected.");
} catch (GeminiApiException e) {
logger.debug("Request to Gemini via action failed: {}", e.getMessage(), e);
}
return null;
}
public static @Nullable String sendMessage(ThingActions actions, @Nullable String prompt) {
if (!(actions instanceof GeminiActions geminiActions)) {
throw new IllegalArgumentException("The 'actions' argument is not an instance of GeminiActions");
}
return geminiActions.sendMessage(prompt);
}
public static @Nullable String sendMessage(ThingActions actions, @Nullable String prompt, @Nullable String model) {
if (!(actions instanceof GeminiActions geminiActions)) {
throw new IllegalArgumentException("The 'actions' argument is not an instance of GeminiActions");
}
return geminiActions.sendMessage(prompt, model);
}
public static @Nullable String sendMessage(ThingActions actions, @Nullable String prompt, @Nullable String model,
@Nullable String systemMessage, @Nullable Double temperature, @Nullable Double topP,
@Nullable Integer maxOutputTokens, @Nullable Integer requestTimeout) {
if (!(actions instanceof GeminiActions geminiActions)) {
throw new IllegalArgumentException("The 'actions' argument is not an instance of GeminiActions");
}
return geminiActions.sendMessage(prompt, model, systemMessage, temperature, topP, maxOutputTokens,
requestTimeout);
}
}
@@ -0,0 +1,355 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_REQUEST_TIMEOUT;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Queue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.MimeTypes;
import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
import org.openhab.binding.gemini.internal.api.dto.GeminiFunctionCall;
import org.openhab.binding.gemini.internal.api.dto.GeminiPart;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiFunctionDeclaration;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiFunctionResponse;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiGenerationConfig;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiRequest;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiSchema;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiTool;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiModel;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiModelsResponse;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolCall;
import org.openhab.core.voice.text.interpreter.llm.LLMToolParam;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* The {@link GeminiApiClient} class encapsulates all HTTP/REST API communications with the Google Gemini API.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiApiClient {
private static final String GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
private static final String HEADER_API_KEY = "x-goog-api-key";
private static final String ROLE_USER = "user";
private static final String ROLE_MODEL = "model";
private final Logger logger = LoggerFactory.getLogger(GeminiApiClient.class);
private final HttpClient httpClient;
private final String apiKey;
private final ObjectMapper objectMapper;
public GeminiApiClient(HttpClient httpClient, String apiKey) {
this.httpClient = httpClient;
this.apiKey = apiKey;
this.objectMapper = new ObjectMapper();
this.objectMapper.setDefaultPropertyInclusion(
com.fasterxml.jackson.annotation.JsonInclude.Value.construct(Include.NON_NULL, Include.ALWAYS));
}
/**
* Sends a single prompt to the Gemini API and returns the response DTO.
*
* @param model the Gemini model to use
* @param prompt the prompt text
* @param systemMessage the system instruction text
* @param temperature the temperature config parameter
* @param topP the topP config parameter
* @param maxOutputTokens the maxOutputTokens config parameter
* @param timeoutSeconds request timeout in seconds
* @return the deserialized GeminiResponse
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
*/
public GeminiResponse sendPrompt(String model, String prompt, @Nullable String systemMessage,
@Nullable Double temperature, @Nullable Double topP, @Nullable Integer maxOutputTokens,
@Nullable Integer timeoutSeconds) throws GeminiApiException {
GeminiContent systemInstruction = createSystemInstruction(systemMessage);
// Contents
GeminiPart userPart = new GeminiPart(prompt, null, null, null);
GeminiContent userContent = new GeminiContent(ROLE_USER, List.of(userPart));
// Config
GeminiGenerationConfig genConfig = new GeminiGenerationConfig(maxOutputTokens, temperature, topP, null);
GeminiRequest request = new GeminiRequest(List.of(userContent), systemInstruction, genConfig, null);
return executeGenerateContentRequest(model, request, timeoutSeconds);
}
/**
* Sends a conversation history and tools to the Gemini API and returns the response DTO.
*
* @param model the Gemini model to use
* @param history the conversation message history
* @param tools the available tools
* @param systemMessage the system instruction text
* @param temperature the temperature config parameter
* @param topP the topP config parameter
* @param maxOutputTokens the maxOutputTokens config parameter
* @param timeoutSeconds request timeout in seconds
* @return the deserialized GeminiResponse
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
*/
public GeminiResponse sendPrompt(String model, List<Conversation.Message> history, List<LLMTool> tools,
@Nullable String systemMessage, @Nullable Double temperature, @Nullable Double topP,
@Nullable Integer maxOutputTokens, @Nullable Integer timeoutSeconds) throws GeminiApiException {
GeminiContent systemInstruction = createSystemInstruction(systemMessage);
List<GeminiContent> contents = new ArrayList<>();
Queue<String> pendingToolCallNames = new LinkedList<>();
for (Conversation.Message msg : history) {
switch (msg.role()) {
case USER: {
GeminiPart part = new GeminiPart(msg.content(), null, null, null);
contents.add(new GeminiContent(ROLE_USER, List.of(part)));
break;
}
case OPENHAB: {
GeminiPart part = new GeminiPart(msg.content(), null, null, null);
contents.add(new GeminiContent(ROLE_MODEL, List.of(part)));
break;
}
case TOOL_CALL: {
LLMToolCall toolCall = LLMToolCall.fromJson(msg.content());
String name = toolCall.tool().replaceAll("[^a-zA-Z0-9_-]", "_");
pendingToolCallNames.add(name);
GeminiFunctionCall fc = new GeminiFunctionCall(name, toolCall.params());
GeminiPart part = new GeminiPart(null, fc, null, null);
contents.add(new GeminiContent(ROLE_MODEL, List.of(part)));
break;
}
case TOOL_RETURN: {
String name = pendingToolCallNames.poll();
if (name == null) {
name = "";
}
GeminiFunctionResponse fr = new GeminiFunctionResponse(name, Map.of("result", msg.content()));
GeminiPart part = new GeminiPart(null, null, fr, null);
contents.add(new GeminiContent(ROLE_USER, List.of(part)));
break;
}
case THINKING:
break;
}
}
List<GeminiTool> geminiTools = null;
if (!tools.isEmpty()) {
List<GeminiFunctionDeclaration> functions = new ArrayList<>();
for (LLMTool t : tools) {
Map<String, GeminiSchema> properties = new HashMap<>();
List<String> required = new ArrayList<>();
for (LLMToolParam p : t.getParamDescriptions(null)) {
List<String> enumValues = p.options().isEmpty() ? null : p.options();
String typeStr = p.type().name().toLowerCase(Locale.ROOT);
GeminiSchema itemsSchema = null;
if ("array".equals(typeStr)) {
itemsSchema = new GeminiSchema("string");
}
GeminiSchema prop = new GeminiSchema(typeStr, p.description(), null, null, enumValues, itemsSchema);
properties.put(p.name(), prop);
if (p.required()) {
required.add(p.name());
}
}
GeminiSchema params = new GeminiSchema("object", null, properties, required.isEmpty() ? null : required,
null, null);
String fName = t.getUID().replaceAll("[^a-zA-Z0-9_-]", "_");
String fDesc = t.getDescription(null);
functions.add(new GeminiFunctionDeclaration(fName, fDesc, params));
}
geminiTools = List.of(new GeminiTool(functions));
}
GeminiGenerationConfig genConfig = new GeminiGenerationConfig(maxOutputTokens, temperature, topP, null);
GeminiRequest request = new GeminiRequest(contents, systemInstruction, genConfig, geminiTools);
return executeGenerateContentRequest(model, request, timeoutSeconds);
}
/**
* Executes an HTTP POST request to the <code>generateContent</code> endpoint of a Gemini model.
*
* <p>
* If the request fails with 503 Service Unavailable, up to two retries are performed with with an increasing delay.
*
* @param model the Gemini model to use
* @param requestPayload the payload to send
* @param timeoutSeconds request timeout in seconds
* @return the deserialized GeminiResponse
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
*/
private GeminiResponse executeGenerateContentRequest(String model, GeminiRequest requestPayload,
@Nullable Integer timeoutSeconds) throws GeminiApiException {
String url = GEMINI_API_BASE_URL + "/models/" + model + ":generateContent";
String queryJson;
try {
queryJson = objectMapper.writeValueAsString(requestPayload);
} catch (JsonProcessingException e) {
throw new GeminiApiException("Failed to serialize Gemini request: " + e.getMessage(), e);
}
int attemptCount = 1;
while (true) {
Request request = httpClient.newRequest(url).method(HttpMethod.POST)
.timeout((Objects.requireNonNullElse(timeoutSeconds, DEFAULT_REQUEST_TIMEOUT)), TimeUnit.SECONDS)
.header(HttpHeader.CONTENT_TYPE, MimeTypes.Type.APPLICATION_JSON.asString())
.header(HEADER_API_KEY, apiKey)
.content(new StringContentProvider(queryJson, StandardCharsets.UTF_8));
if (logger.isDebugEnabled()) {
try {
String prettyJson = objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(requestPayload);
logger.debug("Request to {} (attempt {}): \n{}", url, attemptCount, prettyJson);
} catch (JsonProcessingException e) {
logger.debug("Request to {} (attempt {}): {}", url, attemptCount, queryJson);
}
}
try {
ContentResponse response = request.send();
if (response.getStatus() == HttpStatus.OK_200) {
String responseBody = response.getContentAsString();
if (logger.isDebugEnabled()) {
logger.debug("Response from {}: {}", url, responseBody);
}
try {
@Nullable
GeminiResponse geminiResponse = readNullableValue(responseBody, GeminiResponse.class);
if (geminiResponse == null) {
throw new GeminiApiException("Failed to parse Gemini response: response was null");
}
return geminiResponse;
} catch (JsonProcessingException e) {
throw new GeminiApiException("Failed to parse Gemini response: " + e.getMessage(), e);
}
} else if (response.getStatus() == HttpStatus.SERVICE_UNAVAILABLE_503 && attemptCount <= 3) {
logger.debug("Gemini request failed with 503 Service Unavailable on attempt #{}/3", attemptCount);
try {
Thread.sleep(1000 * attemptCount);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GeminiApiException(
"Interrupted while waiting to retry Gemini API request: " + e.getMessage(), e);
}
attemptCount++;
} else {
logger.debug("Gemini request failed on the final attempt with HTTP {} {}: {}", response.getStatus(),
response.getReason(), response.getContentAsString());
throw new GeminiApiException("Gemini generateContent request resulted failed with HTTP "
+ response.getStatus() + " " + response.getReason());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GeminiApiException("Could not connect to Gemini API: " + e.getMessage(), e);
} catch (TimeoutException | ExecutionException e) {
throw new GeminiApiException("Could not connect to Gemini API: " + e.getMessage(), e);
}
}
}
/**
* Fetches the list of models supported by the Gemini API key.
*
* @param timeoutSeconds request timeout in seconds
* @return a list of GeminiModel objects
* @throws GeminiApiException if a communication error, timeout, or parsing error occurs
*/
public List<GeminiModel> fetchModels(@Nullable Integer timeoutSeconds) throws GeminiApiException {
String modelsUrl = GEMINI_API_BASE_URL + "/models";
Request request = httpClient.newRequest(modelsUrl)
.timeout(Objects.requireNonNullElse(timeoutSeconds, DEFAULT_REQUEST_TIMEOUT), TimeUnit.SECONDS)
.method(HttpMethod.GET).header(HEADER_API_KEY, apiKey);
logger.debug("Request to {}: (GET)", modelsUrl);
try {
ContentResponse response = request.send();
if (response.getStatus() == HttpStatus.OK_200) {
String responseBody = response.getContentAsString();
if (logger.isDebugEnabled()) {
logger.debug("Response from {}: {}", modelsUrl, responseBody);
}
try {
@Nullable
GeminiModelsResponse modelsResponse = readNullableValue(responseBody, GeminiModelsResponse.class);
if (modelsResponse == null) {
throw new GeminiApiException("Failed to parse models response DTO: response was null");
}
List<GeminiModel> models = modelsResponse.models();
if (models != null) {
return models;
}
} catch (JsonProcessingException e) {
throw new GeminiApiException("Failed to parse models response DTO: " + e.getMessage(), e);
}
} else {
throw new GeminiApiException(
"Gemini request for models resulted in HTTP status " + response.getStatus());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GeminiApiException("Could not retrieve models from Gemini API: " + e.getMessage(), e);
} catch (TimeoutException | ExecutionException e) {
throw new GeminiApiException("Could not retrieve models from Gemini API: " + e.getMessage(), e);
}
return new ArrayList<>();
}
private @Nullable GeminiContent createSystemInstruction(@Nullable String systemMessage) {
if (systemMessage != null && !systemMessage.isBlank()) {
GeminiPart sysPart = new GeminiPart(systemMessage, null, null, null);
return new GeminiContent(null, List.of(sysPart));
}
return null;
}
private <T> @Nullable T readNullableValue(String content, Class<T> valueType) throws JsonProcessingException {
return objectMapper.readValue(content, valueType);
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link GeminiApiException} class represents an exception thrown during communication
* with the Google Gemini API.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class GeminiApiException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
public GeminiApiException(String message) {
super(message);
}
public GeminiApiException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiContent} record represents the structured content of a message in Gemini API requests
* and responses, associating a role (e.g., "user" or "model") with a list of content parts.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiContent(@Nullable String role, @Nullable List<GeminiPart> parts) {
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiFunctionCall} record represents a function call request generated by the model,
* indicating the function name and arguments to be executed.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiFunctionCall(@Nullable String name, @Nullable Map<String, Object> args) {
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.gemini.internal.api.dto.request.GeminiFunctionResponse;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiPart} record represents a single part of content, which may contain text, a function call,
* or a function response.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiPart(@Nullable String text, @Nullable GeminiFunctionCall functionCall,
@Nullable GeminiFunctionResponse functionResponse, @Nullable Boolean thought) {
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiFunctionDeclaration} record defines a tool's function signature, description,
* and parameters schema that the model can request to execute.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiFunctionDeclaration(@Nullable String name, @Nullable String description,
@Nullable GeminiSchema parameters) {
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiFunctionResponse} record represents the execution result of a function call
* sent back to the model as context.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiFunctionResponse(@Nullable String name, @Nullable Map<String, Object> response) {
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiGenerationConfig} record contains the configuration parameters for controlling
* content generation, such as temperature, topP, and max output tokens.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiGenerationConfig(@Nullable Integer maxOutputTokens, @Nullable Double temperature,
@Nullable Double topP, @Nullable GeminiThinkingConfig thinkingConfig) {
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiRequest} record represents the payload for a content generation request, including
* chat history, system instructions, tools, and generation configurations.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiRequest(@Nullable List<GeminiContent> contents, @Nullable GeminiContent systemInstruction,
@Nullable GeminiGenerationConfig generationConfig, @Nullable List<GeminiTool> tools) {
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* The {@link GeminiSchema} record represents a JSON schema definition for tool parameters and properties.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(Include.NON_NULL)
@NonNullByDefault
public record GeminiSchema(String type, @Nullable String description, @Nullable Map<String, GeminiSchema> properties,
@Nullable List<String> required, @JsonProperty("enum") @Nullable List<String> enumValues,
@Nullable GeminiSchema items) {
public GeminiSchema(String type) {
this(type, null, null, null, null, null);
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiThinkingConfig} record contains the parameters for controlling the thinking behavior
* of Gemini models that support reasoning.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiThinkingConfig(@Nullable Boolean includeThoughts, @Nullable Integer thinkingBudget,
@Nullable String thinkingLevel) {
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.request;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiTool} record defines the list of functions/declarations made available to the model.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiTool(@Nullable List<GeminiFunctionDeclaration> functionDeclarations) {
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiCandidate} record represents a single candidate generation/response from the Gemini model.
* It contains the generated content and the reason generation finished.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiCandidate(@Nullable GeminiContent content, @Nullable String finishReason) {
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.response;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiModel} record represents details of a model returned from the models list endpoint.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiModel(@Nullable String name, @Nullable String version, @Nullable String displayName,
@Nullable String description, @Nullable List<String> supportedGenerationMethods) {
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.response;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiModelsResponse} record represents the response payload containing the list of available
* Gemini models.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiModelsResponse(@Nullable List<GeminiModel> models) {
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.response;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiResponse} record represents the response payload for a content generation request,
* containing the candidates generated by the model and usage metadata.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiResponse(@Nullable List<GeminiCandidate> candidates, @Nullable GeminiUsageMetadata usageMetadata) {
public @Nullable String getFirstText() {
List<GeminiCandidate> candidatesList = candidates;
if (candidatesList != null && !candidatesList.isEmpty()) {
GeminiCandidate candidate = candidatesList.getFirst();
var content = candidate.content();
if (content != null) {
var parts = content.parts();
if (parts != null && !parts.isEmpty()) {
var part = parts.getFirst();
return part.text();
}
}
}
return null;
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.api.dto.response;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* The {@link GeminiUsageMetadata} record represents token usage metadata for the request and response.
*
* @author Florian Hotze - Initial contribution
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@NonNullByDefault
public record GeminiUsageMetadata(@Nullable Integer promptTokenCount, @Nullable Integer candidatesTokenCount,
@Nullable Integer totalTokenCount, @Nullable Integer thoughtsTokenCount) {
}
@@ -0,0 +1,264 @@
/*
* Copyright (c) 2010-2026 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.binding.gemini.internal.hli;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.BINDING_ID;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_MODEL;
import static org.openhab.binding.gemini.internal.GeminiBindingConstants.DEFAULT_SYSTEM_MESSAGE;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.gemini.internal.GeminiConfiguration;
import org.openhab.binding.gemini.internal.GeminiHandler;
import org.openhab.binding.gemini.internal.api.GeminiApiClient;
import org.openhab.binding.gemini.internal.api.GeminiApiException;
import org.openhab.binding.gemini.internal.api.dto.GeminiContent;
import org.openhab.binding.gemini.internal.api.dto.GeminiFunctionCall;
import org.openhab.binding.gemini.internal.api.dto.GeminiPart;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiCandidate;
import org.openhab.binding.gemini.internal.api.dto.response.GeminiResponse;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.InterpreterContext;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolCall;
import org.openhab.core.voice.text.interpreter.llm.LLMToolException;
import org.osgi.service.component.annotations.Component;
/**
* The {@link GeminiHLIService} is a {@link HumanLanguageInterpreter} implementation based on Google Gemini.
*
* @author Florian Hotze - Initial contribution
*/
@Component(service = { GeminiHLIService.class, HumanLanguageInterpreter.class })
@NonNullByDefault
public class GeminiHLIService implements ThingHandlerService, HumanLanguageInterpreter {
private static final String LABEL = "Gemini Human Language Interpreter";
private @Nullable GeminiHandler handler;
@Override
public void setThingHandler(@Nullable ThingHandler handler) {
if (handler instanceof GeminiHandler geminiHandler) {
this.handler = geminiHandler;
} else {
this.handler = null;
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return handler;
}
@Override
public String getId() {
return BINDING_ID;
}
@Override
public String getLabel(@Nullable Locale locale) {
return LABEL;
}
@Override
public Set<Locale> getSupportedLocales() {
return Set.of();
}
@Override
public Set<String> getSupportedGrammarFormats() {
return Set.of();
}
@Override
public @Nullable String getGrammar(Locale locale, String format) {
return null;
}
@Override
public String interpret(Locale locale, String text) throws InterpretationException {
GeminiHandler geminiHandler = handler;
if (geminiHandler == null) {
throw new InterpretationException("GeminiHandler is not initialized");
}
GeminiApiClient apiClient = geminiHandler.getApiClient();
if (apiClient == null) {
throw new InterpretationException("Gemini API client is not available");
}
GeminiConfiguration config = geminiHandler.getGeminiConfiguration();
if (config == null) {
throw new InterpretationException("Gemini HLI configuration is not available");
}
String model = config.model.isBlank() ? DEFAULT_MODEL : config.model;
try {
GeminiResponse geminiResponse = apiClient.sendPrompt(model, text, DEFAULT_SYSTEM_MESSAGE,
config.temperature, config.topP, config.maxOutputTokens, config.requestTimeout);
String response = geminiResponse.getFirstText();
if (response != null) {
return response;
}
} catch (GeminiApiException e) {
var ex = new InterpretationException("Failed to get response from Gemini: " + e.getMessage());
ex.initCause(e);
throw ex;
}
throw new InterpretationException("No valid response received");
}
@Override
public String interpret(Locale locale, InterpreterContext interpreterContext) throws InterpretationException {
GeminiHandler geminiHandler = handler;
if (geminiHandler == null) {
throw new InterpretationException("GeminiHandler is not initialized");
}
GeminiApiClient apiClient = geminiHandler.getApiClient();
if (apiClient == null) {
throw new InterpretationException("Gemini API client is not available");
}
GeminiConfiguration config = geminiHandler.getGeminiConfiguration();
if (config == null) {
throw new InterpretationException("Gemini HLI configuration is not available");
}
Conversation conversation = interpreterContext.conversation();
List<LLMTool> tools = interpreterContext.tools();
String systemMessage = interpreterContext.systemPrompt();
if (systemMessage == null || systemMessage.isBlank()) {
throw new InterpretationException("System prompt is missing or empty");
}
String locationItem = interpreterContext.locationItem();
if (locationItem != null && !locationItem.isBlank()) {
systemMessage = systemMessage.trim() + "\n\n" + "Your location (item name): " + locationItem;
}
if (!tools.isEmpty()) {
String toolGuidance = """
You have tools available to interact with the environment. Use them when appropriate.
However, if the user's request cannot be fulfilled by any tool, or if they ask a general question,
answer them directly using your general knowledge.
Do not try to force a tool call. Do not state that you can only perform actions supported by the tools.
When using a tool, always provide a clear and concise natural language response.
""";
systemMessage = systemMessage.trim() + "\n\n" + toolGuidance;
}
int loopCount = 0;
final int maxLoops = 10;
while (true) {
if (loopCount >= maxLoops) {
throw new InterpretationException(
"Tool execution loop limit exceeded (max " + maxLoops + " iterations)");
}
loopCount++;
String model = config.model.isBlank() ? DEFAULT_MODEL : config.model;
try {
GeminiResponse geminiResponse = apiClient.sendPrompt(model, conversation.getMessages(), tools,
systemMessage, config.temperature, config.topP, config.maxOutputTokens, config.requestTimeout);
List<GeminiCandidate> candidates = geminiResponse.candidates();
if (candidates == null || candidates.isEmpty()) {
throw new InterpretationException("No valid response received");
}
GeminiContent responseContent = candidates.getFirst().content();
if (responseContent == null) {
throw new InterpretationException("No valid response received");
}
List<GeminiPart> parts = responseContent.parts();
if (parts == null) {
throw new InterpretationException("No valid response received");
}
boolean hasToolCall = false;
StringBuilder textBuilder = new StringBuilder();
for (GeminiPart part : parts) {
GeminiFunctionCall fc = part.functionCall();
if (fc != null) {
hasToolCall = true;
String toolName = fc.name();
Map<String, Object> args = fc.args();
LLMToolCall llmToolCall = new LLMToolCall(toolName != null ? toolName : "",
args != null ? args : new HashMap<>());
try {
conversation.addMessage(ConversationRole.TOOL_CALL, llmToolCall.toJson());
} catch (ConversationException e) {
var ex = new InterpretationException("Failed to add TOOL_CALL to conversation");
ex.initCause(e);
throw ex;
}
String result = executeTool(tools, toolName, args, locale);
try {
conversation.addMessage(ConversationRole.TOOL_RETURN, result);
} catch (ConversationException e) {
var ex = new InterpretationException("Failed to add TOOL_RETURN to conversation");
ex.initCause(e);
throw ex;
}
} else if (part.text() != null) {
textBuilder.append(part.text());
}
}
if (!hasToolCall) {
String finalResponse = textBuilder.toString();
try {
conversation.addMessage(ConversationRole.OPENHAB, finalResponse);
} catch (ConversationException e) {
var ex = new InterpretationException("Failed to add OPENHAB message to conversation");
ex.initCause(e);
throw ex;
}
return finalResponse;
}
} catch (GeminiApiException e) {
InterpretationException ex = new InterpretationException(
"Failed to communicate with Gemini: " + e.getMessage());
ex.initCause(e);
throw ex;
}
}
}
private String executeTool(List<LLMTool> tools, @Nullable String toolName, @Nullable Map<String, Object> args,
Locale locale) {
if (toolName == null) {
return "Error: Tool name is null";
}
LLMTool tool = tools.stream().filter(t -> t.getUID().replaceAll("[^a-zA-Z0-9_-]", "_").equals(toolName))
.findFirst().orElse(null);
if (tool == null) {
return "Error: Tool " + toolName + " not found";
}
try {
return tool.call(args != null ? args : new HashMap<>(), locale);
} catch (LLMToolException e) {
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="gemini" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Gemini Binding</name>
<description>Integrates with the Google Gemini API for AI-powered chat and smart home control.</description>
<connection>cloud</connection>
</addon:addon>
@@ -0,0 +1,73 @@
# add-on
addon.gemini.name = Gemini Binding
addon.gemini.description = Integrates with the Google Gemini API for AI-powered chat and smart home control.
# thing types
thing-type.gemini.account.label = Gemini Account
thing-type.gemini.account.description = Integrates the Google Gemini API with openHAB.
# thing types config
thing-type.config.gemini.account.apiKey.label = API Key
thing-type.config.gemini.account.apiKey.description = The Google Gemini API key (Create & Manage API keys in <a href="https://aistudio.google.com/api-keys">Google AI Studio</a>).
thing-type.config.gemini.account.group.authentication.label = Authentication
thing-type.config.gemini.account.group.connection.label = Connection
thing-type.config.gemini.account.group.hli.label = Model Configuration
thing-type.config.gemini.account.group.hli.description = Configures the used model and its behavior. Applies to the Human Language Interpreter and as a default for all channels and Thing actions.
thing-type.config.gemini.account.maxOutputTokens.label = Max Output Tokens
thing-type.config.gemini.account.maxOutputTokens.description = The maximum number of tokens to generate in the response.
thing-type.config.gemini.account.model.label = Model
thing-type.config.gemini.account.model.description = The Google Gemini model to use (<a href="https://ai.google.dev/gemini-api/docs/models">Models</a>, <a href="https://ai.google.dev/gemini-api/docs/pricing">Pricing</a>).
thing-type.config.gemini.account.requestTimeout.label = Request Timeout
thing-type.config.gemini.account.requestTimeout.description = The timeout in seconds for API requests.
thing-type.config.gemini.account.temperature.label = Temperature
thing-type.config.gemini.account.temperature.description = Controls randomness. Higher values mean more random responses.
thing-type.config.gemini.account.topP.label = Top-P
thing-type.config.gemini.account.topP.description = Controls the cumulative probability threshold for token selection.
# channel types
channel-type.gemini.chat.label = Chat
channel-type.gemini.chat.description = Send a prompt to a model and receive a response.
# channel types config
channel-type.config.gemini.chat.maxOutputTokens.label = Max Output Tokens
channel-type.config.gemini.chat.maxOutputTokens.description = The maximum number of tokens to generate in the response.
channel-type.config.gemini.chat.model.label = Model
channel-type.config.gemini.chat.model.description = The Gemini model to use.
channel-type.config.gemini.chat.systemMessage.label = System Message
channel-type.config.gemini.chat.systemMessage.description = The system instruction provided to the model.
channel-type.config.gemini.chat.temperature.label = Temperature
channel-type.config.gemini.chat.temperature.description = Controls randomness. Higher values mean more random responses.
channel-type.config.gemini.chat.topP.label = Top-P
channel-type.config.gemini.chat.topP.description = Controls the cumulative probability threshold for token selection.
# actions
action.sendMessage.label = Send Message
action.sendMessage.description = Sends a message to Gemini and returns the response.
action.sendMessage.prompt.label = Prompt
action.sendMessage.prompt.description = The prompt message to send to Gemini.
action.sendMessage.model.label = Model
action.sendMessage.model.description = The specific Gemini model to use for this message.
action.sendMessage.systemMessage.label = System Message
action.sendMessage.systemMessage.description = The system instruction provided to the model.
action.sendMessage.temperature.label = Temperature
action.sendMessage.temperature.description = The temperature setting to control randomness.
action.sendMessage.topP.label = Top P
action.sendMessage.topP.description = Controls the cumulative probability threshold for token selection.
action.sendMessage.maxOutputTokens.label = Max Output Tokens
action.sendMessage.maxOutputTokens.description = The maximum number of tokens to generate in the response.
action.sendMessage.requestTimeout.label = Request Timeout
action.sendMessage.requestTimeout.description = The timeout in seconds for this request.
action.sendMessage.result.label = Response
action.sendMessage.result.description = The text response returned by Gemini.
# thing status
offline.configuration-error = No API key configured
requestTimeout.configuration-error = Timeout must be greater than zero
offline.communication-error = Could not connect to Gemini API
@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="gemini"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="account" extensible="chat">
<label>Gemini Account</label>
<description>Integrates the Google Gemini API with openHAB.</description>
<channels>
<channel id="chat" typeId="chat"/>
</channels>
<config-description>
<parameter-group name="authentication">
<context>network-security</context>
<label>Authentication</label>
</parameter-group>
<parameter-group name="hli">
<label>Model Configuration</label>
<description>Configures the used model and its behavior. Applies to the Human Language Interpreter and as a default
for all channels and Thing actions.</description>
</parameter-group>
<parameter-group name="connection">
<context>network</context>
<label>Connection</label>
</parameter-group>
<!-- Authentication Settings -->
<parameter name="apiKey" type="text" required="true" groupName="authentication">
<context>password</context>
<label>API Key</label>
<description>
<![CDATA[
The Google Gemini API key (Create & Manage API keys in <a href="https://aistudio.google.com/api-keys">Google AI Studio</a>).
]]>
</description>
</parameter>
<!-- HLI Settings -->
<parameter name="model" type="text" groupName="hli">
<label>Model</label>
<description>
<![CDATA[
The Google Gemini model to use (<a href="https://ai.google.dev/gemini-api/docs/models">Models</a>, <a href="https://ai.google.dev/gemini-api/docs/pricing">Pricing</a>).
]]>
</description>
<default>gemini-2.5-flash</default>
</parameter>
<parameter name="temperature" type="decimal" groupName="hli" min="0.0" max="1.0">
<label>Temperature</label>
<description>Controls randomness. Higher values mean more random responses.</description>
<default>1.0</default>
<advanced>true</advanced>
</parameter>
<parameter name="topP" type="decimal" groupName="hli" min="0.0" max="1.0">
<label>Top-P</label>
<description>Controls the cumulative probability threshold for token selection.</description>
<default>1.0</default>
<advanced>true</advanced>
</parameter>
<parameter name="maxOutputTokens" type="integer" groupName="hli" min="1">
<label>Max Output Tokens</label>
<description>The maximum number of tokens to generate in the response.</description>
<default>2048</default>
<advanced>true</advanced>
</parameter>
<!-- Connection Settings -->
<parameter name="requestTimeout" type="integer" min="1" unit="s" groupName="connection">
<label>Request Timeout</label>
<description>The timeout in seconds for API requests.</description>
<default>30</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<channel-type id="chat">
<item-type>String</item-type>
<label>Chat</label>
<description>Send a prompt to a model and receive a response.</description>
<autoUpdatePolicy>veto</autoUpdatePolicy>
<config-description>
<parameter name="model" type="text">
<label>Model</label>
<description>The Gemini model to use.</description>
</parameter>
<parameter name="temperature" type="decimal" min="0.0" max="2.0">
<label>Temperature</label>
<description>Controls randomness. Higher values mean more random responses.</description>
<advanced>true</advanced>
</parameter>
<parameter name="topP" type="decimal" min="0.0" max="1.0">
<label>Top-P</label>
<description>Controls the cumulative probability threshold for token selection.</description>
<advanced>true</advanced>
</parameter>
<parameter name="maxOutputTokens" type="integer" min="1">
<label>Max Output Tokens</label>
<description>The maximum number of tokens to generate in the response.</description>
<advanced>true</advanced>
</parameter>
<parameter name="systemMessage" type="text">
<label>System Message</label>
<description>The system instruction provided to the model.</description>
<default>You are a helpful assistant.</default>
<advanced>false</advanced>
</parameter>
</config-description>
</channel-type>
</thing:thing-descriptions>
+1
View File
@@ -181,6 +181,7 @@
<module>org.openhab.binding.ftpupload</module>
<module>org.openhab.binding.gardena</module>
<module>org.openhab.binding.gce</module>
<module>org.openhab.binding.gemini</module>
<module>org.openhab.binding.generacmobilelink</module>
<module>org.openhab.binding.goecharger</module>
<module>org.openhab.binding.govee</module>