diff --git a/CODEOWNERS b/CODEOWNERS index 2755e0ac9e..daff67d58c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 463d04bc68..65b60d788f 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -706,6 +706,11 @@ org.openhab.binding.gce ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.gemini + ${project.version} + org.openhab.addons.bundles org.openhab.binding.generacmobilelink diff --git a/bundles/org.openhab.binding.gemini/NOTICE b/bundles/org.openhab.binding.gemini/NOTICE new file mode 100644 index 0000000000..38d625e349 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/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.binding.gemini/README.md b/bundles/org.openhab.binding.gemini/README.md new file mode 100644 index 0000000000..55c3ac1235 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/README.md @@ -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: . + +| 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" } +``` diff --git a/bundles/org.openhab.binding.gemini/pom.xml b/bundles/org.openhab.binding.gemini/pom.xml new file mode 100644 index 0000000000..4917b347bf --- /dev/null +++ b/bundles/org.openhab.binding.gemini/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.2.0-SNAPSHOT + + + org.openhab.binding.gemini + + openHAB Add-ons :: Bundles :: Gemini Binding + + diff --git a/bundles/org.openhab.binding.gemini/src/main/feature/feature.xml b/bundles/org.openhab.binding.gemini/src/main/feature/feature.xml new file mode 100644 index 0000000000..5def2be65e --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.gemini/${project.version} + + diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiBindingConstants.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiBindingConstants.java new file mode 100644 index 0000000000..0255b18326 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiBindingConstants.java @@ -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"; +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiChannelConfiguration.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiChannelConfiguration.java new file mode 100644 index 0000000000..3177beab0a --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiChannelConfiguration.java @@ -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; +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiConfiguration.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiConfiguration.java new file mode 100644 index 0000000000..24db08a957 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiConfiguration.java @@ -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; +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiHandler.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiHandler.java new file mode 100644 index 0000000000..034cd0b2aa --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiHandler.java @@ -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 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 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 apiModels = client.fetchModels(currentConfig.requestTimeout); + if (!apiModels.isEmpty()) { + List modelList = new ArrayList<>(); + Set seenNames = new HashSet<>(); + for (GeminiModel model : apiModels) { + String name = model.name(); + if (name != null) { + List 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> getServices() { + return List.of(GeminiModelOptionProvider.class, GeminiActions.class, GeminiHLIService.class); + } + + private boolean invalidTimeout(int timeout) { + return timeout <= 0; + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiHandlerFactory.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiHandlerFactory.java new file mode 100644 index 0000000000..c86c87800b --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiHandlerFactory.java @@ -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 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; + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiModelOptionProvider.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiModelOptionProvider.java new file mode 100644 index 0000000000..8d3967ab74 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/GeminiModelOptionProvider.java @@ -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 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 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; + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/action/GeminiActions.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/action/GeminiActions.java new file mode 100644 index 0000000000..936aca1119 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/action/GeminiActions.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiClient.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiClient.java new file mode 100644 index 0000000000..95202a52d0 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiClient.java @@ -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 history, List tools, + @Nullable String systemMessage, @Nullable Double temperature, @Nullable Double topP, + @Nullable Integer maxOutputTokens, @Nullable Integer timeoutSeconds) throws GeminiApiException { + GeminiContent systemInstruction = createSystemInstruction(systemMessage); + + List contents = new ArrayList<>(); + Queue 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 geminiTools = null; + if (!tools.isEmpty()) { + List functions = new ArrayList<>(); + for (LLMTool t : tools) { + Map properties = new HashMap<>(); + List required = new ArrayList<>(); + + for (LLMToolParam p : t.getParamDescriptions(null)) { + List 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 generateContent endpoint of a Gemini model. + * + *

+ * 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 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 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 @Nullable T readNullableValue(String content, Class valueType) throws JsonProcessingException { + return objectMapper.readValue(content, valueType); + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiException.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiException.java new file mode 100644 index 0000000000..750101e06f --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/GeminiApiException.java @@ -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); + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiContent.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiContent.java new file mode 100644 index 0000000000..87abbd6768 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiContent.java @@ -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 parts) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiFunctionCall.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiFunctionCall.java new file mode 100644 index 0000000000..a0d3bd731a --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiFunctionCall.java @@ -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 args) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiPart.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiPart.java new file mode 100644 index 0000000000..85fe602455 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/GeminiPart.java @@ -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) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionDeclaration.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionDeclaration.java new file mode 100644 index 0000000000..caf3f57816 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionDeclaration.java @@ -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) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionResponse.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionResponse.java new file mode 100644 index 0000000000..81de25c134 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiFunctionResponse.java @@ -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 response) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiGenerationConfig.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiGenerationConfig.java new file mode 100644 index 0000000000..d9028324fb --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiGenerationConfig.java @@ -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) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiRequest.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiRequest.java new file mode 100644 index 0000000000..881e41734e --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiRequest.java @@ -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 contents, @Nullable GeminiContent systemInstruction, + @Nullable GeminiGenerationConfig generationConfig, @Nullable List tools) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiSchema.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiSchema.java new file mode 100644 index 0000000000..0d71aa9581 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiSchema.java @@ -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 properties, + @Nullable List required, @JsonProperty("enum") @Nullable List enumValues, + @Nullable GeminiSchema items) { + + public GeminiSchema(String type) { + this(type, null, null, null, null, null); + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiThinkingConfig.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiThinkingConfig.java new file mode 100644 index 0000000000..0291537c83 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiThinkingConfig.java @@ -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) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiTool.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiTool.java new file mode 100644 index 0000000000..a1707f15b2 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/request/GeminiTool.java @@ -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 functionDeclarations) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiCandidate.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiCandidate.java new file mode 100644 index 0000000000..679158c374 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiCandidate.java @@ -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) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiModel.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiModel.java new file mode 100644 index 0000000000..248fb8c6ac --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiModel.java @@ -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 supportedGenerationMethods) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiModelsResponse.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiModelsResponse.java new file mode 100644 index 0000000000..cfd420659c --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiModelsResponse.java @@ -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 models) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiResponse.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiResponse.java new file mode 100644 index 0000000000..5aba865651 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiResponse.java @@ -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 candidates, @Nullable GeminiUsageMetadata usageMetadata) { + + public @Nullable String getFirstText() { + List 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; + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiUsageMetadata.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiUsageMetadata.java new file mode 100644 index 0000000000..356a95c340 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/api/dto/response/GeminiUsageMetadata.java @@ -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) { +} diff --git a/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/hli/GeminiHLIService.java b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/hli/GeminiHLIService.java new file mode 100644 index 0000000000..bac15615ea --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/java/org/openhab/binding/gemini/internal/hli/GeminiHLIService.java @@ -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 getSupportedLocales() { + return Set.of(); + } + + @Override + public Set 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 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 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 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 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 tools, @Nullable String toolName, @Nullable Map 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(); + } + } +} diff --git a/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000..ec2ae7ebb4 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + Gemini Binding + Integrates with the Google Gemini API for AI-powered chat and smart home control. + cloud + + diff --git a/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/i18n/gemini.properties b/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/i18n/gemini.properties new file mode 100644 index 0000000000..bd5569e6a3 --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/i18n/gemini.properties @@ -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 Google AI Studio). +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 (Models, Pricing). +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 diff --git a/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000..d3ede6776f --- /dev/null +++ b/bundles/org.openhab.binding.gemini/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,112 @@ + + + + + + Integrates the Google Gemini API with openHAB. + + + + + + + + network-security + + + + + Configures the used model and its behavior. Applies to the Human Language Interpreter and as a default + for all channels and Thing actions. + + + network + + + + + + password + + + Google AI Studio). + ]]> + + + + + + + Models, Pricing). + ]]> + + gemini-2.5-flash + + + + Controls randomness. Higher values mean more random responses. + 1.0 + true + + + + Controls the cumulative probability threshold for token selection. + 1.0 + true + + + + The maximum number of tokens to generate in the response. + 2048 + true + + + + + The timeout in seconds for API requests. + 30 + true + + + + + + String + + Send a prompt to a model and receive a response. + veto + + + + The Gemini model to use. + + + + Controls randomness. Higher values mean more random responses. + true + + + + Controls the cumulative probability threshold for token selection. + true + + + + The maximum number of tokens to generate in the response. + true + + + + The system instruction provided to the model. + You are a helpful assistant. + false + + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index d5c468f816..32901b3823 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -181,6 +181,7 @@ org.openhab.binding.ftpupload org.openhab.binding.gardena org.openhab.binding.gce + org.openhab.binding.gemini org.openhab.binding.generacmobilelink org.openhab.binding.goecharger org.openhab.binding.govee