diff --git a/openhab-item@phoehnel/CHANGELOG.md b/openhab-item@phoehnel/CHANGELOG.md new file mode 100644 index 00000000000..5f7cb604e6c --- /dev/null +++ b/openhab-item@phoehnel/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## 1.1.0 + +- ⏱️ Auto-close popup menu after configurable timeout (default 10s) +- πŸ–±οΈ Scroll-wheel dimmer control directly on the panel (auto-populated for Dimmer items) +- πŸ”’ Read-only mode to disable all controls +- 🎨 Configurable color swatch dimensions and option to hide brightness % from panel +- πŸ› οΈ Cinnamon Spices best practices (GLib.SOURCE_REMOVE, proper timer cleanup) +- πŸ› Fixed color swatch stretching to full panel height +- πŸ› Fixed scroll-wheel not sending commands on sliders +- πŸ“„ Added CONTRIBUTING.md with development setup guide + +## 1.0.0 + +- 🏠 Display any standard OpenHAB item on the panel (Switch, Dimmer, Number, String, Contact, Rollershutter, Color, DateTime, Player, Group) +- πŸ” Multi-instance support β€” add the applet multiple times for different items +- πŸ”— Shared server configuration across instances via `~/.config/` with Gio.FileMonitor +- πŸ‘† Double-click toggle for Switch/Dimmer items (configurable) +- βž• Additional items in popup menu (e.g. lamp + brightness slider together) +- πŸ“Œ Popup menu stays open during interaction +- 🎨 Custom icons per instance +- πŸ’¬ Configurable tooltip fields +- πŸ“ stateDescription pattern formatting (e.g. `%.1f Β°C`) +- 🌈 Color items with live color swatch on panel and brightness slider in popup +- πŸ“… DateTime items with Java-style stateDescription patterns +- πŸŽ›οΈ Dimmer ON/OFF toggle (configurable, default off) diff --git a/openhab-item@phoehnel/CLAUDE.md b/openhab-item@phoehnel/CLAUDE.md new file mode 100644 index 00000000000..1a0525d698b --- /dev/null +++ b/openhab-item@phoehnel/CLAUDE.md @@ -0,0 +1,36 @@ +# cinnamon-spice-openhab + +Cinnamon panel applet to display and control OpenHAB smart home items. + +## Guidelines + +The Cinnamon Spices contributor guidelines MUST be followed: +- https://github.com/linuxmint/cinnamon-spices-applets/blob/master/.github/CONTRIBUTING.md +- https://github.com/linuxmint/cinnamon-spices-applets/blob/master/.github/copilot-instructions.md + +Key rules from those guides: +- Never write to the installation directory (`metadata.path`) β€” use `GLib.get_user_config_dir()` / `GLib.get_user_state_dir()` / `GLib.get_user_cache_dir()` +- Clean up all timers and signal handlers in `on_applet_removed_from_panel()` +- Use `GLib.SOURCE_REMOVE` / `GLib.SOURCE_CONTINUE` for timer return values +- Use `Clutter.EVENT_STOP` / `Clutter.EVENT_PROPAGATE` for event handlers +- No synchronous network calls β€” use async Soup APIs +- No compiled code, binaries, or minified JS +- JS must be compatible with SpiderMonkey 102+ (Linux Mint Cinnamon) + +## Architecture + +- **UUID**: `openhab-item@phoehnel` +- **applet.js**: Main `TextIconApplet` class β€” settings binding, polling, panel display (incl. color swatch), popup menu, double-click toggle, scroll-wheel dimmer +- **httpClient.js**: Soup 2/3 compatible HTTP GET/POST with Bearer auth +- **serverConfig.js**: Shared server config in `$XDG_CONFIG_HOME/UUID/server.json` with `Gio.FileMonitor` for cross-instance sync +- **itemRenderers.js**: Per-type icons, state formatting (printf patterns, Java date patterns, HSBβ†’hex), popup menu controls (keeps menu open, debounced sliders) +- **settings-schema.json**: 3 pages (Server, Item, Display) with per-instance settings + +## Resources + +- Cinnamon Spices repo: https://github.com/linuxmint/cinnamon-spices-applets + - Reference popular applets like `Weather@mockturtl` and `Cinnamenu@json` for best practices +- OpenHAB docs: use context7 MCP with libraryId `/openhab/openhab-docs` + - context7 also works for Cinnamon and other library docs +- OpenHAB Basic UI (no auth): http://:8080/basicui/app + - You'll have to ask user for the IP diff --git a/openhab-item@phoehnel/CONTRIBUTING.md b/openhab-item@phoehnel/CONTRIBUTING.md new file mode 100644 index 00000000000..c97732c2726 --- /dev/null +++ b/openhab-item@phoehnel/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to OpenHAB Item Applet + +Contributions are welcome! πŸŽ‰ Whether it's bug fixes, new features, or documentation improvements β€” feel free to open an issue or submit a pull request. + +Please make sure your contributions respect the [Cinnamon Spices contributing guidelines](https://github.com/linuxmint/cinnamon-spices-applets/blob/master/.github/CONTRIBUTING.md) and the [code review instructions](https://github.com/linuxmint/cinnamon-spices-applets/blob/master/.github/copilot-instructions.md). + +## Prerequisites + +- Cinnamon Desktop Environment (5.4+) +- OpenHAB server accessible on your network + +## Install from Repository for Development + +1. **Clone this repository:** + ```bash + git clone + cd cinnamon-spice-openhab + ``` + +2. **Create a symlink to your local Cinnamon applets directory:** + ```bash + mkdir -p ~/.local/share/cinnamon/applets + ln -sf "$(pwd)/files/openhab-item@phoehnel" \ + ~/.local/share/cinnamon/applets/openhab-item@phoehnel + ``` + +3. **Restart Cinnamon:** + - Press `Alt+F2`, type `r`, press `Enter` (X11 only) + - Or log out and back in (works on both X11 and Wayland) + +4. **Add the applet to your panel:** + - Right-click on the panel -> **Applets** + - Search for "OpenHAB Item" + - Click the **+** button to add it + +5. **Configure the applet:** + - Right-click the new applet -> **Configure...** + - On the **Server** tab: enter your OpenHAB server URL (e.g., `http://openhabianpi:8080`) + - On the **Item** tab: enter an OpenHAB item name (e.g., `LivingRoom_Light`) + +6. **Add more instances** for additional items: + - Right-click panel -> **Applets** -> add another "OpenHAB Item" + - The server URL is shared automatically -- just configure the item name + +## View Logs + +- **Looking Glass**: Press `Alt+F2`, type `lg`, go to the **Log** tab +- **Journal**: `journalctl -f /usr/bin/cinnamon` + +## After Code Changes + +Restart Cinnamon (`Alt+F2` -> `r` -> `Enter`) to reload the applet. + +## Uninstall + +```bash +rm ~/.local/share/cinnamon/applets/openhab-item@phoehnel +``` +Then restart Cinnamon. + +## Project Structure + +- `files/openhab-item@phoehnel/applet.js` -- Main TextIconApplet class +- `files/openhab-item@phoehnel/httpClient.js` -- Soup 2/3 compatible HTTP client +- `files/openhab-item@phoehnel/serverConfig.js` -- Shared server config with file monitoring +- `files/openhab-item@phoehnel/itemRenderers.js` -- Per-type icons, formatting, and popup menu controls +- `files/openhab-item@phoehnel/settings-schema.json` -- Settings UI definition + +## Cinnamon Spices Guidelines + +This applet follows the [Cinnamon Spices contributing guidelines](https://github.com/linuxmint/cinnamon-spices-applets/blob/master/.github/CONTRIBUTING.md). diff --git a/openhab-item@phoehnel/README.md b/openhab-item@phoehnel/README.md new file mode 100644 index 00000000000..88d12cdf449 --- /dev/null +++ b/openhab-item@phoehnel/README.md @@ -0,0 +1,102 @@ +# 🏠 OpenHAB Item Applet for Cinnamon + +Display and control [OpenHAB](https://www.openhab.org/) smart home items directly from your Cinnamon desktop panel. + +Screenshot + + +## ✨ Features + +- πŸ” **Multi-instance** β€” Add the applet multiple times to monitor different items +- πŸ”— **Shared configuration** β€” Configure the OpenHAB server URL once, all instances share it +- πŸŽ›οΈ **Rich item support** β€” Switch, Dimmer, Rollershutter, Color, Player, and read-only types like Number, String, Contact, DateTime, and Group +- βž• **Additional items** β€” Show related items in the popup (e.g. lamp switch + brightness dimmer together) +- πŸ‘† **Double-click toggle** β€” Quickly toggle Switch/Dimmer items without opening the menu +- πŸ–±οΈ **Scroll-wheel dimmer** β€” Control brightness by scrolling directly on the panel β€” no popup needed (auto-populated for Dimmer items) +- 🎨 **Configurable display** β€” Choose what to show on the panel (icon, label, state) with a custom format string and configurable tooltip +- πŸ”’ **Read-only mode** β€” Disable all controls to use the applet as a display-only monitor +- ⏱️ **Auto-close popup** β€” Popup menu auto-closes after a configurable timeout +- πŸ”„ **Auto-polling** β€” Configurable refresh interval (1–300 seconds) + +## πŸ“‹ Supported Item Types + +| Type | Panel Display | Controls | +|------|--------------|----------| +| Switch | ON / OFF | Toggle switch | +| Dimmer | Percentage | Brightness slider (optional ON/OFF toggle) | +| Number | Formatted value | Read-only | +| String | Text | Read-only | +| Contact | Open / Closed | Read-only | +| Rollershutter | Position % | UP / STOP / DOWN + slider | +| Color | Color swatch + brightness % | ON/OFF toggle, color preview, brightness slider | +| DateTime | Formatted (supports OpenHAB patterns) | Read-only | +| Player | Play state | Play / Pause / Next / Previous | +| Group | Aggregated value (AVG, SUM, etc.) | Read-only | + +## πŸ› οΈ Configuration + +Right-click the applet β†’ **Configure...** to access settings across three tabs. + +### 🌐 Server Settings (shared across all instances) + +| Setting | Description | Default | +|---------|-------------|---------| +| Server URL | OpenHAB server URL (e.g. `http://openhabianpi:8080`) | *(empty)* | +| API Token | Bearer token for authentication (optional) | *(empty)* | +| Poll Interval | How often to refresh item state, in seconds | 30 | + +### πŸ“¦ Item Settings (per instance) + +| Setting | Description | Default | +|---------|-------------|---------| +| Item Name | Exact OpenHAB item name (e.g. `LivingRoom_Light`) | *(empty)* | +| Custom Label | Override the item label from OpenHAB | *(empty)* | +| Additional Items | Comma-separated item names to show in popup | *(empty)* | +| Scroll-wheel Dimmer Item | Item to control via scroll wheel on the panel | *(auto for Dimmer)* | +| Scroll Step Size | Percentage step per scroll tick | 5% | +| Read-only Mode | Disable all controls (display only) | OFF | + +### πŸ–₯️ Display Settings (per instance) + +| Setting | Description | Default | +|---------|-------------|---------| +| Show Icon | Show item type icon on the panel | ON | +| Custom Icon | Override with a custom icon (name or file path) | *(auto by type)* | +| Show Label | Show item label text on the panel | OFF | +| Show State | Show state value on the panel | ON | +| Panel Text Format | Format string using `{label}`, `{state}`, `{name}` | `{state}` | +| Double-click Toggle | Toggle Switch/Dimmer items on double-click | ON | +| Dimmer Toggle | Show ON/OFF toggle for Dimmer items in popup | OFF | +| Color Show % | Show brightness percentage on panel for Color items | ON | +| Color Swatch Width | Width of color preview swatch on panel | 16 px | +| Color Swatch Height | Height of color preview swatch on panel | 16 px | +| Auto-close Popup | Automatically close popup menu after timeout | ON | +| Auto-close Delay | Seconds before popup auto-closes | 10 | + +### πŸ’¬ Tooltip Settings (per instance) + +| Setting | Description | Default | +|---------|-------------|---------| +| Show Label | Show item label in tooltip | ON | +| Show Item Type | Show the OpenHAB item type | OFF | +| Show State Value | Show current state in tooltip | ON | +| Show Item Name | Show the technical item name | OFF | +| Show Server URL | Show the configured server URL | OFF | + +## πŸ› Known Issues + +- **Color items: no full color picker** β€” The Color item type currently only supports brightness control and ON/OFF toggle. Hue and saturation cannot be changed from the applet because Cinnamon's toolkit (St/Clutter) does not provide a color picker widget. Contributions welcome if you know a viable approach! + +## πŸ§‘β€πŸ’» Development + +See [CONTRIBUTING.md](CONTRIBUTING.md) for local testing, development setup, and project structure. + +## πŸ‘€ Author + +[phoehnel](https://github.com/phoehnel) + +## πŸ“„ License + +GPL-3.0 + +> ⚠️ **Disclaimer:** This applet is an independent community project and is **not affiliated with, endorsed by, or supported by** the openHAB Foundation or the openHAB project. It simply interfaces with the [OpenHAB REST API](https://www.openhab.org/docs/configuration/restdocs.html). \ No newline at end of file diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/applet.js b/openhab-item@phoehnel/files/openhab-item@phoehnel/applet.js new file mode 100644 index 00000000000..34f59d2d9ec --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/applet.js @@ -0,0 +1,628 @@ +const Applet = imports.ui.applet; +const Settings = imports.ui.settings; +const PopupMenu = imports.ui.popupMenu; +const Mainloop = imports.mainloop; +const St = imports.gi.St; +const GLib = imports.gi.GLib; +const Clutter = imports.gi.Clutter; + +const UUID = "openhab-item@phoehnel"; + +// Import local modules +const AppletDir = imports.ui.appletManager.appletMeta[UUID].path; +imports.searchPath.unshift(AppletDir); +const HttpClient = imports.httpClient; +const ServerConfig = imports.serverConfig; +const ItemRenderers = imports.itemRenderers; +imports.searchPath.shift(); + +class OpenHABItemApplet extends Applet.TextIconApplet { + constructor(metadata, orientation, panelHeight, instanceId) { + super(orientation, panelHeight, instanceId); + + this.metadata = metadata; + this.instanceId = instanceId; + this.orientation = orientation; + + // Item data from OpenHAB + this._itemData = null; + this._additionalItemData = {}; + this._pollTimerId = null; + this._isDestroyed = false; + this._lastClickTime = 0; + this._clickTimer = null; + this._menuAutoCloseTimer = null; + + // Initialize HTTP client and shared config + this._http = new HttpClient.HttpClient(); + this._serverConfig = new ServerConfig.ServerConfig(); + + // Color preview swatch on panel β€” wrapped in a Bin that centers it + // so the panel layout doesn't stretch its height + this._colorSwatch = new St.Widget({ + style: "border-radius: 3px;", + visible: false, + reactive: false + }); + let swatchContainer = new St.Bin({ + y_align: St.Align.MIDDLE, + x_align: St.Align.MIDDLE, + style: "margin: 2px 4px;", + child: this._colorSwatch + }); + this.actor.add_child(swatchContainer); + this._colorSwatchContainer = swatchContainer; + + // Set up the panel appearance + this.set_applet_icon_symbolic_name("network-offline-symbolic"); + this.set_applet_label("OpenHAB"); + this.set_applet_tooltip("OpenHAB Item - Not configured"); + + // Create popup menu + this.menuManager = new PopupMenu.PopupMenuManager(this); + this.menu = new Applet.AppletPopupMenu(this, orientation); + this.menuManager.addMenu(this.menu); + + // Bind settings + this._bindSettings(); + + // Load shared server config if own settings are empty + this._loadSharedConfig(); + + // Scroll-wheel dimmer control directly on panel + this.actor.connect("scroll-event", this._onScrollEvent.bind(this)); + + // Auto-close popup menu on timeout + this.menu.connect("open-state-changed", this._onMenuOpenStateChanged.bind(this)); + + // Start monitoring shared config for changes from other instances + this._serverConfig.startMonitor(this._onSharedConfigChanged.bind(this)); + + // Start polling + this._startPolling(); + } + + _bindSettings() { + this.settings = new Settings.AppletSettings(this, UUID, this.instanceId); + + this.settings.bind("serverUrl", "serverUrl", this._onServerSettingsChanged.bind(this)); + this.settings.bind("apiToken", "apiToken", this._onServerSettingsChanged.bind(this)); + this.settings.bind("pollInterval", "pollInterval", this._onPollIntervalChanged.bind(this)); + this.settings.bind("itemName", "itemName", this._onItemChanged.bind(this)); + this.settings.bind("itemLabel", "itemLabel", this._onDisplayChanged.bind(this)); + this.settings.bind("additionalItems", "additionalItems", this._onItemChanged.bind(this)); + this.settings.bind("scrollDimmerItem", "scrollDimmerItem"); + this.settings.bind("scrollDimmerStep", "scrollDimmerStep"); + this.settings.bind("showIcon", "optShowIcon", this._onDisplayChanged.bind(this)); + this.settings.bind("customIcon", "customIcon", this._onDisplayChanged.bind(this)); + this.settings.bind("showLabel", "optShowLabel", this._onDisplayChanged.bind(this)); + this.settings.bind("showState", "optShowState", this._onDisplayChanged.bind(this)); + this.settings.bind("panelTextFormat", "panelTextFormat", this._onDisplayChanged.bind(this)); + this.settings.bind("readOnly", "readOnly", this._onDisplayChanged.bind(this)); + this.settings.bind("doubleClickToggle", "doubleClickToggle"); + this.settings.bind("dimmerShowToggle", "dimmerShowToggle", this._onDisplayChanged.bind(this)); + this.settings.bind("colorShowPercent", "colorShowPercent", this._onDisplayChanged.bind(this)); + this.settings.bind("colorPreviewWidth", "colorPreviewWidth", this._onDisplayChanged.bind(this)); + this.settings.bind("colorPreviewHeight", "colorPreviewHeight", this._onDisplayChanged.bind(this)); + this.settings.bind("popupAutoClose", "popupAutoClose"); + this.settings.bind("popupAutoCloseDelay", "popupAutoCloseDelay"); + this.settings.bind("tooltipShowLabel", "tooltipShowLabel", this._onDisplayChanged.bind(this)); + this.settings.bind("tooltipShowType", "tooltipShowType", this._onDisplayChanged.bind(this)); + this.settings.bind("tooltipShowState", "tooltipShowState", this._onDisplayChanged.bind(this)); + this.settings.bind("tooltipShowName", "tooltipShowName", this._onDisplayChanged.bind(this)); + this.settings.bind("tooltipShowServer", "tooltipShowServer", this._onDisplayChanged.bind(this)); + } + + _loadSharedConfig() { + this._serverConfig.read((shared) => { + if (this._isDestroyed) return; + if (shared) { + // If our settings are at default/empty and shared config has values, use shared + if (shared.serverUrl && !this.serverUrl) { + this.serverUrl = shared.serverUrl; + } + if (shared.apiToken && !this.apiToken) { + this.apiToken = shared.apiToken; + } + if (shared.pollInterval && this.pollInterval === 30) { + this.pollInterval = shared.pollInterval; + } + } + }); + } + + _onSharedConfigChanged(config) { + if (this._isDestroyed) return; + + // Update own settings from shared config + let changed = false; + if (config.serverUrl && config.serverUrl !== this.serverUrl) { + this.serverUrl = config.serverUrl; + changed = true; + } + if (config.apiToken !== undefined && config.apiToken !== this.apiToken) { + this.apiToken = config.apiToken; + changed = true; + } + if (config.pollInterval && config.pollInterval !== this.pollInterval) { + this.pollInterval = config.pollInterval; + } + + if (changed) { + this._restartPolling(); + } + } + + _onServerSettingsChanged() { + // Write to shared config so other instances pick it up + this._serverConfig.write({ + serverUrl: this.serverUrl, + apiToken: this.apiToken, + pollInterval: this.pollInterval + }); + this._restartPolling(); + } + + _onPollIntervalChanged() { + this._serverConfig.write({ + serverUrl: this.serverUrl, + apiToken: this.apiToken, + pollInterval: this.pollInterval + }); + this._restartPolling(); + } + + _onItemChanged() { + this._itemData = null; + this._additionalItemData = {}; + this._restartPolling(); + } + + _onDisplayChanged() { + this._updatePanel(); + } + + // --- Polling --- + + _startPolling() { + this._stopPolling(); + + if (!this.serverUrl || !this.itemName) { + this._showStatus("setup", "Configure server URL and item name"); + return; + } + + // Fetch immediately + this._fetchItem(); + + // Set up periodic polling + this._pollTimerId = Mainloop.timeout_add_seconds( + this.pollInterval, + () => { + if (this._isDestroyed) return GLib.SOURCE_REMOVE; + this._fetchItem(); + return GLib.SOURCE_CONTINUE; + } + ); + } + + _stopPolling() { + if (this._pollTimerId) { + Mainloop.source_remove(this._pollTimerId); + this._pollTimerId = null; + } + } + + _restartPolling() { + this._startPolling(); + } + + // --- HTTP --- + + _getAdditionalItemNames() { + if (!this.additionalItems) return []; + return this.additionalItems.split(",") + .map(function(s) { return s.trim(); }) + .filter(function(s) { return s.length > 0; }); + } + + _fetchItem() { + if (this._isDestroyed || !this.serverUrl || !this.itemName) return; + + let baseUrl = this.serverUrl.replace(/\/$/, ""); + let url = baseUrl + "/rest/items/" + encodeURIComponent(this.itemName); + + this._http.get( + url, + this.apiToken, + (responseText) => { + try { + let data = JSON.parse(responseText); + this._itemData = data; + this._updatePanel(); + // Don't rebuild menu while it's open β€” it causes a crash + if (!this.menu.isOpen) { + this._updateMenu(); + } + } catch (e) { + this._showStatus("error", "Invalid response from server"); + global.logError("OpenHAB: Failed to parse response: " + e.message); + } + }, + (errorMsg, status) => { + if (status === 404) { + this._showStatus("error", "Item '" + this.itemName + "' not found"); + } else if (status === 401 || status === 403) { + this._showStatus("error", "Authentication failed - check API token"); + } else { + this._showStatus("offline", "Cannot reach server: " + errorMsg); + } + global.logError("OpenHAB: Fetch error for " + this.itemName + ": " + errorMsg); + } + ); + + // Fetch additional items + let additionalNames = this._getAdditionalItemNames(); + for (let name of additionalNames) { + this._fetchAdditionalItem(name, baseUrl); + } + } + + _fetchAdditionalItem(name, baseUrl) { + let url = baseUrl + "/rest/items/" + encodeURIComponent(name); + + this._http.get( + url, + this.apiToken, + (responseText) => { + try { + let data = JSON.parse(responseText); + this._additionalItemData[name] = data; + if (!this.menu.isOpen) { + this._updateMenu(); + } + } catch (e) { + global.logError("OpenHAB: Failed to parse additional item " + name + ": " + e.message); + } + }, + (errorMsg) => { + global.logError("OpenHAB: Fetch error for additional item " + name + ": " + errorMsg); + } + ); + } + + _sendCommandToItem(itemName, command) { + if (this.readOnly) return; + if (!this.serverUrl || !itemName) return; + + let url = this.serverUrl.replace(/\/$/, "") + "/rest/items/" + encodeURIComponent(itemName); + + this._http.post( + url, + command, + this.apiToken, + (responseText, status) => { + // Refresh after a short delay to let OpenHAB process the command + if (!this._isDestroyed) { + Mainloop.timeout_add(500, () => { + this._fetchItem(); + return GLib.SOURCE_REMOVE; + }); + } + }, + (errorMsg, status) => { + global.logError("OpenHAB: Command error for " + this.itemName + ": " + errorMsg); + } + ); + } + + _sendCommand(command) { + this._sendCommandToItem(this.itemName, command); + } + + // --- Panel Display --- + + _updatePanel() { + if (this._isDestroyed) return; + + if (!this._itemData) { + if (this.itemName) { + this.set_applet_label("..."); + this.set_applet_tooltip("Loading " + this.itemName + "..."); + } + return; + } + + let data = this._itemData; + let baseType = data.type ? data.type.split(":")[0] : "String"; + let label = this.itemLabel || data.label || data.name; + + // Auto-populate scroll dimmer for Dimmer items + if (baseType === "Dimmer" && !this.scrollDimmerItem) { + this.scrollDimmerItem = this.itemName; + } + + // Pass unitSymbol into stateDescription so formatState can use it + let stateDesc = data.stateDescription || {}; + if (data.unitSymbol) stateDesc.unitSymbol = data.unitSymbol; + let formatOpts = {}; + if (baseType === "Color" && !this.colorShowPercent) { + formatOpts.hideColorPercent = true; + } + let state = ItemRenderers.formatState(baseType, data.state, stateDesc, formatOpts); + + // Icon + if (this.optShowIcon) { + if (this.customIcon) { + if (this.customIcon.endsWith(".svg") || this.customIcon.endsWith(".png")) { + this.set_applet_icon_path(this.customIcon); + } else { + this.set_applet_icon_symbolic_name(this.customIcon); + } + } else { + let iconName = ItemRenderers.getIconName(baseType); + this.set_applet_icon_symbolic_name(iconName); + } + } else { + this.hide_applet_icon(); + } + + // Color swatch on panel for Color items + if (baseType === "Color" && data.state && data.state !== "NULL" && data.state !== "UNDEF") { + let parts = data.state.split(","); + if (parts.length === 3) { + let h = parseFloat(parts[0]) || 0; + let s = parseFloat(parts[1]) || 0; + let b = parseFloat(parts[2]) || 0; + let hex = ItemRenderers.hsbToHex(h, s, Math.max(b, 5)); + let swatchW = this.colorPreviewWidth || 16; + let swatchH = this.colorPreviewHeight || 16; + this._colorSwatch.set_size(swatchW, swatchH); + this._colorSwatch.set_style( + "background-color: " + hex + ";" + + " border: 1px solid rgba(255,255,255,0.3);" + + " border-radius: 3px;" + ); + this._colorSwatch.show(); + this._colorSwatchContainer.show(); + } else { + this._colorSwatch.hide(); + this._colorSwatchContainer.hide(); + } + } else { + this._colorSwatch.hide(); + this._colorSwatchContainer.hide(); + } + + // Panel text + let showText = this.optShowLabel || this.optShowState; + if (showText) { + let text = this.panelTextFormat + .replace("{label}", label) + .replace("{state}", state) + .replace("{name}", data.name); + this.set_applet_label(text); + } else { + this.set_applet_label(""); + } + + // Tooltip + let tooltipParts = []; + if (this.tooltipShowLabel) tooltipParts.push(label); + if (this.tooltipShowType) tooltipParts.push("Type: " + data.type); + if (this.tooltipShowState) tooltipParts.push("State: " + state); + if (this.tooltipShowName) tooltipParts.push("Item: " + data.name); + if (this.tooltipShowServer) tooltipParts.push("Server: " + this.serverUrl); + this.set_applet_tooltip(tooltipParts.join("\n") || "OpenHAB Item"); + } + + _showStatus(status, message) { + if (this._isDestroyed) return; + + let iconName = ItemRenderers.getStatusIcon(status); + this.set_applet_icon_symbolic_name(iconName); + + if (status === "setup") { + this.set_applet_label("Setup"); + } else if (status === "offline") { + this.set_applet_label("!"); + } else { + this.set_applet_label("?"); + } + + this.set_applet_tooltip("OpenHAB: " + message); + } + + // --- Popup Menu --- + + _onMenuOpenStateChanged(menu, isOpen) { + if (isOpen) { + this._startAutoCloseTimer(); + } else { + this._stopAutoCloseTimer(); + } + } + + _startAutoCloseTimer() { + this._stopAutoCloseTimer(); + if (!this.popupAutoClose) return; + + let delay = this.popupAutoCloseDelay || 10; + this._menuAutoCloseTimer = Mainloop.timeout_add_seconds(delay, () => { + this._menuAutoCloseTimer = null; + if (this.menu.isOpen) { + this.menu.close(true); + } + return GLib.SOURCE_REMOVE; + }); + } + + _stopAutoCloseTimer() { + if (this._menuAutoCloseTimer) { + Mainloop.source_remove(this._menuAutoCloseTimer); + this._menuAutoCloseTimer = null; + } + } + + _updateMenu() { + if (this._isDestroyed) return; + + this.menu.removeAll(); + + let menuOpts = { + dimmerShowToggle: this.dimmerShowToggle, + readOnly: this.readOnly + }; + + if (this._itemData) { + let menuItems = ItemRenderers.buildMenuItems( + this._itemData, + this._sendCommand.bind(this), + menuOpts + ); + for (let item of menuItems) { + this.menu.addMenuItem(item); + } + } + + // Additional items + let additionalNames = this._getAdditionalItemNames(); + for (let name of additionalNames) { + let data = this._additionalItemData[name]; + if (data) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + let sendCmd = ((itemName) => (cmd) => { + this._sendCommandToItem(itemName, cmd); + })(name); + let menuItems = ItemRenderers.buildMenuItems(data, sendCmd, menuOpts); + for (let item of menuItems) { + this.menu.addMenuItem(item); + } + } + } + + if (this._itemData || additionalNames.length > 0) { + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + } + + // Refresh button + let refreshItem = new PopupMenu.PopupIconMenuItem( + "Refresh", + "view-refresh-symbolic", + St.IconType.SYMBOLIC + ); + refreshItem.connect("activate", () => { + this._fetchItem(); + }); + this.menu.addMenuItem(refreshItem); + + // Configure button + let configItem = new PopupMenu.PopupIconMenuItem( + "Configure...", + "preferences-system-symbolic", + St.IconType.SYMBOLIC + ); + configItem.connect("activate", () => { + this.configureApplet(); + }); + this.menu.addMenuItem(configItem); + } + + // --- Applet Events --- + + _isToggleable() { + if (!this._itemData) return false; + let baseType = this._itemData.type ? this._itemData.type.split(":")[0] : ""; + return baseType === "Switch" || baseType === "Dimmer"; + } + + on_applet_clicked(event) { + if (!this.doubleClickToggle || !this._isToggleable()) { + // Read-only items: open menu immediately + this._updateMenu(); + this.menu.toggle(); + return; + } + + let now = Date.now(); + let doubleClickThreshold = 400; // ms + + if (now - this._lastClickTime < doubleClickThreshold) { + if (this._clickTimer) { + Mainloop.source_remove(this._clickTimer); + this._clickTimer = null; + } + this._lastClickTime = 0; + this._onDoubleClick(); + return; + } + + this._lastClickTime = now; + + this._clickTimer = Mainloop.timeout_add(doubleClickThreshold, () => { + this._clickTimer = null; + this._updateMenu(); + this.menu.toggle(); + return GLib.SOURCE_REMOVE; + }); + } + + _onScrollEvent(actor, event) { + let scrollItem = this.scrollDimmerItem; + if (!scrollItem) return Clutter.EVENT_PROPAGATE; + + let direction = event.get_scroll_direction(); + + // Determine current value from item data + let currentValue = 0; + if (scrollItem === this.itemName && this._itemData) { + currentValue = parseFloat(this._itemData.state) || 0; + } else if (this._additionalItemData[scrollItem]) { + currentValue = parseFloat(this._additionalItemData[scrollItem].state) || 0; + } + + let step = this.scrollDimmerStep || 5; + if (direction === Clutter.ScrollDirection.UP) { + currentValue = Math.min(100, currentValue + step); + } else if (direction === Clutter.ScrollDirection.DOWN) { + currentValue = Math.max(0, currentValue - step); + } else { + return Clutter.EVENT_PROPAGATE; + } + + this._sendCommandToItem(scrollItem, Math.round(currentValue).toString()); + return Clutter.EVENT_STOP; + } + + _onDoubleClick() { + if (!this._itemData) return; + + let baseType = this._itemData.type ? this._itemData.type.split(":")[0] : ""; + let state = this._itemData.state; + + if (baseType === "Switch") { + this._sendCommand(state === "ON" ? "OFF" : "ON"); + } else if (baseType === "Dimmer") { + this._sendCommand(state === "0" || state === "OFF" ? "ON" : "OFF"); + } + } + + on_applet_removed_from_panel() { + this._isDestroyed = true; + this._stopPolling(); + this._stopAutoCloseTimer(); + if (this._clickTimer) { + Mainloop.source_remove(this._clickTimer); + this._clickTimer = null; + } + this._serverConfig.stopMonitor(); + if (this._http) { + this._http.destroy(); + this._http = null; + } + } +} + +function main(metadata, orientation, panelHeight, instanceId) { + return new OpenHABItemApplet(metadata, orientation, panelHeight, instanceId); +} diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/httpClient.js b/openhab-item@phoehnel/files/openhab-item@phoehnel/httpClient.js new file mode 100644 index 00000000000..b72fc54712d --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/httpClient.js @@ -0,0 +1,109 @@ +const Soup = imports.gi.Soup; +const GLib = imports.gi.GLib; + +const SOUP_MAJOR = Soup.MAJOR_VERSION; + +var HttpClient = class HttpClient { + constructor() { + if (SOUP_MAJOR === 3) { + this._session = new Soup.Session(); + } else { + this._session = new Soup.SessionAsync(); + Soup.Session.prototype.add_feature.call( + this._session, new Soup.ProxyResolverDefault() + ); + } + this._session.timeout = 10; + } + + get(url, apiToken, onSuccess, onError) { + this._request("GET", url, null, apiToken, onSuccess, onError); + } + + post(url, body, apiToken, onSuccess, onError) { + this._request("POST", url, body, apiToken, onSuccess, onError); + } + + _request(method, url, body, apiToken, onSuccess, onError) { + let message; + try { + message = Soup.Message.new(method, url); + if (!message) { + onError("Invalid URL: " + url); + return; + } + } catch (e) { + onError("Failed to create request: " + e.message); + return; + } + + if (apiToken) { + if (SOUP_MAJOR === 3) { + message.get_request_headers().append("Authorization", "Bearer " + apiToken); + } else { + message.request_headers.append("Authorization", "Bearer " + apiToken); + } + } + + if (SOUP_MAJOR === 3) { + message.get_request_headers().append("Accept", "application/json"); + } else { + message.request_headers.append("Accept", "application/json"); + } + + if (body !== null && body !== undefined) { + if (SOUP_MAJOR === 3) { + let bytes = GLib.Bytes.new(new TextEncoder().encode(body.toString())); + message.set_request_body_from_bytes("text/plain", bytes); + } else { + message.set_request( + "text/plain", + Soup.MemoryUse.COPY, + body.toString() + ); + } + } + + if (SOUP_MAJOR === 3) { + this._session.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + null, + (session, result) => { + try { + let bytes = session.send_and_read_finish(result); + let status = message.get_status(); + if (status === Soup.Status.OK || status === 200) { + let data = new TextDecoder().decode(bytes.get_data()); + onSuccess(data, status); + } else { + onError("HTTP " + status, status); + } + } catch (e) { + onError(e.message, 0); + } + } + ); + } else { + this._session.queue_message(message, (session, msg) => { + let status = msg.status_code; + if (status === Soup.KnownStatusCode.OK || status === 200) { + onSuccess(msg.response_body.data, status); + } else { + onError("HTTP " + status, status); + } + }); + } + } + + destroy() { + if (this._session) { + if (SOUP_MAJOR === 3) { + // Soup3 sessions don't need explicit abort + } else if (this._session.abort) { + this._session.abort(); + } + this._session = null; + } + } +}; diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/icon.png b/openhab-item@phoehnel/files/openhab-item@phoehnel/icon.png new file mode 100644 index 00000000000..43c38d910aa Binary files /dev/null and b/openhab-item@phoehnel/files/openhab-item@phoehnel/icon.png differ diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/icon.svg b/openhab-item@phoehnel/files/openhab-item@phoehnel/icon.svg new file mode 100644 index 00000000000..197a316107d --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/itemRenderers.js b/openhab-item@phoehnel/files/openhab-item@phoehnel/itemRenderers.js new file mode 100644 index 00000000000..c53cd25c7bd --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/itemRenderers.js @@ -0,0 +1,400 @@ +const PopupMenu = imports.ui.popupMenu; +const St = imports.gi.St; +const Mainloop = imports.mainloop; +const GLib = imports.gi.GLib; + +// Icon names from the system icon theme +const ICON_MAP = { + "Switch": "system-shutdown-symbolic", + "Dimmer": "display-brightness-symbolic", + "Number": "accessories-calculator-symbolic", + "String": "mintstick-info-symbolic", + "Contact": "changes-prevent-symbolic", + "Rollershutter": "view-reveal-symbolic", + "Color": "color-select-symbolic", + "DateTime": "x-office-calendar-symbolic", + "Player": "media-playback-start-symbolic", + "Group": "folder-symbolic", + "Image": "image-x-generic-symbolic", + "Location": "mark-location-symbolic", + "_default": "dialog-question-symbolic", + "_error": "dialog-error-symbolic", + "_setup": "preferences-system-symbolic", + "_offline": "network-offline-symbolic" +}; + +var getIconName = function(itemType) { + return ICON_MAP[itemType] || ICON_MAP["_default"]; +}; + +var getStatusIcon = function(status) { + return ICON_MAP["_" + status] || ICON_MAP["_default"]; +}; + +function _applyPattern(state, stateDescription) { + if (!stateDescription || !stateDescription.pattern) return null; + + let num = parseFloat(state); + if (isNaN(num)) return null; + + let pattern = stateDescription.pattern; + try { + // Handle printf-style format: e.g. "%.1f Β°C", "%d %%", "%.0f lux" + let formatted = pattern.replace(/%[.\d]*[dfs]/, function(match) { + if (match.endsWith("d")) { + return Math.round(num).toString(); + } else if (match.endsWith("f")) { + let decimals = 0; + let precMatch = match.match(/\.(\d+)/); + if (precMatch) decimals = parseInt(precMatch[1]); + return num.toFixed(decimals); + } + return num.toString(); + }); + // Replace %unit% placeholder with unitSymbol if available + // (caller can pass unitSymbol in stateDescription) + if (formatted.includes("%unit%") && stateDescription.unitSymbol) { + formatted = formatted.replace("%unit%", stateDescription.unitSymbol); + } + // Replace escaped %% with % + formatted = formatted.replace("%%", "%"); + return formatted; + } catch (e) { + return null; + } +} + +function _pad2(n) { + return n < 10 ? "0" + n : "" + n; +} + +function _applyDatePattern(date, pattern) { + // Java-style date format tokens: %1$tY, %1$tm, %1$td, %1$tH, %1$tM, %1$tS, etc. + let result = pattern; + result = result.replace(/%1\$tY/g, "" + date.getFullYear()); + result = result.replace(/%1\$ty/g, ("" + date.getFullYear()).slice(-2)); + result = result.replace(/%1\$tm/g, _pad2(date.getMonth() + 1)); + result = result.replace(/%1\$td/g, _pad2(date.getDate())); + result = result.replace(/%1\$te/g, "" + date.getDate()); + result = result.replace(/%1\$tH/g, _pad2(date.getHours())); + result = result.replace(/%1\$tk/g, "" + date.getHours()); + result = result.replace(/%1\$tI/g, _pad2(date.getHours() % 12 || 12)); + result = result.replace(/%1\$tl/g, "" + (date.getHours() % 12 || 12)); + result = result.replace(/%1\$tM/g, _pad2(date.getMinutes())); + result = result.replace(/%1\$tS/g, _pad2(date.getSeconds())); + result = result.replace(/%1\$tp/g, date.getHours() < 12 ? "am" : "pm"); + return result; +} + +var formatState = function(itemType, state, stateDescription, options) { + let opts = options || {}; + if (state === "NULL" || state === "UNDEF") { + return "--"; + } + + switch (itemType) { + case "Switch": + return state; + case "Dimmer": + return Math.round(parseFloat(state)) + "%"; + case "Number": { + // Apply stateDescription pattern for Number types (e.g. "%.1f Β°C") + let formatted = _applyPattern(state, stateDescription); + if (formatted !== null) return formatted; + return state; + } + case "Group": { + // Groups with numeric aggregation (AVG, SUM, etc.) + let formatted = _applyPattern(state, stateDescription); + if (formatted !== null) return formatted; + return state; + } + case "Contact": + return state === "OPEN" ? "Open" : "Closed"; + case "Rollershutter": + return Math.round(parseFloat(state)) + "%"; + case "Color": { + // HSB format: "hue,saturation,brightness" + if (opts.hideColorPercent) return ""; + let parts = state.split(","); + if (parts.length === 3) { + return Math.round(parseFloat(parts[2])) + "%"; + } + return state; + } + case "DateTime": + try { + let date = new Date(state); + if (isNaN(date.getTime())) return state; + if (stateDescription && stateDescription.pattern) { + return _applyDatePattern(date, stateDescription.pattern); + } + return date.toLocaleString(); + } catch (e) { + return state; + } + case "Player": + return state; + default: + return state; + } +}; + +var buildMenuItems = function(itemData, sendCommandCallback, options) { + let items = []; + let type = itemData.type; + let state = itemData.state; + let label = itemData.label || itemData.name; + let opts = options || {}; + + // Strip ":" suffixed type info (e.g., "Number:Temperature" -> "Number") + let baseType = type.split(":")[0]; + + // Pass unitSymbol into stateDescription for formatting + let stateDesc = itemData.stateDescription || {}; + if (itemData.unitSymbol) stateDesc.unitSymbol = itemData.unitSymbol; + let formattedState = formatState(baseType, state, stateDesc); + + if (state === "NULL" || state === "UNDEF") { + let header = new PopupMenu.PopupMenuItem( + label + ": --", { reactive: false } + ); + items.push(header); + return items; + } + + if (opts.readOnly) { + // Read-only mode: show all types as non-interactive labels + let header = new PopupMenu.PopupMenuItem( + label + ": " + formattedState, { reactive: false } + ); + items.push(header); + return items; + } + + switch (baseType) { + case "Switch": + _buildSwitchMenu(items, label, state, sendCommandCallback); + break; + case "Dimmer": + _buildDimmerMenu(items, label, state, sendCommandCallback, opts.dimmerShowToggle); + break; + case "Rollershutter": + _buildRollershutterMenu(items, label, state, sendCommandCallback); + break; + case "Color": + _buildColorMenu(items, label, state, sendCommandCallback, opts); + break; + case "Player": + _buildPlayerMenu(items, label, state, sendCommandCallback); + break; + default: + // Read-only types: show label + formatted state + let header = new PopupMenu.PopupMenuItem( + label + ": " + formattedState, { reactive: false } + ); + items.push(header); + break; + } + + return items; +}; + +// Debounced slider command: sends command after 300ms of inactivity on value-changed. +// This ensures scroll-wheel changes (which don't fire drag-end) still send commands. +function _connectSliderDebounce(slider, commandFn) { + let timerId = null; + slider.connect("value-changed", () => { + if (timerId) { + Mainloop.source_remove(timerId); + } + timerId = Mainloop.timeout_add(300, () => { + timerId = null; + commandFn(slider.value); + return GLib.SOURCE_REMOVE; + }); + }); +} + +// Keep menu open: override activate to always pass keepMenu=true +function _keepMenuOpen(menuItem) { + menuItem.activate = function(event) { + this.emit('activate', event, true); + }; +} + +function _buildSwitchMenu(items, label, state, sendCommand) { + let switchItem = new PopupMenu.PopupSwitchMenuItem(label, state === "ON"); + switchItem.connect("activate", () => { + sendCommand(switchItem.state ? "ON" : "OFF"); + }); + _keepMenuOpen(switchItem); + items.push(switchItem); +} + +function _buildDimmerMenu(items, label, state, sendCommand, showToggle) { + if (showToggle) { + let isOn = state !== "0" && state !== "OFF"; + let switchItem = new PopupMenu.PopupSwitchMenuItem(label, isOn); + switchItem.connect("activate", () => { + sendCommand(switchItem.state ? "ON" : "OFF"); + }); + _keepMenuOpen(switchItem); + items.push(switchItem); + } else { + let dimmerLabel = new PopupMenu.PopupMenuItem( + label + ": " + Math.round(parseFloat(state) || 0) + "%", + { reactive: false } + ); + items.push(dimmerLabel); + } + + let sliderValue = parseFloat(state) / 100.0; + if (isNaN(sliderValue)) sliderValue = 0; + sliderValue = Math.max(0, Math.min(1, sliderValue)); + + let sliderItem = new PopupMenu.PopupSliderMenuItem(sliderValue); + sliderItem.connect("drag-end", (slider) => { + let pct = Math.round(slider.value * 100); + sendCommand(pct.toString()); + }); + _connectSliderDebounce(sliderItem, (value) => { + sendCommand(Math.round(value * 100).toString()); + }); + items.push(sliderItem); +} + +function _buildRollershutterMenu(items, label, state, sendCommand) { + let position = parseFloat(state); + if (isNaN(position)) position = 0; + + let upItem = new PopupMenu.PopupMenuItem(" \u25B2 UP"); + upItem.connect("activate", () => sendCommand("UP")); + _keepMenuOpen(upItem); + items.push(upItem); + + let stopItem = new PopupMenu.PopupMenuItem(" \u25A0 STOP"); + stopItem.connect("activate", () => sendCommand("STOP")); + _keepMenuOpen(stopItem); + items.push(stopItem); + + let downItem = new PopupMenu.PopupMenuItem(" \u25BC DOWN"); + downItem.connect("activate", () => sendCommand("DOWN")); + _keepMenuOpen(downItem); + items.push(downItem); + + let sliderValue = position / 100.0; + sliderValue = Math.max(0, Math.min(1, sliderValue)); + + let sliderItem = new PopupMenu.PopupSliderMenuItem(sliderValue); + sliderItem.connect("drag-end", (slider) => { + sendCommand(Math.round(slider.value * 100).toString()); + }); + _connectSliderDebounce(sliderItem, (value) => { + sendCommand(Math.round(value * 100).toString()); + }); + items.push(sliderItem); +} + +var hsbToHex = function(h, s, b) { + // h: 0-360, s: 0-100, b: 0-100 -> "#RRGGBB" + s = s / 100; + b = b / 100; + let c = b * s; + let x = c * (1 - Math.abs((h / 60) % 2 - 1)); + let m = b - c; + let r = 0, g = 0, bl = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; bl = x; } + else if (h < 240) { g = x; bl = c; } + else if (h < 300) { r = x; bl = c; } + else { r = c; bl = x; } + let toHex = function(v) { + let hex = Math.round((v + m) * 255).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }; + return "#" + toHex(r) + toHex(g) + toHex(bl); +} + +function _buildColorMenu(items, label, state, sendCommand, options) { + let opts = options || {}; + let parts = state.split(","); + let hue = 0, sat = 100, bri = 100; + if (parts.length === 3) { + hue = parseFloat(parts[0]) || 0; + sat = parseFloat(parts[1]) || 0; + bri = parseFloat(parts[2]) || 0; + } + + // ON/OFF toggle + let isOn = bri > 0; + let switchItem = new PopupMenu.PopupSwitchMenuItem(label, isOn); + switchItem.connect("activate", () => { + sendCommand(switchItem.state ? "ON" : "OFF"); + }); + _keepMenuOpen(switchItem); + items.push(switchItem); + + // Color preview swatch (rendered rectangle) + let colorHex = hsbToHex(hue, sat, isOn ? Math.max(bri, 20) : 0); + let previewItem = new PopupMenu.PopupMenuItem("", { reactive: false }); + previewItem.label.hide(); + let previewStyle = "background-color: " + colorHex + ";" + + " border: 1px solid rgba(255,255,255,0.3);" + + " border-radius: 4px;" + + " min-height: 28px;" + + " max-height: 28px;"; + let previewBox = new St.Bin({ + style: previewStyle, + x_expand: true, + }); + previewItem.addActor(previewBox, { span: -1, expand: true }); + items.push(previewItem); + + // Brightness slider (0-100) β€” most common adjustment + let briLabel = new PopupMenu.PopupMenuItem("Brightness: " + Math.round(bri) + "%", { reactive: false }); + items.push(briLabel); + let briSlider = new PopupMenu.PopupSliderMenuItem(bri / 100); + briSlider.connect("value-changed", (slider, value) => { + bri = Math.round(value * 100); + briLabel.label.set_text("Brightness: " + bri + "%"); + previewBox.set_style( + "background-color: " + hsbToHex(hue, sat, Math.max(bri, 5)) + ";" + + " border: 1px solid rgba(255,255,255,0.3);" + + " border-radius: 4px;" + + " min-height: 28px;" + + " max-height: 28px;" + ); + }); + briSlider.connect("drag-end", (slider) => { + bri = Math.round(slider.value * 100); + sendCommand(Math.round(hue) + "," + Math.round(sat) + "," + bri); + }); + _connectSliderDebounce(briSlider, (value) => { + let b = Math.round(value * 100); + sendCommand(Math.round(hue) + "," + Math.round(sat) + "," + b); + }); + items.push(briSlider); +} + + +function _buildPlayerMenu(items, label, state, sendCommand) { + let prevItem = new PopupMenu.PopupMenuItem(" \u23EE Previous"); + prevItem.connect("activate", () => sendCommand("PREVIOUS")); + _keepMenuOpen(prevItem); + items.push(prevItem); + + let isPlaying = state === "PLAY"; + let playPauseItem = new PopupMenu.PopupMenuItem( + isPlaying ? " \u23F8 Pause" : " \u25B6 Play" + ); + playPauseItem.connect("activate", () => sendCommand(isPlaying ? "PAUSE" : "PLAY")); + _keepMenuOpen(playPauseItem); + items.push(playPauseItem); + + let nextItem = new PopupMenu.PopupMenuItem(" \u23ED Next"); + nextItem.connect("activate", () => sendCommand("NEXT")); + _keepMenuOpen(nextItem); + items.push(nextItem); +} diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/metadata.json b/openhab-item@phoehnel/files/openhab-item@phoehnel/metadata.json new file mode 100644 index 00000000000..4eef4d7d2c5 --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/metadata.json @@ -0,0 +1,9 @@ +{ + "uuid": "openhab-item@phoehnel", + "name": "OpenHAB Item", + "description": "Display and control OpenHAB smart home items on the Cinnamon panel", + "version": "1.1.0", + "max-instances": -1, + "cinnamon-version": ["5.4", "5.6", "5.8", "6.0", "6.2", "6.4", "6.6"], + "author": "phoehnel" +} diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/serverConfig.js b/openhab-item@phoehnel/files/openhab-item@phoehnel/serverConfig.js new file mode 100644 index 00000000000..10813e6dc02 --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/serverConfig.js @@ -0,0 +1,98 @@ +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; + +const CONFIG_DIR_NAME = "openhab-item@phoehnel"; +const CONFIG_FILE_NAME = "server.json"; + +var ServerConfig = class ServerConfig { + constructor() { + this._configDir = GLib.build_filenamev([ + GLib.get_user_config_dir(), CONFIG_DIR_NAME + ]); + this._configPath = GLib.build_filenamev([ + this._configDir, CONFIG_FILE_NAME + ]); + this._monitor = null; + this._changeCallbacks = []; + } + + read(callback) { + let file = Gio.File.new_for_path(this._configPath); + file.load_contents_async(null, (source, result) => { + try { + let [ok, contents] = source.load_contents_finish(result); + if (ok) { + let text = imports.byteArray.toString(contents); + callback(JSON.parse(text)); + return; + } + } catch (e) { + if (!e.matches || !e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) { + global.logError("OpenHAB: Failed to read server config: " + e.message); + } + } + callback(null); + }); + } + + write(config) { + try { + GLib.mkdir_with_parents(this._configDir, 0o755); + let data = JSON.stringify(config, null, 2); + GLib.file_set_contents(this._configPath, data); + return true; + } catch (e) { + global.logError("OpenHAB: Failed to write server config: " + e.message); + return false; + } + } + + startMonitor(callback) { + this._changeCallbacks.push(callback); + + if (this._monitor) { + return; + } + + try { + // Ensure the config directory exists + GLib.mkdir_with_parents(this._configDir, 0o755); + let file = Gio.File.new_for_path(this._configPath); + + // Create default config if file doesn't exist yet + this.read((config) => { + if (!config) { + this.write({ serverUrl: "", apiToken: "", pollInterval: 30 }); + } + }); + + this._monitor = file.monitor_file(Gio.FileMonitorFlags.NONE, null); + this._monitor.connect("changed", (monitor, changedFile, otherFile, eventType) => { + if (eventType === Gio.FileMonitorEvent.CHANGES_DONE_HINT || + eventType === Gio.FileMonitorEvent.CHANGED) { + this.read((config) => { + if (config) { + for (let cb of this._changeCallbacks) { + try { + cb(config); + } catch (e) { + global.logError("OpenHAB: Config change callback error: " + e.message); + } + } + } + }); + } + }); + } catch (e) { + global.logError("OpenHAB: Failed to start config monitor: " + e.message); + } + } + + stopMonitor() { + if (this._monitor) { + this._monitor.cancel(); + this._monitor = null; + } + this._changeCallbacks = []; + } +}; diff --git a/openhab-item@phoehnel/files/openhab-item@phoehnel/settings-schema.json b/openhab-item@phoehnel/files/openhab-item@phoehnel/settings-schema.json new file mode 100644 index 00000000000..f0c629cdb67 --- /dev/null +++ b/openhab-item@phoehnel/files/openhab-item@phoehnel/settings-schema.json @@ -0,0 +1,205 @@ +{ + "layout": { + "type": "layout", + "pages": ["page_server", "page_item", "page_display"], + "page_server": { + "type": "page", + "title": "Server", + "sections": ["section_connection"] + }, + "page_item": { + "type": "page", + "title": "Item", + "sections": ["section_item"] + }, + "page_display": { + "type": "page", + "title": "Display", + "sections": ["section_display", "section_tooltip"] + }, + "section_connection": { + "type": "section", + "title": "OpenHAB Server", + "keys": ["serverUrl", "apiToken", "pollInterval"] + }, + "section_item": { + "type": "section", + "title": "Item Configuration", + "keys": ["itemName", "itemLabel", "additionalItems", "scrollDimmerItem", "scrollDimmerStep", "readOnly"] + }, + "section_display": { + "type": "section", + "title": "Display Options", + "keys": ["showIcon", "customIcon", "showLabel", "showState", "panelTextFormat", "doubleClickToggle", "dimmerShowToggle", "colorShowPercent", "colorPreviewWidth", "colorPreviewHeight", "popupAutoClose", "popupAutoCloseDelay"] + }, + "section_tooltip": { + "type": "section", + "title": "Tooltip (Mouseover)", + "keys": ["tooltipShowLabel", "tooltipShowType", "tooltipShowState", "tooltipShowName", "tooltipShowServer"] + } + }, + + "serverUrl": { + "type": "entry", + "default": "", + "description": "OpenHAB Server URL", + "tooltip": "URL of your OpenHAB server (e.g. http://openhabianpi:8080). Shared across all applet instances." + }, + "apiToken": { + "type": "entry", + "default": "", + "description": "API Token (optional)", + "tooltip": "Bearer token for authentication. Leave empty if not required." + }, + "pollInterval": { + "type": "spinbutton", + "default": 30, + "min": 1, + "max": 300, + "step": 1, + "units": "seconds", + "description": "Poll interval" + }, + "itemName": { + "type": "entry", + "default": "", + "description": "Item Name", + "tooltip": "The exact OpenHAB item name (e.g. LivingRoom_Light)" + }, + "itemLabel": { + "type": "entry", + "default": "", + "description": "Custom Label (optional)", + "tooltip": "Custom label for the panel. If empty, uses the item label from OpenHAB." + }, + "additionalItems": { + "type": "entry", + "default": "", + "description": "Additional items in popup (comma-separated)", + "tooltip": "Comma-separated item names to show in the popup menu (e.g. LivingRoom_Dimmer, LivingRoom_Color). Each item gets its own controls." + }, + "scrollDimmerItem": { + "type": "entry", + "default": "", + "description": "Scroll-wheel dimmer item (optional)", + "tooltip": "Item name to control via mouse wheel directly on the panel applet (no popup needed). For Dimmer items, this is auto-populated with the main item. Leave empty to disable scroll control." + }, + "scrollDimmerStep": { + "type": "spinbutton", + "default": 5, + "min": 1, + "max": 25, + "step": 1, + "units": "%", + "description": "Scroll step size" + }, + "showIcon": { + "type": "switch", + "default": true, + "description": "Show icon on panel" + }, + "customIcon": { + "type": "iconfilechooser", + "default": "", + "description": "Custom icon", + "tooltip": "Choose a custom icon. Leave empty to use the default icon for the item type.", + "dependency": "showIcon", + "allow-none": true + }, + "showLabel": { + "type": "switch", + "default": false, + "description": "Show label on panel" + }, + "showState": { + "type": "switch", + "default": true, + "description": "Show state value on panel" + }, + "panelTextFormat": { + "type": "entry", + "default": "{state}", + "description": "Panel text format", + "tooltip": "Format string. Available placeholders: {label}, {state}, {name}" + }, + "readOnly": { + "type": "switch", + "default": false, + "description": "Read-only mode (disable all controls)" + }, + "doubleClickToggle": { + "type": "switch", + "default": true, + "description": "Double-click to toggle Switch/Dimmer items" + }, + "dimmerShowToggle": { + "type": "switch", + "default": false, + "description": "Show ON/OFF toggle for Dimmer items" + }, + "colorShowPercent": { + "type": "switch", + "default": true, + "description": "Show brightness % on panel for Color items" + }, + "colorPreviewWidth": { + "type": "spinbutton", + "default": 16, + "min": 8, + "max": 64, + "step": 2, + "units": "px", + "description": "Color swatch width on panel", + "tooltip": "Width of the color preview swatch shown on the panel for Color items." + }, + "colorPreviewHeight": { + "type": "spinbutton", + "default": 16, + "min": 8, + "max": 64, + "step": 2, + "units": "px", + "description": "Color swatch height on panel", + "tooltip": "Height of the color preview swatch shown on the panel for Color items." + }, + "popupAutoClose": { + "type": "switch", + "default": true, + "description": "Auto-close popup menu after timeout" + }, + "popupAutoCloseDelay": { + "type": "spinbutton", + "default": 10, + "min": 3, + "max": 60, + "step": 1, + "units": "seconds", + "description": "Auto-close delay", + "dependency": "popupAutoClose" + }, + "tooltipShowLabel": { + "type": "switch", + "default": true, + "description": "Show label" + }, + "tooltipShowType": { + "type": "switch", + "default": false, + "description": "Show item type" + }, + "tooltipShowState": { + "type": "switch", + "default": true, + "description": "Show state value" + }, + "tooltipShowName": { + "type": "switch", + "default": false, + "description": "Show item name" + }, + "tooltipShowServer": { + "type": "switch", + "default": false, + "description": "Show server URL" + } +} diff --git a/openhab-item@phoehnel/info.json b/openhab-item@phoehnel/info.json new file mode 100644 index 00000000000..13bdb10f136 --- /dev/null +++ b/openhab-item@phoehnel/info.json @@ -0,0 +1,8 @@ +{ + "uuid": "openhab-item@phoehnel", + "name": "OpenHAB Item", + "description": "Display and control OpenHAB smart home items directly from the Cinnamon panel. Supports Switch, Dimmer, Number, Contact, Rollershutter, Color, Player, and more. Add multiple instances for different items.", + "author": "phoehnel", + "website": "https://github.com/cinnamon-spice-openhab", + "comments": "" +} diff --git a/openhab-item@phoehnel/screenshot.png b/openhab-item@phoehnel/screenshot.png new file mode 100644 index 00000000000..a854b0437ad Binary files /dev/null and b/openhab-item@phoehnel/screenshot.png differ