[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
parent
0ccaa3379b
commit
64eeeae32c
|
@ -1,19 +1,44 @@
|
|||
# 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
|
||||
|
||||
- `alpha3`: The Grundfos Alpha3 pump
|
||||
- `mi401`: The Grundfos MI401 ALPHA Reader
|
||||
|
||||
## 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
|
||||
|
||||
### `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
|
||||
|
||||
| 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
|
||||
|
||||
### `alpha3` Channels
|
||||
|
||||
| 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 |
|
||||
| pump-head | Number:Length | R | The water head above the pump |
|
||||
| pump-temperature | Number:Temperature | R | The temperature of the pump |
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,8 @@
|
|||
*/
|
||||
package org.openhab.binding.bluetooth.grundfosalpha.internal;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.bluetooth.BluetoothBindingConstants;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
@ -26,11 +28,21 @@ import org.openhab.core.thing.ThingTypeUID;
|
|||
public class GrundfosAlphaBindingConstants {
|
||||
|
||||
// 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 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
|
||||
public static final String CHANNEL_TYPE_FLOW_RATE = "flow-rate";
|
||||
public static final String CHANNEL_TYPE_PUMP_HEAD = "pump-head";
|
||||
public static final String CHANNEL_TYPE_BATTERY_LEVEL = "battery-level";
|
||||
public static final String CHANNEL_TYPE_PUMP_TEMPERATUR = "pump-temperature";
|
||||
public static final String CHANNEL_FLOW_RATE = "flow-rate";
|
||||
public static final String CHANNEL_PUMP_HEAD = "pump-head";
|
||||
public static final String CHANNEL_BATTERY_LEVEL = "battery-level";
|
||||
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";
|
||||
}
|
||||
|
|
|
@ -14,10 +14,10 @@ package org.openhab.binding.bluetooth.grundfosalpha.internal;
|
|||
|
||||
import static org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants.*;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
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.ThingTypeUID;
|
||||
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)
|
||||
public class GrundfosAlphaHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_MI401);
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
|
@ -47,7 +45,9 @@ public class GrundfosAlphaHandlerFactory extends BaseThingHandlerFactory {
|
|||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
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;
|
||||
|
|
|
@ -10,7 +10,10 @@
|
|||
*
|
||||
* 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.Map;
|
||||
|
@ -18,15 +21,18 @@ import java.util.Set;
|
|||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
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.BluetoothDiscoveryParticipant;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.i18n.TranslationProvider;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
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.Reference;
|
||||
import org.slf4j.Logger;
|
||||
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.
|
||||
*
|
||||
* @author Markus Heberling - Initial contribution
|
||||
*
|
||||
* @author Jacob Laursen - Added support for Alpha3
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component
|
||||
public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryParticipant {
|
||||
private final Logger logger = LoggerFactory.getLogger(GrundfosAlphaDiscoveryParticipant.class);
|
||||
|
||||
private final TranslationProvider translationProvider;
|
||||
|
||||
@Activate
|
||||
public GrundfosAlphaDiscoveryParticipant(final @Reference TranslationProvider translationProvider) {
|
||||
this.translationProvider = translationProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
|
||||
return Set.of(GrundfosAlphaBindingConstants.THING_TYPE_MI401);
|
||||
return SUPPORTED_THING_TYPES_UIDS;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -54,15 +67,26 @@ public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryPart
|
|||
@Override
|
||||
public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) {
|
||||
Integer manufacturerId = device.getManufacturerId();
|
||||
@Nullable
|
||||
String name = device.getName();
|
||||
logger.debug("Discovered device {} with manufacturerId {} and name {}", device.getAddress(), manufacturerId,
|
||||
name);
|
||||
if ("MI401".equals(name)) {
|
||||
return new ThingUID(GrundfosAlphaBindingConstants.THING_TYPE_MI401, device.getAdapter().getUID(),
|
||||
device.getAddress().toString().toLowerCase().replace(":", ""));
|
||||
|
||||
if (name == null) {
|
||||
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
|
||||
|
@ -71,18 +95,25 @@ public class GrundfosAlphaDiscoveryParticipant implements BluetoothDiscoveryPart
|
|||
if (thingUID == 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<>();
|
||||
properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
|
||||
properties.put(Thing.PROPERTY_VENDOR, "Grundfos");
|
||||
properties.put(CONFIGURATION_ADDRESS, device.getAddress().toString());
|
||||
String deviceName = device.getName();
|
||||
if (deviceName != null) {
|
||||
properties.put(Thing.PROPERTY_MODEL_ID, deviceName);
|
||||
}
|
||||
Integer txPower = device.getTxPower();
|
||||
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
|
||||
return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
|
||||
.withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
|
||||
.withBridge(device.getAdapter().getUID()).withLabel(label).build();
|
||||
.withRepresentationProperty(CONFIGURATION_ADDRESS).withBridge(device.getAdapter().getUID())
|
||||
.withLabel(label).build();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@
|
|||
*
|
||||
* 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.*;
|
||||
|
||||
|
@ -20,28 +20,23 @@ import javax.measure.quantity.Temperature;
|
|||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.openhab.binding.bluetooth.BeaconBluetoothHandler;
|
||||
import org.openhab.binding.bluetooth.BluetoothDeviceListener;
|
||||
import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
|
||||
import org.openhab.core.library.dimension.VolumetricFlowRate;
|
||||
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.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.
|
||||
*
|
||||
* @author Markus Heberling - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class GrundfosAlphaHandler extends BeaconBluetoothHandler implements BluetoothDeviceListener {
|
||||
public class GrundfosAlphaReaderHandler extends BeaconBluetoothHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(GrundfosAlphaHandler.class);
|
||||
|
||||
public GrundfosAlphaHandler(Thing thing) {
|
||||
public GrundfosAlphaReaderHandler(Thing thing) {
|
||||
super(thing);
|
||||
}
|
||||
|
||||
|
@ -49,22 +44,22 @@ public class GrundfosAlphaHandler extends BeaconBluetoothHandler implements Blue
|
|||
public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
|
||||
super.onScanRecordReceived(scanNotification);
|
||||
byte[] data = scanNotification.getManufacturerData();
|
||||
if (data != null && data.length == 21) {
|
||||
if (data.length == 21) {
|
||||
int batteryLevel = (data[5] & 0xFF) * 25;
|
||||
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;
|
||||
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;
|
||||
QuantityType<Length> quantity3 = new QuantityType<>(pumpHead, SIUnits.METRE);
|
||||
updateState(CHANNEL_TYPE_PUMP_HEAD, quantity3);
|
||||
updateState(CHANNEL_PUMP_HEAD, quantity3);
|
||||
|
||||
float pumpTemperature = data[14] & 0xFF;
|
||||
QuantityType<Temperature> quantity4 = new QuantityType<>(pumpTemperature, SIUnits.CELSIUS);
|
||||
updateState(CHANNEL_TYPE_PUMP_TEMPERATUR, quantity4);
|
||||
updateState(CHANNEL_PUMP_TEMPERATURE, quantity4);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,10 +1,20 @@
|
|||
# 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.description = A Grundfos Alpha Reader MI401
|
||||
|
||||
# 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.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-head.label = 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.description = Current pump temperature
|
||||
|
||||
# discovery result
|
||||
|
||||
discovery.alpha3.label = Grundfos Alpha3 Circulator Pump
|
||||
discovery.mi401.label = Grundfos Alpha Reader MI401
|
||||
|
|
|
@ -32,6 +32,14 @@
|
|||
<state readOnly="true" pattern="%.2f %unit%"/>
|
||||
</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">
|
||||
<supported-bridge-type-refs>
|
||||
<bridge-type-ref id="roaming"/>
|
||||
|
@ -51,12 +59,65 @@
|
|||
<channel id="battery-level" typeId="system.battery-level"/>
|
||||
</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>
|
||||
</config-description>
|
||||
|
||||
</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>
|
||||
|
|
|
@ -10,16 +10,18 @@
|
|||
*
|
||||
* 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.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.openhab.binding.bluetooth.grundfosalpha.internal.GrundfosAlphaBindingConstants;
|
||||
import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
|
@ -34,23 +36,23 @@ import org.openhab.core.thing.binding.ThingHandlerCallback;
|
|||
import org.openhab.core.util.HexUtils;
|
||||
|
||||
/**
|
||||
* Test the {@link GrundfosAlphaHandler}.
|
||||
* Test the {@link GrundfosAlphaReaderHandler}.
|
||||
*
|
||||
* @author Markus Heberling - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GrundfosAlphaHandlerTest {
|
||||
class GrundfosAlphaReaderHandlerTest {
|
||||
|
||||
private @Mock Thing thingMock;
|
||||
|
||||
private @Mock ThingHandlerCallback callback;
|
||||
private @NonNullByDefault({}) @Mock Thing thingMock;
|
||||
private @NonNullByDefault({}) @Mock ThingHandlerCallback callback;
|
||||
|
||||
@Test
|
||||
public void testMessageType0xf1() {
|
||||
byte[] data = HexUtils.hexToBytes("15f130017a5113030300994109589916613003004005");
|
||||
final BluetoothScanNotification scanNotification = new BluetoothScanNotification();
|
||||
scanNotification.setManufacturerData(data);
|
||||
final GrundfosAlphaHandler handler = new GrundfosAlphaHandler(thingMock);
|
||||
final GrundfosAlphaReaderHandler handler = new GrundfosAlphaReaderHandler(thingMock);
|
||||
handler.setCallback(callback);
|
||||
handler.onScanRecordReceived(scanNotification);
|
||||
|
||||
|
@ -67,23 +69,23 @@ class GrundfosAlphaHandlerTest {
|
|||
byte[] data = HexUtils.hexToBytes("14f23001650305065419b9180f011f007c1878170d");
|
||||
final BluetoothScanNotification scanNotification = new BluetoothScanNotification();
|
||||
scanNotification.setManufacturerData(data);
|
||||
final GrundfosAlphaHandler handler = new GrundfosAlphaHandler(thingMock);
|
||||
final GrundfosAlphaReaderHandler handler = new GrundfosAlphaReaderHandler(thingMock);
|
||||
handler.setCallback(callback);
|
||||
handler.onScanRecordReceived(scanNotification);
|
||||
|
||||
verify(callback).statusUpdated(thingMock,
|
||||
new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
|
||||
verifyNoMoreInteractions(callback);
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue