[grundfosalpha] Add support for Alpha3 pump (#18187)

* Add support for Alpha3
* Add I18N support for discovery labels

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
pull/18293/head
Jacob Laursen 2025-02-19 09:32:11 +01:00 committed by GitHub
parent 0ccaa3379b
commit 64eeeae32c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1343 additions and 55 deletions

View File

@ -1,19 +1,44 @@
# GrundfosAlpha Binding # GrundfosAlpha Binding
This adds support for reading out the data of Grundfos Alpha Pumps with a [Grundfos Alpha Reader](https://product-selection.grundfos.com/products/alpha-reader) This binding adds support for reading out the data of Grundfos Alpha pumps with a [Grundfos Alpha Reader](https://product-selection.grundfos.com/products/alpha-reader) or [Alpha3 pump](https://product-selection.grundfos.com/products/alpha/alpha3) with built-in Bluetooth.
The reverse engineering of the protocol was taken from [https://github.com/JsBergbau/AlphaDecoder](https://github.com/JsBergbau/AlphaDecoder). The reverse engineering of the Alpha Reader protocol was taken from [https://github.com/JsBergbau/AlphaDecoder](https://github.com/JsBergbau/AlphaDecoder).
## Supported Things ## Supported Things
- `alpha3`: The Grundfos Alpha3 pump
- `mi401`: The Grundfos MI401 ALPHA Reader - `mi401`: The Grundfos MI401 ALPHA Reader
## Discovery ## Discovery
All readers are auto-detected as soon as Bluetooth is configured in openHAB and the MI401 device is powered on. All pumps and readers are auto-detected as soon as Bluetooth is configured in openHAB and the devices are powered on.
## Thing Configuration ## Thing Configuration
### `alpha3` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|-----------------|---------|---------------------------------------------------------|---------|----------|----------|
| address | text | Bluetooth address in XX:XX:XX:XX:XX:XX format | N/A | yes | no |
| refreshInterval | integer | Number of seconds between fetching values from the pump | 30 | no | yes |
### Pairing
After creating the Thing, the binding will attempt to connect to the pump.
To start the pairing process, press the blue LED button on the pump.
When the LED stops blinking and stays lit, the connection has been established, and the Thing should appear online.
However, the pump may still not be bonded correctly, which could prevent the binding from reconnecting after a disconnection.
On Linux, you can take additional steps to fix this issue by manually pairing the pump:
```shell
bluetoothctl pair XX:XX:XX:XX:XX:XX
Attempting to pair with XX:XX:XX:XX:XX:XX
[CHG] Device XX:XX:XX:XX:XX:XX Bonded: yes
[CHG] Device XX:XX:XX:XX:XX:XX Paired: yes
Pairing successful
```
### `mi401` Thing Configuration ### `mi401` Thing Configuration
| Name | Type | Description | Default | Required | Advanced | | Name | Type | Description | Default | Required | Advanced |
@ -22,9 +47,22 @@ All readers are auto-detected as soon as Bluetooth is configured in openHAB and
## Channels ## Channels
### `alpha3` Channels
| Channel | Type | Read/Write | Description | | Channel | Type | Read/Write | Description |
|------------------|---------------------------|------------|------------------------------------| |------------------|---------------------------|------------|------------------------------------|
| rssi | Number | R | Received Signal Strength Indicator | | rssi | Number:Power | R | Received Signal Strength Indicator |
| flow-rate | Number:VolumetricFlowRate | R | The flow rate of the pump |
| pump-head | Number:Length | R | The water head above the pump |
| voltage-ac | Number:ElectricPotential | R | Current AC pump voltage |
| power | Number:Power | R | Current pump power consumption |
| motor-speed | Number:Frequency | R | Current rotation of the pump motor |
### `mi401` Channels
| Channel | Type | Read/Write | Description |
|------------------|---------------------------|------------|------------------------------------|
| rssi | Number:Power | R | Received Signal Strength Indicator |
| flow-rate | Number:VolumetricFlowRate | R | The flow rate of the pump | | flow-rate | Number:VolumetricFlowRate | R | The flow rate of the pump |
| pump-head | Number:Length | R | The water head above the pump | | pump-head | Number:Length | R | The water head above the pump |
| pump-temperature | Number:Temperature | R | The temperature of the pump | | pump-temperature | Number:Temperature | R | The temperature of the pump |

View File

@ -0,0 +1,93 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal;
import java.util.Arrays;
import java.util.Objects;
import java.util.UUID;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BluetoothCharacteristic;
import org.openhab.binding.bluetooth.BluetoothDevice;
import org.openhab.binding.bluetooth.grundfosalpha.internal.protocol.MessageType;
import org.openhab.core.util.HexUtils;
/**
* This represents a request for writing characteristic to a Bluetooth device.
*
* This can be used for adding such requests to a queue.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class CharacteristicRequest {
private UUID uuid;
private byte[] value;
/**
* Creates a new request object.
*
* @param uuid The UUID of the characteristic
* @param messageType The {@link MessageType} containing the data to write
*/
public CharacteristicRequest(UUID uuid, MessageType messageType) {
this.uuid = uuid;
this.value = messageType.request();
}
/**
* Writes the characteristic to the provided {@link BluetoothDevice}.
*
* @param device The Bluetooth device
* @return true if written, false if the characteristic is not found in the device
*/
public boolean send(BluetoothDevice device) {
BluetoothCharacteristic characteristic = device.getCharacteristic(uuid);
if (characteristic != null) {
device.writeCharacteristic(characteristic, value);
return true;
}
return false;
}
public UUID getUUID() {
return uuid;
}
public byte[] getValue() {
return value;
}
@Override
public boolean equals(@Nullable Object o) {
if (o == this) {
return true;
}
if (!(o instanceof CharacteristicRequest other)) {
return false;
}
return uuid.equals(other.uuid) && Arrays.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hash(uuid, value);
}
@Override
public String toString() {
return uuid + ": " + HexUtils.bytesToHex(value);
}
}

View File

@ -12,6 +12,8 @@
*/ */
package org.openhab.binding.bluetooth.grundfosalpha.internal; package org.openhab.binding.bluetooth.grundfosalpha.internal;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.bluetooth.BluetoothBindingConstants; import org.openhab.binding.bluetooth.BluetoothBindingConstants;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
@ -26,11 +28,21 @@ import org.openhab.core.thing.ThingTypeUID;
public class GrundfosAlphaBindingConstants { public class GrundfosAlphaBindingConstants {
// List of all Thing Type UIDs // List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_ALPHA3 = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID,
"alpha3");
public static final ThingTypeUID THING_TYPE_MI401 = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID, "mi401"); public static final ThingTypeUID THING_TYPE_MI401 = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID, "mi401");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ALPHA3, THING_TYPE_MI401);
// List of configuration parameters
public static final String CONFIGURATION_REFRESH_INTERVAL = "refreshInterval";
// List of all Channel ids // List of all Channel ids
public static final String CHANNEL_TYPE_FLOW_RATE = "flow-rate"; public static final String CHANNEL_FLOW_RATE = "flow-rate";
public static final String CHANNEL_TYPE_PUMP_HEAD = "pump-head"; public static final String CHANNEL_PUMP_HEAD = "pump-head";
public static final String CHANNEL_TYPE_BATTERY_LEVEL = "battery-level"; public static final String CHANNEL_BATTERY_LEVEL = "battery-level";
public static final String CHANNEL_TYPE_PUMP_TEMPERATUR = "pump-temperature"; public static final String CHANNEL_PUMP_TEMPERATURE = "pump-temperature";
public static final String CHANNEL_VOLTAGE_AC = "voltage-ac";
public static final String CHANNEL_POWER = "power";
public static final String CHANNEL_MOTOR_SPEED = "motor-speed";
} }

View File

@ -14,10 +14,10 @@ package org.openhab.binding.bluetooth.grundfosalpha.internal;
import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*; import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.grundfosalpha.internal.handler.GrundfosAlpha3Handler;
import org.openhab.binding.bluetooth.grundfosalpha.internal.handler.GrundfosAlphaReaderHandler;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@ -35,8 +35,6 @@ import org.osgi.service.component.annotations.Component;
@Component(configurationPid = "binding.bluetooth.grundfosalpha", service = ThingHandlerFactory.class) @Component(configurationPid = "binding.bluetooth.grundfosalpha", service = ThingHandlerFactory.class)
public class GrundfosAlphaHandlerFactory extends BaseThingHandlerFactory { public class GrundfosAlphaHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_MI401);
@Override @Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) { public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
@ -47,7 +45,9 @@ public class GrundfosAlphaHandlerFactory extends BaseThingHandlerFactory {
ThingTypeUID thingTypeUID = thing.getThingTypeUID(); ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_MI401.equals(thingTypeUID)) { if (THING_TYPE_MI401.equals(thingTypeUID)) {
return new GrundfosAlphaHandler(thing); return new GrundfosAlphaReaderHandler(thing);
} else if (THING_TYPE_ALPHA3.equals(thingTypeUID)) {
return new GrundfosAlpha3Handler(thing);
} }
return null; return null;

View File

@ -10,7 +10,10 @@
* *
* SPDX-License-Identifier: EPL-2.0 * SPDX-License-Identifier: EPL-2.0
*/ */
package org.openhab.binding.bluetooth.grundfosalpha.internal; package org.openhab.binding.bluetooth.grundfosalpha.internal.discovery;
import static org.openhab.binding.bluetooth.BluetoothBindingConstants.*;
import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -18,15 +21,18 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BluetoothBindingConstants;
import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice; import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice;
import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant; import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.ThingUID;
import org.osgi.framework.FrameworkUtil;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -34,16 +40,23 @@ import org.slf4j.LoggerFactory;
* This discovery participant is able to recognize Grundfos Alpha devices and create discovery results for them. * This discovery participant is able to recognize Grundfos Alpha devices and create discovery results for them.
* *
* @author Markus Heberling - Initial contribution * @author Markus Heberling - Initial contribution
* * @author Jacob Laursen - Added support for Alpha3
*/ */
@NonNullByDefault @NonNullByDefault
@Component @Component
public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryParticipant { public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryParticipant {
private final Logger logger = LoggerFactory.getLogger(GrundfosAlphaDiscoveryParticipant.class); private final Logger logger = LoggerFactory.getLogger(GrundfosAlphaDiscoveryParticipant.class);
private final TranslationProvider translationProvider;
@Activate
public GrundfosAlphaDiscoveryParticipant(final @Reference TranslationProvider translationProvider) {
this.translationProvider = translationProvider;
}
@Override @Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() { public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return Set.of(GrundfosAlphaBindingConstants.THING_TYPE_MI401); return SUPPORTED_THING_TYPES_UIDS;
} }
@Override @Override
@ -54,15 +67,26 @@ public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryPart
@Override @Override
public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) { public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) {
Integer manufacturerId = device.getManufacturerId(); Integer manufacturerId = device.getManufacturerId();
@Nullable
String name = device.getName(); String name = device.getName();
logger.debug("Discovered device {} with manufacturerId {} and name {}", device.getAddress(), manufacturerId, logger.debug("Discovered device {} with manufacturerId {} and name {}", device.getAddress(), manufacturerId,
name); name);
if ("MI401".equals(name)) {
return new ThingUID(GrundfosAlphaBindingConstants.THING_TYPE_MI401, device.getAdapter().getUID(), if (name == null) {
device.getAddress().toString().toLowerCase().replace(":", "")); return null;
} }
return null;
ThingTypeUID thingTypeUID = switch (name) {
case "Alpha3" -> THING_TYPE_ALPHA3;
case "MI401" -> THING_TYPE_MI401;
default -> null;
};
if (thingTypeUID == null) {
return null;
}
return new ThingUID(thingTypeUID, device.getAdapter().getUID(),
device.getAddress().toString().toLowerCase().replace(":", ""));
} }
@Override @Override
@ -71,18 +95,25 @@ public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryPart
if (thingUID == null) { if (thingUID == null) {
return null; return null;
} }
String label = "Grundfos Alpha Reader MI401";
String thingID = thingUID.getAsString().split(ThingUID.SEPARATOR)[1];
String label = translationProvider.getText(FrameworkUtil.getBundle(getClass()),
"discovery.%s.label".formatted(thingID), null, null);
Map<String, Object> properties = new HashMap<>(); Map<String, Object> properties = new HashMap<>();
properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString()); properties.put(CONFIGURATION_ADDRESS, device.getAddress().toString());
properties.put(Thing.PROPERTY_VENDOR, "Grundfos"); String deviceName = device.getName();
if (deviceName != null) {
properties.put(Thing.PROPERTY_MODEL_ID, deviceName);
}
Integer txPower = device.getTxPower(); Integer txPower = device.getTxPower();
if (txPower != null) { if (txPower != null) {
properties.put(BluetoothBindingConstants.PROPERTY_TXPOWER, Integer.toString(txPower)); properties.put(PROPERTY_TXPOWER, Integer.toString(txPower));
} }
// Create the discovery result and add to the inbox // Create the discovery result and add to the inbox
return DiscoveryResultBuilder.create(thingUID).withProperties(properties) return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
.withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS) .withRepresentationProperty(CONFIGURATION_ADDRESS).withBridge(device.getAdapter().getUID())
.withBridge(device.getAdapter().getUID()).withLabel(label).build(); .withLabel(label).build();
} }
} }

View File

@ -0,0 +1,285 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.handler;
import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BluetoothCharacteristic;
import org.openhab.binding.bluetooth.BluetoothDevice;
import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
import org.openhab.binding.bluetooth.BluetoothService;
import org.openhab.binding.bluetooth.ConnectedBluetoothHandler;
import org.openhab.binding.bluetooth.grundfosalpha.internal.CharacteristicRequest;
import org.openhab.binding.bluetooth.grundfosalpha.internal.protocol.MessageType;
import org.openhab.binding.bluetooth.grundfosalpha.internal.protocol.ResponseMessage;
import org.openhab.binding.bluetooth.grundfosalpha.internal.protocol.SensorDataType;
import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.util.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link GrundfosAlpha3Handler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class GrundfosAlpha3Handler extends ConnectedBluetoothHandler {
private static final UUID UUID_SERVICE_GENI = UUID.fromString("0000fe5d-0000-1000-8000-00805f9b34fb");
private static final UUID UUID_CHARACTERISTIC_GENI = UUID.fromString("859cffd1-036e-432a-aa28-1a0085b87ba9");
private static final Set<String> FLOW_HEAD_CHANNELS = Set.of(CHANNEL_FLOW_RATE, CHANNEL_PUMP_HEAD);
private static final Set<String> POWER_CHANNELS = Set.of(CHANNEL_VOLTAGE_AC, CHANNEL_POWER, CHANNEL_MOTOR_SPEED);
private static final int DEFAULT_REFRESH_INTERVAL_SECONDS = 30;
private final Logger logger = LoggerFactory.getLogger(GrundfosAlpha3Handler.class);
private final Lock stateLock = new ReentrantLock();
private final BlockingQueue<CharacteristicRequest> sendQueue = new LinkedBlockingQueue<>();
private @Nullable ScheduledFuture<?> refreshFuture;
private @Nullable WriteCharacteristicThread senderThread;
private ResponseMessage responseMessage = new ResponseMessage();
public GrundfosAlpha3Handler(Thing thing) {
super(thing);
}
@Override
public void initialize() {
super.initialize();
WriteCharacteristicThread senderThread = this.senderThread = new WriteCharacteristicThread(device);
senderThread.start();
}
@Override
public void dispose() {
WriteCharacteristicThread senderThread = this.senderThread;
if (senderThread != null) {
senderThread.interrupt();
this.senderThread = null;
}
cancelFuture();
super.dispose();
}
private void scheduleFuture() {
cancelFuture();
Object refreshIntervalRaw = getConfig().get(CONFIGURATION_REFRESH_INTERVAL);
int refreshInterval = DEFAULT_REFRESH_INTERVAL_SECONDS;
if (refreshIntervalRaw instanceof Number number) {
refreshInterval = number.intValue();
}
refreshFuture = scheduler.scheduleWithFixedDelay(this::refreshChannels, 0, refreshInterval, TimeUnit.SECONDS);
}
private void cancelFuture() {
ScheduledFuture<?> refreshFuture = this.refreshFuture;
if (refreshFuture != null) {
refreshFuture.cancel(true);
this.refreshFuture = null;
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
super.handleCommand(channelUID, command);
if (command != RefreshType.REFRESH) {
// Currently no writable channels
return;
}
if (device.getConnectionState() != ConnectionState.CONNECTED) {
logger.info("Cannot send command {} because device is not connected", command);
return;
}
if (FLOW_HEAD_CHANNELS.contains(channelUID.getId())) {
sendQueue.add(new CharacteristicRequest(UUID_CHARACTERISTIC_GENI, MessageType.FlowHead));
} else if (POWER_CHANNELS.contains(channelUID.getId())) {
sendQueue.add(new CharacteristicRequest(UUID_CHARACTERISTIC_GENI, MessageType.Power));
}
}
@Override
public void onServicesDiscovered() {
logger.debug("onServicesDiscovered");
super.onServicesDiscovered();
for (BluetoothService service : device.getServices()) {
logger.debug("Supported service for {}: {}", device.getName(), service.getUuid());
if (UUID_SERVICE_GENI.equals(service.getUuid())) {
List<BluetoothCharacteristic> characteristics = service.getCharacteristics();
for (BluetoothCharacteristic characteristic : characteristics) {
if (UUID_CHARACTERISTIC_GENI.equals(characteristic.getUuid())) {
logger.debug("Characteristic {} found for service {}", UUID_CHARACTERISTIC_GENI,
UUID_SERVICE_GENI);
device.enableNotifications(characteristic);
String deviceName = device.getName();
if (deviceName != null) {
updateProperty(Thing.PROPERTY_MODEL_ID, deviceName);
}
updateStatus(ThingStatus.ONLINE);
scheduleFuture();
}
}
}
}
}
@Override
public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
logger.debug("onScanRecordReceived");
// Avoid calling super method when it would set Thing status to online.
// Instead, we set the status in onServicesDiscovered when we have discovered
// the GENI service and characteristic, which means we have a pairing connection.
if (Integer.MIN_VALUE != scanNotification.getRssi()) {
super.onScanRecordReceived(scanNotification);
}
}
@Override
public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
super.onCharacteristicUpdate(characteristic, value);
stateLock.lock();
try {
if (!UUID_CHARACTERISTIC_GENI.equals(characteristic.getUuid())) {
if (logger.isDebugEnabled()) {
logger.debug("Received update {} to unknown characteristic {} of device {}",
HexUtils.bytesToHex(value), characteristic.getUuid(), address);
}
return;
}
if (responseMessage.addPacket(value)) {
Map<SensorDataType, BigDecimal> values = responseMessage.decode();
updateChannels(values);
responseMessage = new ResponseMessage();
}
} finally {
stateLock.unlock();
}
}
private void refreshChannels() {
if (device.getConnectionState() != ConnectionState.CONNECTED) {
return;
}
if (FLOW_HEAD_CHANNELS.stream().anyMatch(this::isLinked)) {
sendQueue.add(new CharacteristicRequest(UUID_CHARACTERISTIC_GENI, MessageType.FlowHead));
}
if (POWER_CHANNELS.stream().anyMatch(this::isLinked)) {
sendQueue.add(new CharacteristicRequest(UUID_CHARACTERISTIC_GENI, MessageType.Power));
}
}
private void updateChannels(Map<SensorDataType, BigDecimal> values) {
for (Entry<SensorDataType, BigDecimal> entry : values.entrySet()) {
BigDecimal stateValue = entry.getValue();
switch (entry.getKey()) {
case Flow -> updateState(CHANNEL_FLOW_RATE, new QuantityType<>(stateValue, Units.CUBICMETRE_PER_HOUR));
case Head -> updateState(CHANNEL_PUMP_HEAD, new QuantityType<>(stateValue, SIUnits.METRE));
case VoltageAC -> updateState(CHANNEL_VOLTAGE_AC, new QuantityType<>(stateValue, Units.VOLT));
case PowerConsumption -> updateState(CHANNEL_POWER, new QuantityType<>(stateValue, Units.WATT));
case MotorSpeed -> updateState(CHANNEL_MOTOR_SPEED, new QuantityType<>(stateValue, Units.RPM));
}
}
}
@Override
public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
super.onConnectionStateChange(connectionNotification);
logger.debug("{}", connectionNotification.getConnectionState());
}
private class WriteCharacteristicThread extends Thread {
private static final int REQUEST_DELAY_MS = 700;
private final BluetoothDevice device;
public WriteCharacteristicThread(BluetoothDevice device) {
super("OH-binding-" + getThing().getUID() + "-WriteCharacteristicThread");
this.device = device;
}
@Override
public void run() {
logger.debug("Starting sender thread");
while (!interrupted()) {
try {
processQueue();
Thread.sleep(REQUEST_DELAY_MS);
} catch (InterruptedException e) {
break;
}
}
logger.debug("Sender thread finished");
}
private void processQueue() throws InterruptedException {
logger.trace("Processing/await queue, size: {}", sendQueue.size());
CharacteristicRequest request = sendQueue.take();
if (logger.isDebugEnabled()) {
logger.debug("Writing characteristic {}: {}", request.getUUID(),
HexUtils.bytesToHex(request.getValue()));
}
if (request.send(device)) {
removeDuplicates(request);
}
}
private void removeDuplicates(CharacteristicRequest request) {
int duplicates = 0;
while (sendQueue.remove(request)) {
duplicates++;
}
if (duplicates > 0 && logger.isDebugEnabled()) {
logger.debug("Removed {} duplicate characteristic requests for '{}' from queue", duplicates, request);
}
}
}
}

View File

@ -10,7 +10,7 @@
* *
* SPDX-License-Identifier: EPL-2.0 * SPDX-License-Identifier: EPL-2.0
*/ */
package org.openhab.binding.bluetooth.grundfosalpha.internal; package org.openhab.binding.bluetooth.grundfosalpha.internal.handler;
import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*; import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*;
@ -20,28 +20,23 @@ import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.bluetooth.BeaconBluetoothHandler; import org.openhab.binding.bluetooth.BeaconBluetoothHandler;
import org.openhab.binding.bluetooth.BluetoothDeviceListener;
import org.openhab.binding.bluetooth.notification.BluetoothScanNotification; import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
import org.openhab.core.library.dimension.VolumetricFlowRate; import org.openhab.core.library.dimension.VolumetricFlowRate;
import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits; import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units; import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Thing; import org.openhab.core.thing.Thing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** /**
* The {@link GrundfosAlphaHandler} is responsible for handling commands, which are * The {@link GrundfosAlphaReaderHandler} is responsible for handling commands, which are
* sent to one of the channels. * sent to one of the channels.
* *
* @author Markus Heberling - Initial contribution * @author Markus Heberling - Initial contribution
*/ */
@NonNullByDefault @NonNullByDefault
public class GrundfosAlphaHandler extends BeaconBluetoothHandler implements BluetoothDeviceListener { public class GrundfosAlphaReaderHandler extends BeaconBluetoothHandler {
private final Logger logger = LoggerFactory.getLogger(GrundfosAlphaHandler.class); public GrundfosAlphaReaderHandler(Thing thing) {
public GrundfosAlphaHandler(Thing thing) {
super(thing); super(thing);
} }
@ -49,22 +44,22 @@ public class GrundfosAlphaHandler extends BeaconBluetoothHandler implements Blue
public void onScanRecordReceived(BluetoothScanNotification scanNotification) { public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
super.onScanRecordReceived(scanNotification); super.onScanRecordReceived(scanNotification);
byte[] data = scanNotification.getManufacturerData(); byte[] data = scanNotification.getManufacturerData();
if (data != null && data.length == 21) { if (data.length == 21) {
int batteryLevel = (data[5] & 0xFF) * 25; int batteryLevel = (data[5] & 0xFF) * 25;
QuantityType<Dimensionless> quantity = new QuantityType<>(batteryLevel, Units.PERCENT); QuantityType<Dimensionless> quantity = new QuantityType<>(batteryLevel, Units.PERCENT);
updateState(CHANNEL_TYPE_BATTERY_LEVEL, quantity); updateState(CHANNEL_BATTERY_LEVEL, quantity);
float flowRate = ((data[9] & 0xFF) << 8 | (data[8] & 0xFF)) / 6553.5f; float flowRate = ((data[9] & 0xFF) << 8 | (data[8] & 0xFF)) / 6553.5f;
QuantityType<VolumetricFlowRate> quantity2 = new QuantityType<>(flowRate, Units.CUBICMETRE_PER_HOUR); QuantityType<VolumetricFlowRate> quantity2 = new QuantityType<>(flowRate, Units.CUBICMETRE_PER_HOUR);
updateState(CHANNEL_TYPE_FLOW_RATE, quantity2); updateState(CHANNEL_FLOW_RATE, quantity2);
float pumpHead = ((data[11] & 0xFF) << 8 | (data[10] & 0xFF)) / 3276.7f; float pumpHead = ((data[11] & 0xFF) << 8 | (data[10] & 0xFF)) / 3276.7f;
QuantityType<Length> quantity3 = new QuantityType<>(pumpHead, SIUnits.METRE); QuantityType<Length> quantity3 = new QuantityType<>(pumpHead, SIUnits.METRE);
updateState(CHANNEL_TYPE_PUMP_HEAD, quantity3); updateState(CHANNEL_PUMP_HEAD, quantity3);
float pumpTemperature = data[14] & 0xFF; float pumpTemperature = data[14] & 0xFF;
QuantityType<Temperature> quantity4 = new QuantityType<>(pumpTemperature, SIUnits.CELSIUS); QuantityType<Temperature> quantity4 = new QuantityType<>(pumpTemperature, SIUnits.CELSIUS);
updateState(CHANNEL_TYPE_PUMP_TEMPERATUR, quantity4); updateState(CHANNEL_PUMP_TEMPERATURE, quantity4);
} }
} }
} }

View File

@ -0,0 +1,111 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Utility class for checksum calculation using the CRC-16-CCITT algorithm.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class CRC16Calculator {
public static final int[] LOOKUP_TABLE = { 0, 4129, 8258, 12387, 16516, 20645, 24774, 28903, 33032, 37161, 41290,
45419, 49548, 53677, 57806, 61935, 4657, 528, 12915, 8786, 21173, 17044, 29431, 25302, 37689, 33560, 45947,
41818, 54205, 50076, 62463, 58334, 9314, 13379, 1056, 5121, 25830, 29895, 17572, 21637, 42346, 46411, 34088,
38153, 58862, 62927, 50604, 54669, 13907, 9842, 5649, 1584, 30423, 26358, 22165, 18100, 46939, 42874, 38681,
34616, 63455, 59390, 55197, 51132, 18628, 22757, 26758, 30887, 2112, 6241, 10242, 14371, 51660, 55789,
59790, 63919, 35144, 39273, 43274, 47403, 23285, 19156, 31415, 27286, 6769, 2640, 14899, 10770, 56317,
52188, 64447, 60318, 39801, 35672, 47931, 43802, 27814, 31879, 19684, 23749, 11298, 15363, 3168, 7233,
60846, 64911, 52716, 56781, 44330, 48395, 36200, 40265, 32407, 28342, 24277, 20212, 15891, 11826, 7761,
3696, 65439, 61374, 57309, 53244, 48923, 44858, 40793, 36728, 37256, 33193, 45514, 41451, 53516, 49453,
61774, 57711, 4224, 161, 12482, 8419, 20484, 16421, 28742, 24679, 33721, 37784, 41979, 46042, 49981, 54044,
58239, 62302, 689, 4752, 8947, 13010, 16949, 21012, 25207, 29270, 46570, 42443, 38312, 34185, 62830, 58703,
54572, 50445, 13538, 9411, 5280, 1153, 29798, 25671, 21540, 17413, 42971, 47098, 34713, 38840, 59231, 63358,
50973, 55100, 9939, 14066, 1681, 5808, 26199, 30326, 17941, 22068, 55628, 51565, 63758, 59695, 39368, 35305,
47498, 43435, 22596, 18533, 30726, 26663, 6336, 2273, 14466, 10403, 52093, 56156, 60223, 64286, 35833,
39896, 43963, 48026, 19061, 23124, 27191, 31254, 2801, 6864, 10931, 14994, 64814, 60687, 56684, 52557,
48554, 44427, 40424, 36297, 31782, 27655, 23652, 19525, 15522, 11395, 7392, 3265, 61215, 65342, 53085,
57212, 44955, 49082, 36825, 40952, 28183, 32310, 20053, 24180, 11923, 16050, 3793, 7920 };
private static final int CRC16_INITIAL_VALUE = 0xffff;
private static final int CRC16_FINAL_XOR = 0xffff;
private static final int BYTE_MASK = 0xff;
private CRC16Calculator() {
// Prevent instantiation
}
/**
* Validates the CRC-16 checksum for the given data.
*
* @param data The data to validate, including checksum bytes.
* @return True if the checksum is valid, false otherwise.
*/
public static boolean check(byte[] data) {
if (data.length < 3) {
throw new IllegalArgumentException("Data array must contain at least 3 bytes.");
}
int dataLength = (data[1] & BYTE_MASK);
if (dataLength + 4 > data.length) {
throw new IllegalArgumentException("Invalid data length specified in the array.");
}
int crcValue = calculate(data, 1, dataLength + 1);
byte lowByte = getLowByte(crcValue);
byte highByte = getHighByte(crcValue);
return data[dataLength + 2] == highByte && data[dataLength + 3] == lowByte;
}
/**
* Calculates the CRC-16 checksum for the given data and puts it into
* the last two bytes of the given data array.
*
* @param data The data array to calculate CRC-16 with the two last bytes reserved for the result
* @param length The message size excluding start delimiter/size (first two bytes) and CRC-16 checksum (last two
* bytes)
*/
public static void put(byte[] data, int length) {
int dataLength = (data[1] & BYTE_MASK);
if (dataLength + 4 > data.length) {
throw new IllegalArgumentException("Invalid data length specified in the array.");
}
int crcValue = calculate(data, 1, length + 1);
data[length + 2] = getHighByte(crcValue);
data[length + 3] = getLowByte(crcValue);
}
private static int calculate(byte[] data, int start, int length) {
int value = CRC16_INITIAL_VALUE;
for (int i = 0; i < length; i++) {
int inputByte = data[start + i] & BYTE_MASK;
int tableIndex = (inputByte ^ (value >> 8)) & BYTE_MASK;
value = (LOOKUP_TABLE[tableIndex] ^ (value << 8)) & 0xffff;
}
return value ^ CRC16_FINAL_XOR;
}
private static byte getLowByte(int word) {
return (byte) (word & BYTE_MASK);
}
private static byte getHighByte(int word) {
return (byte) ((word >> 8) & BYTE_MASK);
}
}

View File

@ -0,0 +1,91 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This defines the protocol header.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class MessageHeader {
/**
* Header length including {@link MessageStartDelimiter} and size byte.
*/
public static final int LENGTH = 5;
private static final byte OFFSET_START_DELIMITER = 0;
private static final byte OFFSET_LENGTH = 1;
private static final byte OFFSET_SOURCE_ADDRESS = 2;
private static final byte OFFSET_DESTINATION_ADDRESS = 3;
private static final byte OFFSET_HEADER4 = 4;
/**
* Address of the controller/client (openHAB).
*/
private static final byte CONTROLLER_ADDRESS = (byte) 0xe7;
/**
* Address of the peripheral/server (pump).
*/
private static final byte PERIPHERAL_ADDRESS = (byte) 0xf8;
/**
* Last byte in header used for flowhead/power requests/responses.
* Not sure about meaning.
*/
private static final byte HEADER4_VALUE = (byte) 0x0a;
/**
* Fill in header for a request.
*
* @param request Request buffer
* @param messageLength The request size excluding {@link MessageStartDelimiter}, size byte and CRC-16 checksum
*/
public static void setRequestHeader(byte[] request, int messageLength) {
if (request.length < LENGTH) {
throw new IllegalArgumentException("Buffer is too small for header");
}
request[OFFSET_START_DELIMITER] = MessageStartDelimiter.Request.value();
request[OFFSET_LENGTH] = (byte) messageLength;
request[OFFSET_SOURCE_ADDRESS] = CONTROLLER_ADDRESS;
request[OFFSET_DESTINATION_ADDRESS] = PERIPHERAL_ADDRESS;
request[OFFSET_HEADER4] = HEADER4_VALUE;
}
/**
* Check if this packet is the first packet in a response payload.
*
* @param packet The packet to inspect
* @return true if determined to be first packet, otherwise false
*/
public static boolean isInitialResponsePacket(byte[] packet) {
return packet.length >= LENGTH && packet[OFFSET_START_DELIMITER] == MessageStartDelimiter.Reply.value()
&& packet[OFFSET_SOURCE_ADDRESS] == PERIPHERAL_ADDRESS
&& packet[OFFSET_DESTINATION_ADDRESS] == CONTROLLER_ADDRESS && packet[OFFSET_HEADER4] == HEADER4_VALUE;
}
/**
* Get total size of message including {@link MessageStartDelimiter}, size byte and CRC-16 checksum.
*
* @param header Header bytes (at least)
* @return total size
*/
public static int getTotalSize(byte[] header) {
return ((byte) header[MessageHeader.OFFSET_LENGTH]) + 4;
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This defines the start delimiters for different kinds of messages.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public enum MessageStartDelimiter {
Reply((byte) 0x24),
Message((byte) 0x26),
Request((byte) 0x27),
Echo((byte) 0x30);
private final byte value;
MessageStartDelimiter(byte value) {
this.value = value;
}
public byte value() {
return value;
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This represents the different types of messages that can be requested and received.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public enum MessageType {
FlowHead(new byte[] { 0x1f, 0x00, 0x01, 0x30, 0x01, 0x00, 0x00, 0x18 },
new byte[] { (byte) 0x03, (byte) 0x5d, (byte) 0x01, (byte) 0x21 }),
Power(new byte[] { 0x2c, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x25 },
new byte[] { (byte) 0x03, (byte) 0x57, (byte) 0x00, (byte) 0x45 });
private final byte[] responseType;
private final byte[] request;
MessageType(byte[] responseType, byte[] requestMessage) {
this.responseType = responseType;
int messageLength = requestMessage.length + 3;
request = new byte[messageLength + 4];
// Append the header
MessageHeader.setRequestHeader(request, messageLength);
// Append the message type-specific part
System.arraycopy(requestMessage, 0, request, MessageHeader.LENGTH, requestMessage.length);
CRC16Calculator.put(request, messageLength);
}
public byte[] responseType() {
return responseType;
}
public byte[] request() {
return request;
}
}

View File

@ -0,0 +1,137 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.util.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This represents a message received from the Alpha3 pump, made of individual
* packets received. Packets are expected to be in correct order, otherwise
* they will be skipped.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class ResponseMessage {
private final Logger logger = LoggerFactory.getLogger(ResponseMessage.class);
private static final int GENI_RESPONSE_MAX_SIZE = 259;
private static final int GENI_RESPONSE_TYPE_LENGTH = 8;
private int responseTotalSize;
private int responseOffset;
private int responseRemaining = Integer.MAX_VALUE;
private byte[] response = new byte[GENI_RESPONSE_MAX_SIZE];
/**
* Add packet from response payload.
*
* @param packet
* @return true if response is now complete
*/
public boolean addPacket(byte[] packet) {
if (logger.isTraceEnabled()) {
logger.trace("GENI response: {}", HexUtils.bytesToHex(packet));
}
boolean isFirstPacket = MessageHeader.isInitialResponsePacket(packet);
if (responseRemaining == Integer.MAX_VALUE) {
if (!MessageHeader.isInitialResponsePacket(packet)) {
if (logger.isDebugEnabled()) {
byte[] header = new byte[MessageHeader.LENGTH];
System.arraycopy(packet, 0, header, 0, MessageHeader.LENGTH);
if (logger.isDebugEnabled()) {
logger.debug("Response bytes {} don't match GENI header", HexUtils.bytesToHex(header));
}
}
return false;
}
responseTotalSize = MessageHeader.getTotalSize(packet);
responseOffset = 0;
responseRemaining = responseTotalSize;
} else if (isFirstPacket && responseRemaining > 0) {
logger.debug("Received new first packet while awaiting continuation, resetting");
responseTotalSize = MessageHeader.getTotalSize(packet);
responseOffset = 0;
responseRemaining = responseTotalSize;
}
System.arraycopy(packet, 0, response, responseOffset, packet.length);
responseOffset += packet.length;
responseRemaining -= packet.length;
if (responseRemaining < 0) {
responseRemaining = Integer.MAX_VALUE;
responseOffset = 0;
logger.debug("Received too many bytes");
return false;
}
if (responseRemaining == 0) {
if (!CRC16Calculator.check(response)) {
if (logger.isDebugEnabled()) {
logger.debug("CRC16 check failed for {}", HexUtils.bytesToHex(response));
}
responseRemaining = Integer.MAX_VALUE;
responseOffset = 0;
return false;
}
return true;
}
return false;
}
public Map<SensorDataType, BigDecimal> decode() {
HashMap<SensorDataType, BigDecimal> values = new HashMap<>();
for (SensorDataType dataType : SensorDataType.values()) {
decode(dataType).ifPresent(value -> values.put(dataType, new BigDecimal(value.getFloat())
.multiply(dataType.factor()).setScale(dataType.decimals(), RoundingMode.HALF_UP)));
}
return values;
}
private Optional<ByteBuffer> decode(SensorDataType dataType) {
byte[] expectedResponseType = dataType.messageType().responseType();
byte[] responseType = new byte[GENI_RESPONSE_TYPE_LENGTH];
System.arraycopy(response, MessageHeader.LENGTH, responseType, 0, GENI_RESPONSE_TYPE_LENGTH);
if (!Arrays.equals(expectedResponseType, responseType)) {
return Optional.empty();
}
int valueOffset = MessageHeader.LENGTH + GENI_RESPONSE_TYPE_LENGTH + dataType.offset();
byte[] valueBuffer = Arrays.copyOfRange(response, valueOffset, valueOffset + 4);
return Optional.of(ByteBuffer.wrap(valueBuffer).order(ByteOrder.BIG_ENDIAN));
}
}

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* This represents the different sensor data types that can
* be extracted from a {@link ResponseMessage}.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public enum SensorDataType {
Flow(MessageType.FlowHead, 0, new BigDecimal(3600), 3),
Head(MessageType.FlowHead, 4, new BigDecimal("0.0001"), 5),
VoltageAC(MessageType.Power, 0, BigDecimal.ONE, 1),
PowerConsumption(MessageType.Power, 12, BigDecimal.ONE, 1),
MotorSpeed(MessageType.Power, 20, BigDecimal.ONE, 0);
private final MessageType messageType;
private final int offset;
private final BigDecimal factor;
private final int decimals;
SensorDataType(MessageType messageType, int offset, BigDecimal factor, int decimals) {
this.messageType = messageType;
this.offset = offset;
this.factor = factor;
this.decimals = decimals;
}
public MessageType messageType() {
return messageType;
}
public int offset() {
return offset;
}
public BigDecimal factor() {
return factor;
}
public int decimals() {
return decimals;
}
}

View File

@ -1,10 +1,20 @@
# thing types # thing types
thing-type.bluetooth.alpha3.label = Grundfos Alpha3
thing-type.bluetooth.alpha3.description = A Grundfos Alpha3 circulator pump
thing-type.bluetooth.alpha3.channel.power.label = Power Consumption
thing-type.bluetooth.alpha3.channel.power.description = Current pump power consumption
thing-type.bluetooth.alpha3.channel.voltage-ac.label = AC Voltage
thing-type.bluetooth.alpha3.channel.voltage-ac.description = Current AC pump voltage
thing-type.bluetooth.mi401.label = Grundfos Alpha Reader MI401 thing-type.bluetooth.mi401.label = Grundfos Alpha Reader MI401
thing-type.bluetooth.mi401.description = A Grundfos Alpha Reader MI401 thing-type.bluetooth.mi401.description = A Grundfos Alpha Reader MI401
# thing types config # thing types config
thing-type.config.bluetooth.alpha3.address.label = Address
thing-type.config.bluetooth.alpha3.address.description = Bluetooth address in XX:XX:XX:XX:XX:XX format
thing-type.config.bluetooth.alpha3.refreshInterval.label = Refresh Interval
thing-type.config.bluetooth.alpha3.refreshInterval.description = Number of seconds between fetching values from the pump. Default is 30
thing-type.config.bluetooth.mi401.address.label = Address thing-type.config.bluetooth.mi401.address.label = Address
thing-type.config.bluetooth.mi401.address.description = Bluetooth address in XX:XX:XX:XX:XX:XX format thing-type.config.bluetooth.mi401.address.description = Bluetooth address in XX:XX:XX:XX:XX:XX format
@ -14,5 +24,12 @@ channel-type.bluetooth.grundfos-flow.label = Current Flow
channel-type.bluetooth.grundfos-flow.description = Current flow channel-type.bluetooth.grundfos-flow.description = Current flow
channel-type.bluetooth.grundfos-head.label = Current Head channel-type.bluetooth.grundfos-head.label = Current Head
channel-type.bluetooth.grundfos-head.description = Current head channel-type.bluetooth.grundfos-head.description = Current head
channel-type.bluetooth.grundfos-motor-speed.label = Motor Speed
channel-type.bluetooth.grundfos-motor-speed.description = Current rotation of the pump motor
channel-type.bluetooth.grundfos-temperature.label = Current Pump Temperature channel-type.bluetooth.grundfos-temperature.label = Current Pump Temperature
channel-type.bluetooth.grundfos-temperature.description = Current pump temperature channel-type.bluetooth.grundfos-temperature.description = Current pump temperature
# discovery result
discovery.alpha3.label = Grundfos Alpha3 Circulator Pump
discovery.mi401.label = Grundfos Alpha Reader MI401

View File

@ -32,6 +32,14 @@
<state readOnly="true" pattern="%.2f %unit%"/> <state readOnly="true" pattern="%.2f %unit%"/>
</channel-type> </channel-type>
<channel-type id="grundfos-motor-speed">
<item-type unitHint="rpm">Number:Frequency</item-type>
<label>Motor Speed</label>
<description>Current rotation of the pump motor</description>
<category>Fan</category>
<state readOnly="true" pattern="%.0f rpm"/>
</channel-type>
<thing-type id="mi401"> <thing-type id="mi401">
<supported-bridge-type-refs> <supported-bridge-type-refs>
<bridge-type-ref id="roaming"/> <bridge-type-ref id="roaming"/>
@ -51,12 +59,65 @@
<channel id="battery-level" typeId="system.battery-level"/> <channel id="battery-level" typeId="system.battery-level"/>
</channels> </channels>
<properties>
<property name="vendor">Grundfos</property>
</properties>
<representation-property>address</representation-property>
<config-description> <config-description>
<parameter name="address" type="text"> <parameter name="address" type="text">
<label>Address</label> <label>Address</label>
<description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description> <description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
</parameter> </parameter>
</config-description> </config-description>
</thing-type> </thing-type>
<thing-type id="alpha3">
<supported-bridge-type-refs>
<bridge-type-ref id="roaming"/>
<bridge-type-ref id="bluegiga"/>
<bridge-type-ref id="bluez"/>
</supported-bridge-type-refs>
<label>Grundfos Alpha3</label>
<description>A Grundfos Alpha3 circulator pump</description>
<category>Pump</category>
<channels>
<channel id="rssi" typeId="rssi"/>
<channel id="flow-rate" typeId="grundfos-flow"/>
<channel id="pump-head" typeId="grundfos-head"/>
<channel id="voltage-ac" typeId="system.electric-voltage">
<label>AC Voltage</label>
<description>Current AC pump voltage</description>
</channel>
<channel id="power" typeId="system.electric-power">
<label>Power Consumption</label>
<description>Current pump power consumption</description>
</channel>
<channel id="motor-speed" typeId="grundfos-motor-speed"/>
</channels>
<properties>
<property name="vendor">Grundfos</property>
</properties>
<representation-property>address</representation-property>
<config-description>
<parameter name="address" type="text">
<label>Address</label>
<description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
</parameter>
<parameter name="refreshInterval" type="integer" min="5" required="false" unit="s">
<default>30</default>
<label>Refresh Interval</label>
<description>Number of seconds between fetching values from the pump. Default is 30</description>
<advanced>true</advanced>
<unitLabel>seconds</unitLabel>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions> </thing:thing-descriptions>

View File

@ -10,16 +10,18 @@
* *
* SPDX-License-Identifier: EPL-2.0 * SPDX-License-Identifier: EPL-2.0
*/ */
package org.openhab.binding.bluetooth.grundfosalpha.internal; package org.openhab.binding.bluetooth.grundfosalpha.internal.handler;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants;
import org.openhab.binding.bluetooth.notification.BluetoothScanNotification; import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits; import org.openhab.core.library.unit.SIUnits;
@ -34,23 +36,23 @@ import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.util.HexUtils; import org.openhab.core.util.HexUtils;
/** /**
* Test the {@link GrundfosAlphaHandler}. * Test the {@link GrundfosAlphaReaderHandler}.
* *
* @author Markus Heberling - Initial contribution * @author Markus Heberling - Initial contribution
*/ */
@NonNullByDefault
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class GrundfosAlphaHandlerTest { class GrundfosAlphaReaderHandlerTest {
private @Mock Thing thingMock; private @NonNullByDefault({}) @Mock Thing thingMock;
private @NonNullByDefault({}) @Mock ThingHandlerCallback callback;
private @Mock ThingHandlerCallback callback;
@Test @Test
public void testMessageType0xf1() { public void testMessageType0xf1() {
byte[] data = HexUtils.hexToBytes("15f130017a5113030300994109589916613003004005"); byte[] data = HexUtils.hexToBytes("15f130017a5113030300994109589916613003004005");
final BluetoothScanNotification scanNotification = new BluetoothScanNotification(); final BluetoothScanNotification scanNotification = new BluetoothScanNotification();
scanNotification.setManufacturerData(data); scanNotification.setManufacturerData(data);
final GrundfosAlphaHandler handler = new GrundfosAlphaHandler(thingMock); final GrundfosAlphaReaderHandler handler = new GrundfosAlphaReaderHandler(thingMock);
handler.setCallback(callback); handler.setCallback(callback);
handler.onScanRecordReceived(scanNotification); handler.onScanRecordReceived(scanNotification);
@ -67,23 +69,23 @@ class GrundfosAlphaHandlerTest {
byte[] data = HexUtils.hexToBytes("14f23001650305065419b9180f011f007c1878170d"); byte[] data = HexUtils.hexToBytes("14f23001650305065419b9180f011f007c1878170d");
final BluetoothScanNotification scanNotification = new BluetoothScanNotification(); final BluetoothScanNotification scanNotification = new BluetoothScanNotification();
scanNotification.setManufacturerData(data); scanNotification.setManufacturerData(data);
final GrundfosAlphaHandler handler = new GrundfosAlphaHandler(thingMock); final GrundfosAlphaReaderHandler handler = new GrundfosAlphaReaderHandler(thingMock);
handler.setCallback(callback); handler.setCallback(callback);
handler.onScanRecordReceived(scanNotification); handler.onScanRecordReceived(scanNotification);
verify(callback).statusUpdated(thingMock, verify(callback).statusUpdated(thingMock,
new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null)); new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null));
verify(callback).stateUpdated( verify(callback).stateUpdated(
new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_TYPE_BATTERY_LEVEL), new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_BATTERY_LEVEL),
new QuantityType<>(75, Units.PERCENT)); new QuantityType<>(75, Units.PERCENT));
verify(callback).stateUpdated( verify(callback).stateUpdated(
new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_TYPE_FLOW_RATE), new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_FLOW_RATE),
new QuantityType<>(0.98939496, Units.CUBICMETRE_PER_HOUR)); new QuantityType<>(0.98939496, Units.CUBICMETRE_PER_HOUR));
verify(callback).stateUpdated( verify(callback).stateUpdated(
new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_TYPE_PUMP_HEAD), new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_PUMP_HEAD),
new QuantityType<>(1.9315165, SIUnits.METRE)); new QuantityType<>(1.9315165, SIUnits.METRE));
verify(callback).stateUpdated( verify(callback).stateUpdated(
new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_TYPE_PUMP_TEMPERATUR), new ChannelUID(thingMock.getUID(), GrundfosAlphaBindingConstants.CHANNEL_PUMP_TEMPERATURE),
new QuantityType<>(31, SIUnits.CELSIUS)); new QuantityType<>(31, SIUnits.CELSIUS));
verifyNoMoreInteractions(callback); verifyNoMoreInteractions(callback);

View File

@ -0,0 +1,91 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.core.util.HexUtils;
/**
* Tests for {@link CRC16Calculator}.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class CRC16CalculatorTest {
private static final int MAX_16BIT = 0xFFFF;
private static final int MSB_16BIT = 0x8000;
private static final int POLYNOMIAL = 0x1021; // CRC-16-CCITT polynomial
@Test
void precomputedValuesAreCorrect() {
int[] table = new int[256];
for (int byteValue = 0; byteValue < 256; byteValue++) {
table[byteValue] = computeCRCForByte(byteValue);
}
assertThat(table, is(CRC16Calculator.LOOKUP_TABLE));
}
private static int computeCRCForByte(int inputByte) {
int shiftedInput = inputByte << 8;
int crc = 0;
for (int bit = 0; bit < 8; bit++) {
if (((shiftedInput ^ crc) & MSB_16BIT) != 0) {
crc = (crc << 1) ^ POLYNOMIAL;
} else {
crc <<= 1;
}
shiftedInput <<= 1;
crc &= MAX_16BIT;
}
return crc;
}
@Test
void checkIsTrueForValidResponse() {
byte[] response = HexUtils.hexToBytes(
"2430F8E70A2C000100010000254357878B439781803D21B00040F19C0040EA4A404536FDB4FFC00000421C000042040000017317");
assertThat(CRC16Calculator.check(response), is(true));
}
@Test
void checkIsFalseForInvalidResponse() {
byte[] response = HexUtils.hexToBytes(
"2430F8E70A2C000100010000254357878B439781803D21B00040F19C0040EA4A404536FDB4FFC00000421C000042040000017318");
assertThat(CRC16Calculator.check(response), is(false));
}
@Test
void checkThrowsWhenResponseTooShort() {
byte[] response = HexUtils.hexToBytes("2430");
assertThrows(IllegalArgumentException.class, () -> {
CRC16Calculator.check(response);
});
}
@Test
void checkThrowsWhenInvalidDataLength() {
byte[] response = HexUtils.hexToBytes(
"2430F8E70A2C000100010000254357878B439781803D21B00040F19C0040EA4A404536FDB4FFC00000421C0000420400000173");
assertThrows(IllegalArgumentException.class, () -> {
CRC16Calculator.check(response);
});
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.core.util.HexUtils;
/**
* Tests for {@link MessageType}.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class MessageTypeTest {
@Test
void requestFlowHead() {
String expected = "27 07 E7 F8 0A 03 5D 01 21 52 1F";
assertThat(HexUtils.bytesToHex(MessageType.FlowHead.request(), " "), is(expected));
}
@Test
void requestPower() {
String expected = "27 07 E7 F8 0A 03 57 00 45 8A CD";
assertThat(HexUtils.bytesToHex(MessageType.Power.request(), " "), is(expected));
}
}

View File

@ -0,0 +1,132 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.grundfosalpha.internal.protocol;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import java.math.BigDecimal;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.util.HexUtils;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
/**
* Tests for {@link ResponseMessage}.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class ResponseMessageTest {
@BeforeEach
public void setUp() {
final Logger logger = (Logger) LoggerFactory.getLogger(ResponseMessage.class);
logger.setLevel(Level.OFF);
}
@Test
void addPacketFullFlowRateResponseIsParsedWhenValid() {
byte[] packet1 = HexUtils.hexToBytes("2423F8E70A1F000130010000183952A66C468F48");
byte[] packet2 = HexUtils.hexToBytes("AC7FFFFFFF7FFFFFFF41FF21397FFFFFFF44A8");
var decoder = new ResponseMessage();
boolean isFull;
isFull = decoder.addPacket(packet1);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet2);
assertThat(isFull, is(true));
if (isFull) {
Map<SensorDataType, BigDecimal> values = decoder.decode();
assertThat(values.entrySet(), hasSize(2));
assertThat(values, hasEntry(SensorDataType.Flow, new BigDecimal("0.723")));
assertThat(values, hasEntry(SensorDataType.Head, new BigDecimal("1.83403")));
}
}
@Test
void addPacketFullPowerResponseIsParsedWhenValid() {
byte[] packet1 = HexUtils.hexToBytes("2430F8E70A2C000100010000254357878B439781");
byte[] packet2 = HexUtils.hexToBytes("803D21B00040F19C0040EA4A404536FDB4FFC000");
byte[] packet3 = HexUtils.hexToBytes("00421C000042040000017317");
var decoder = new ResponseMessage();
boolean isFull;
isFull = decoder.addPacket(packet1);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet2);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet3);
assertThat(isFull, is(true));
if (isFull) {
Map<SensorDataType, BigDecimal> values = decoder.decode();
assertThat(values.entrySet(), hasSize(3));
assertThat(values, hasEntry(SensorDataType.VoltageAC, new BigDecimal("215.5")));
assertThat(values, hasEntry(SensorDataType.PowerConsumption, new BigDecimal("7.6")));
assertThat(values, hasEntry(SensorDataType.MotorSpeed, new BigDecimal("2928")));
}
}
@Test
void addPacketFullPowerResponseWhileAwaitingContinuationIsParsedWhenValid() {
byte[] packet1 = HexUtils.hexToBytes("2430F8E70A2C000100010000254357878B439781");
byte[] packet2 = HexUtils.hexToBytes("2430F8E70A2C000100010000254357878B439781");
byte[] packet3 = HexUtils.hexToBytes("803D21B00040F19C0040EA4A404536FDB4FFC000");
byte[] packet4 = HexUtils.hexToBytes("00421C000042040000017317");
var decoder = new ResponseMessage();
boolean isFull;
isFull = decoder.addPacket(packet1);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet2);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet3);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet4);
assertThat(isFull, is(true));
if (isFull) {
Map<SensorDataType, BigDecimal> values = decoder.decode();
assertThat(values.entrySet(), hasSize(3));
assertThat(values, hasEntry(SensorDataType.VoltageAC, new BigDecimal("215.5")));
assertThat(values, hasEntry(SensorDataType.PowerConsumption, new BigDecimal("7.6")));
assertThat(values, hasEntry(SensorDataType.MotorSpeed, new BigDecimal("2928")));
}
}
@Test
void addPacketFullPowerResponseAfterOutOfSyncPacketIsParsedWhenValid() {
byte[] packet1 = HexUtils.hexToBytes("AC7FFFFFFF7FFFFFFF41FF21397FFFFFFF44A8");
byte[] packet2 = HexUtils.hexToBytes("2430F8E70A2C000100010000254357878B439781");
byte[] packet3 = HexUtils.hexToBytes("803D21B00040F19C0040EA4A404536FDB4FFC000");
byte[] packet4 = HexUtils.hexToBytes("00421C000042040000017317");
var decoder = new ResponseMessage();
boolean isFull;
isFull = decoder.addPacket(packet1);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet2);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet3);
assertThat(isFull, is(false));
isFull = decoder.addPacket(packet4);
assertThat(isFull, is(true));
if (isFull) {
Map<SensorDataType, BigDecimal> values = decoder.decode();
assertThat(values.entrySet(), hasSize(3));
assertThat(values, hasEntry(SensorDataType.VoltageAC, new BigDecimal("215.5")));
assertThat(values, hasEntry(SensorDataType.PowerConsumption, new BigDecimal("7.6")));
assertThat(values, hasEntry(SensorDataType.MotorSpeed, new BigDecimal("2928")));
}
}
}