[dahuadoor] Initial contribution (#20172)

* Initial commit

Signed-off-by: Sven Schad <svnsssd@gmail.com>
This commit is contained in:
Sven Schad
2026-03-29 21:37:26 +02:00
committed by GitHub
parent 8f44fcc90f
commit 236f1edc3c
21 changed files with 2255 additions and 0 deletions
+1
View File
@@ -76,6 +76,7 @@
/bundles/org.openhab.binding.cm11a/ @BobRak
/bundles/org.openhab.binding.comfoair/ @boehan
/bundles/org.openhab.binding.coolmasternet/ @projectgus
/bundles/org.openhab.binding.dahuadoor/ @svnsssd
/bundles/org.openhab.binding.daikin/ @caffineehacker
/bundles/org.openhab.binding.dali/ @rs22
/bundles/org.openhab.binding.danfossairunit/ @pravussum
+5
View File
@@ -366,6 +366,11 @@
<artifactId>org.openhab.binding.coolmasternet</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.dahuadoor</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.daikin</artifactId>
@@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
@@ -0,0 +1,138 @@
# DahuaDoor Binding
This binding integrates Dahua VTO Villastation door controllers with openHAB, enabling doorbell notifications, camera snapshots, and remote door control.
## Supported Things
| Thing Type | Thing ID | Description |
| ---------- | --------- | ------------------------------------------------ |
| VTO2202 | `vto2202` | Dahua VTO2202 outdoor station with single button |
| VTO3211 | `vto3211` | Dahua VTO3211 outdoor station with dual buttons |
## Discovery
Automatic discovery is not supported.
Things must be manually configured.
## Thing Configuration
### VTO2202/VTO3211 Device
Single-button outdoor station.
| Parameter | Type | Required | Description |
| ------------ | ---- | -------- | ---------------------------------------------------------------------------- |
| hostname | text | Yes | Hostname or IP address of the device (e.g., 192.168.1.100) |
| username | text | Yes | Username to access the device |
| password | text | Yes | Password to access the device |
| snapshotPath | text | Yes | Linux path where image files are stored (e.g., /var/lib/openhab/door-images) |
**Note:** Windows paths are not currently supported.
## Channels
### VTO2202 Channels (Single Button)
| Channel ID | Type | Read/Write | Description |
| ----------- | ------- | ---------- | --------------------------------------------------------- |
| bell-button | Trigger | Read | Triggers when doorbell button is pressed (event: PRESSED) |
| door-image | Image | Read | Camera snapshot taken when doorbell is pressed |
| open-door-1 | Switch | Write | Command to open door relay 1 |
| open-door-2 | Switch | Write | Command to open door relay 2 |
### VTO3211 Channels (Dual Button)
| Channel ID | Type | Read/Write | Description |
| ------------- | ------- | ---------- | -------------------------------------------------- |
| bell-button-1 | Trigger | Read | Triggers when button 1 is pressed (event: PRESSED) |
| bell-button-2 | Trigger | Read | Triggers when button 2 is pressed (event: PRESSED) |
| door-image-1 | Image | Read | Camera snapshot when button 1 is pressed |
| door-image-2 | Image | Read | Camera snapshot when button 2 is pressed |
| open-door-1 | Switch | Write | Command to open door relay 1 |
| open-door-2 | Switch | Write | Command to open door relay 2 |
## Full Example
### VTO2202 Example (Single Button)
#### Thing Configuration
```java
Thing dahuadoor:vto2202:frontdoor "Front Door Station" @ "Entrance" [
hostname="192.168.1.100",
username="admin",
password="password123",
snapshotPath="/var/lib/openhab/door-images"
]
```
#### Item Configuration
```java
Switch OpenFrontDoor "Open Front Door" <door> { channel="dahuadoor:vto2202:frontdoor:open-door-1" }
Image FrontDoorImage "Front Door Camera" <camera> { channel="dahuadoor:vto2202:frontdoor:door-image" }
```
#### Rule Configuration
Send smartphone notification with camera image when doorbell is pressed (requires openHAB Cloud Connector):
```java
rule "Doorbell Notification"
when
Channel "dahuadoor:vto2202:frontdoor:bell-button" triggered PRESSED
then
sendBroadcastNotification("Visitor at the door", "door",
"entrance", "Entrance", "door-notifications", null,
"item:FrontDoorImage",
"Open Door=command:OpenFrontDoor:ON", null)
end
```
### VTO3211 Example (Dual Button)
#### Thing Configuration
```java
Thing dahuadoor:vto3211:entrance "Entrance Station" @ "Entrance" [
hostname="192.168.1.101",
username="admin",
password="password123",
snapshotPath="/var/lib/openhab/door-images"
]
```
#### Item Configuration
```java
Switch OpenApartment1 "Open Apartment 1" <door> { channel="dahuadoor:vto3211:entrance:open-door-1" }
Switch OpenApartment2 "Open Apartment 2" <door> { channel="dahuadoor:vto3211:entrance:open-door-2" }
Image Apartment1Image "Apartment 1 Camera" <camera> { channel="dahuadoor:vto3211:entrance:door-image-1" }
Image Apartment2Image "Apartment 2 Camera" <camera> { channel="dahuadoor:vto3211:entrance:door-image-2" }
```
#### Rule Configuration
Send notifications for both buttons:
```java
rule "Apartment 1 Doorbell"
when
Channel "dahuadoor:vto3211:entrance:bell-button-1" triggered PRESSED
then
sendBroadcastNotification("Visitor at Apartment 1", "door",
"entrance", "Entrance", "door-notifications", null,
"item:Apartment1Image",
"Open Door=command:OpenApartment1:ON", null)
end
rule "Apartment 2 Doorbell"
when
Channel "dahuadoor:vto3211:entrance:bell-button-2" triggered PRESSED
then
sendBroadcastNotification("Visitor at Apartment 2", "door",
"entrance", "Entrance", "door-notifications", null,
"item:Apartment2Image",
"Open Door=command:OpenApartment2:ON", null)
end
```
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.dahuadoor</artifactId>
<name>openHAB Add-ons :: Bundles :: DahuaDoor Binding</name>
</project>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.dahuadoor-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-dahuadoor" description="DahuaDoor Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.dahuadoor/${project.version}</bundle>
</feature>
</features>
@@ -0,0 +1,575 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import static org.openhab.binding.dahuadoor.internal.DahuaDoorBindingConstants.*;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.dahuadoor.internal.dahuaeventhandler.DHIPEventListener;
import org.openhab.binding.dahuadoor.internal.dahuaeventhandler.DahuaEventClient;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
/**
* The {@link DahuaDoorBaseHandler} is responsible for handling commands, which
* are
* sent to one of the channels.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public abstract class DahuaDoorBaseHandler extends BaseThingHandler implements DHIPEventListener {
protected final Logger logger = LoggerFactory.getLogger(DahuaDoorBaseHandler.class);
protected @Nullable DahuaDoorConfiguration config;
protected Gson gson = new Gson();
protected @Nullable DahuaEventClient client = null;
public DahuaDoorBaseHandler(Thing thing) {
super(thing);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
DahuaEventClient localClient = client;
if (localClient == null) {
logger.warn("Client not initialized, cannot handle command");
return;
}
switch (channelUID.getId()) {
case CHANNEL_OPEN_DOOR_1:
if (command instanceof OnOffType) {
if (command == OnOffType.ON) {
localClient.openDoor(1);
updateState(channelUID, OnOffType.OFF);
}
}
break;
case CHANNEL_OPEN_DOOR_2:
if (command instanceof OnOffType) {
if (command == OnOffType.ON) {
localClient.openDoor(2);
updateState(channelUID, OnOffType.OFF);
}
}
break;
}
}
public void errorInformer(String msgError) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msgError);
}
@Override
public void initialize() {
DahuaDoorConfiguration localConfig = getConfigAs(DahuaDoorConfiguration.class);
config = localConfig;
// Validate required configuration
if (localConfig.hostname.isBlank() || localConfig.username.isBlank() || localConfig.password.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error-missing-credentials");
return;
}
if (localConfig.snapshotPath.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error-missing-snapshot-path");
return;
}
client = new DahuaEventClient(localConfig.hostname, localConfig.username, localConfig.password, this,
this::errorInformer);
// Set status to UNKNOWN - will be set to ONLINE when first DHIP event is received
updateStatus(ThingStatus.UNKNOWN);
}
@Override
public void dispose() {
DahuaEventClient localClient = client;
if (localClient != null) {
localClient.dispose();
client = null;
}
}
public void saveSnapshot(byte @Nullable [] buffer) {
final DahuaDoorConfiguration localConfig = config;
if (localConfig == null) {
logger.warn("Configuration not initialized");
return;
}
if (localConfig.snapshotPath.isEmpty()) {
logger.warn("Path for Snapshots is invalid");
return;
}
if (buffer == null) {
logger.warn("cannot save empty buffer");
return;
}
// Ensure snapshot directory exists
File snapshotDir = new File(localConfig.snapshotPath);
if (!snapshotDir.exists() && !snapshotDir.mkdirs()) {
logger.warn("Could not create snapshot directory '{}', check permissions", localConfig.snapshotPath);
return;
}
String timestamp = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ROOT).format(new Date());
String filename = localConfig.snapshotPath + "/DoorBell_" + timestamp + ".jpg";
try (FileOutputStream fos = new FileOutputStream(new File(filename))) {
fos.write(buffer);
} catch (IOException e) {
logger.warn("Could not write image to file '{}', check permissions and path", filename, e);
}
// Write buffer directly to latest snapshot file (avoids copy-from-source failures)
String latestSnapshotFilename = localConfig.snapshotPath + "/Doorbell.jpg";
try (FileOutputStream fos = new FileOutputStream(new File(latestSnapshotFilename))) {
fos.write(buffer);
} catch (IOException e) {
logger.warn("Could not write latest snapshot to '{}', check permissions and path", latestSnapshotFilename,
e);
}
}
private void updateChannelImage(byte @Nullable [] buffer) {
if (buffer == null || buffer.length == 0) {
updateState(CHANNEL_DOOR_IMAGE, UnDefType.UNDEF);
return;
}
RawType image = new RawType(buffer, "image/jpeg");
updateState(CHANNEL_DOOR_IMAGE, image);
}
protected void handleButtonPressed() {
Channel channel = this.getThing().getChannel(CHANNEL_BELL_BUTTON);
if (channel == null) {
logger.warn("Bell button channel not found");
return;
}
triggerChannel(channel.getUID(), "PRESSED");
DahuaEventClient localClient = client;
if (localClient == null) {
logger.warn("Client not initialized, cannot retrieve doorbell image");
return;
}
byte[] buffer = localClient.requestImage();
updateChannelImage(buffer);
saveSnapshot(buffer);
}
@Override
public void onEvent(JsonObject eventData) {
// Set thing ONLINE when first event is received (confirms successful
// connection)
if (getThing().getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.ONLINE);
}
try {
logger.trace("JSON{}", eventData);
JsonObject jsonObj = eventData.getAsJsonObject("params");
if (jsonObj == null) {
logger.debug("Missing 'params' object in DahuaDoor event. Raw payload: {}", eventData.toString());
return;
}
JsonArray firstLevel = jsonObj.getAsJsonArray("eventList");
if (firstLevel == null || firstLevel.isEmpty()) {
logger.debug("Missing or empty 'eventList' array in DahuaDoor event. Raw payload: {}",
eventData.toString());
return;
}
JsonObject eventList = firstLevel.get(0).getAsJsonObject();
if (!eventList.has("Code")) {
logger.debug("Missing 'Code' in event data. Raw payload: {}", eventData.toString());
return;
}
String eventCodeString = eventList.get("Code").getAsString();
DahuaEventCode eventCode = DahuaEventCode.fromString(eventCodeString);
JsonObject eventPayload = eventList.getAsJsonObject("Data");
if (firstLevel.size() > 1) {
logger.debug("Event Manager subscription reply: {}", eventCodeString);
} else {
switch (eventCode) {
case CALL_NO_ANSWERED:
handleVTOCall();
break;
case IGNORE_INVITE:
handleVTHAnswer();
break;
case VIDEO_MOTION:
handleMotionEvent();
break;
case RTSP_SESSION_DISCONNECT:
handleRTSPDisconnect(eventList, eventPayload);
break;
case BACK_KEY_LIGHT:
handleBackKeyLight(eventPayload);
break;
case TIME_CHANGE:
handleTimeChange(eventPayload);
break;
case NTP_ADJUST_TIME:
handleNTPAdjust(eventPayload);
break;
case KEEP_LIGHT_ON:
handleKeepLightOn(eventPayload);
break;
case VIDEO_BLIND:
handleVideoBlind(eventList);
break;
case FINGER_PRINT_CHECK:
handleFingerPrintCheck(eventPayload);
break;
case DOOR_CARD:
handleDoorCard(eventList, eventPayload);
break;
case SIP_REGISTER_RESULT:
handleSIPRegisterResult(eventList, eventPayload);
break;
case ACCESS_CONTROL:
handleAccessControl(eventPayload);
break;
case CALL_SNAP:
handleCallSnap(eventPayload);
break;
case HUNGUP_PHONE:
handleHungupPhone(eventList, eventPayload);
break;
case HANGUP_PHONE:
handleHangupPhone(eventList, eventPayload);
break;
case HANGUP:
handleHangup(eventList, eventPayload);
break;
case INVITE:
handleInvite(eventList, eventPayload);
break;
case ALARM_LOCAL:
handleAlarmLocal(eventList, eventPayload);
break;
case ACCESS_SNAP:
handleAccessSnap(eventPayload);
break;
case REQUEST_CALL_STATE:
handleRequestCallState(eventList, eventPayload);
break;
case PASSIVE_HANGUP:
handlePassiveHangup(eventList, eventPayload);
break;
case PROFILE_ALARM_TRANSMIT:
handleProfileAlarmTransit(eventList, eventPayload);
break;
case NEW_FILE:
handleNewFile(eventList, eventPayload);
break;
case UPDATE_FILE:
handleUpdateFile(eventList, eventPayload);
break;
case REBOOT:
handleReboot(eventList, eventPayload);
break;
case SECURITY_IM_EXPORT:
handleSecurityImport(eventList, eventPayload);
break;
case DGS_ERROR_REPORT:
handleDGSErrorReport(eventList, eventPayload);
break;
case UPGRADE:
handleUpgrade(eventList, eventPayload);
break;
case SEND_CARD:
handleSendCard(eventList, eventPayload);
break;
case ADD_CARD:
handleAddCard(eventList, eventPayload);
break;
case DOOR_STATUS:
handleDoorStatus(eventList, eventPayload);
break;
case DOOR_CONTROL:
handleDoorControl(eventList, eventPayload);
break;
case DOOR_NOT_CLOSED:
handleDoorNotClosed(eventList, eventPayload);
break;
case NETWORK_CHANGE:
handleNetworkChanged(eventList, eventPayload);
break;
case UNKNOWN:
default:
logger.debug("Unknown event received. JSON{}", gson.toJson(eventData));
}
}
} catch (IllegalStateException e) {
logger.debug("Invalid JSON structure while handling DahuaDoor event. Raw payload: {}", eventData.toString(),
e);
} catch (IndexOutOfBoundsException e) {
logger.debug("Missing expected array elements in DahuaDoor event. Raw payload: {}", eventData.toString(),
e);
}
}
protected void handleNetworkChanged(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: NetworkChange, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleDoorNotClosed(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: DoorNotClosed, Action {}, Name{}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("Name").getAsString(), eventData.get("LocaleTime").getAsString());
}
protected void handleDoorControl(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: DoorControl, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleDoorStatus(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: DoorStatus, Action {}, Status: {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("Status").getAsString(), eventData.get("LocaleTime").getAsString());
}
protected void handleAddCard(JsonObject eventList, JsonObject eventData) {
JsonObject cardData = eventData.getAsJsonArray("Data").get(0).getAsJsonObject();
logger.debug(
"Event: AddCard, Action {}: CardNo {}, UserID {}, UserName {}, CardStatus {}, CardType {}, Doors: Door 0={}, Door1={}",
eventList.get("Action").getAsString(), cardData.get("CardNo").getAsString(),
cardData.get("UserID").getAsString(), cardData.get("UserName").getAsString(),
cardData.get("CardStatus").getAsString(), cardData.get("CardType").getAsString(),
cardData.getAsJsonArray("Doors").get(0).getAsString(),
cardData.getAsJsonArray("Doors").get(1).getAsString());
}
protected void handleSendCard(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: SendCard, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleUpgrade(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: Upgrade, Action {}, with State{}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("State").getAsString(), eventData.get("LocaleTime").getAsString());
}
protected void handleDGSErrorReport(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: DGSErrorReport, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleSecurityImport(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: SecurityImExport, Action {}, LocaleTime {}, Status {}",
eventList.get("Action").getAsString(), eventData.get("LocaleTime").getAsString(),
eventData.get("Status").getAsString());
}
protected void handleReboot(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: Reboot, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleUpdateFile(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: UpdateFile, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleNewFile(JsonObject eventList, JsonObject eventData) {
String action = eventList.has("Action") ? eventList.get("Action").getAsString() : "unknown";
String file = eventData.has("File") ? eventData.get("File").getAsString() : "unknown";
String folder = eventData.has("Filter") ? eventData.get("Filter").getAsString() : "unknown";
// not a typo: Filter works for folder, seems to be a naming error in the Dahua firmware
String localeTime = eventData.has("LocaleTime") ? eventData.get("LocaleTime").getAsString() : "unknown";
String index = eventList.has("Index") ? eventList.get("Index").getAsString() : "unknown";
logger.debug("Event: NewFile, Action {}, File {}, Folder {}, LocaleTime {}, Index {}", action, file, folder,
localeTime, index);
}
protected void handleProfileAlarmTransit(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: ProfileAlarmTransmit, Action {}, AlarmType {}, DevSrcType {}, SenseMethod {}",
eventList.get("Action").getAsString(), eventData.get("AlarmType").getAsString(),
eventData.get("DevSrcType").getAsString(), eventData.get("SenseMethod").getAsString());
}
protected void handlePassiveHangup(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: PassiveHangup, Action {}, LocaleTime {}, Index {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString(), eventData.get("Index").getAsString());
}
protected void handleRequestCallState(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: RequestCallState, Action {}, LocaleTime {}, Index {}",
eventList.get("Action").getAsString(), eventData.get("LocaleTime").getAsString(),
eventData.get("Index").getAsString());
}
protected void handleAccessSnap(JsonObject eventData) {
logger.debug("Event: AccessSnap, FTP upload to {}", eventData.get("FtpUrl").getAsString());
}
protected void handleAlarmLocal(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: AlarmLocal, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleInvite(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: Invite, Action {}, CallID {}, Lock Number {}", eventList.get("Action").getAsString(),
eventData.get("CallID").getAsString(), eventData.get("LockNum").getAsString());
}
protected void handleHangup(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: Hangup, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleHungupPhone(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: HungupPhone, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleHangupPhone(JsonObject eventList, JsonObject eventData) {
logger.debug("Event: HangupPhone, Action {}, LocaleTime {}", eventList.get("Action").getAsString(),
eventData.get("LocaleTime").getAsString());
}
protected void handleCallSnap(JsonObject eventData) {
logger.debug("Event: CallSnap, DeviceType {}, RemoteID {}, RemoteIP {}, CallStatus {}",
eventData.get("DeviceType").getAsString(), eventData.get("RemoteID").getAsString(),
eventData.get("RemoteIP").getAsString(),
eventData.getAsJsonArray("ChannelStates").get(0).getAsString());
}
protected void handleAccessControl(JsonObject eventData) {
logger.debug("Event: AccessControl, Name {}, Method {}, ReaderID {}, UserID {}",
eventData.get("Name").getAsString(), eventData.get("Method").getAsString(),
eventData.get("ReaderID").getAsString(), eventData.get("UserID").getAsString());
}
protected void handleSIPRegisterResult(JsonObject eventList, JsonObject eventData) {
if ("Pulse".equals(eventList.get("Action").getAsString())) {
if (eventData.get("Success").getAsBoolean()) {
logger.debug("Event SIPRegisterResult, Success");
} else {
logger.debug("Event SIPRegisterResult, Failed");
}
}
}
protected void handleDoorCard(JsonObject eventList, JsonObject eventData) {
if ("Pulse".equals(eventList.get("Action").getAsString())) {
logger.debug("DoorCard {} was used at door", eventData.get("Number").getAsString());
}
}
protected void handleFingerPrintCheck(JsonObject eventData) {
if (eventData.get("FingerPrintID").getAsInt() > -1) {
int finger = eventData.get("FingerPrintID").getAsInt();
logger.debug("Event FingerPrintCheck success, Finger number {}, User {}", finger, "User" + finger);
} else {
logger.debug("Event FingerPrintCheck failed, unknown Finger");
}
}
protected void handleVideoBlind(JsonObject eventList) {
if ("Start".equals(eventList.get("Action").getAsString())) {
logger.debug("Event VideoBlind started");
} else if ("Stop".equals(eventList.get("Action").getAsString())) {
logger.debug("Event VideoBlind stopped");
}
}
protected void handleKeepLightOn(JsonObject eventData) {
if ("On".equals(eventData.get("Status").getAsString())) {
logger.debug("Event KeepLightOn");
} else if ("Off".equals(eventData.get("Status").getAsString())) {
logger.debug("Event KeepLightOff");
}
}
protected void handleNTPAdjust(JsonObject eventData) {
if (eventData.get("result").getAsBoolean()) {
logger.debug("Event NTPAdjustTime with {} success", eventData.get("Address").getAsString());
} else {
logger.debug("Event NTPAdjustTime failed");
}
}
protected void handleTimeChange(JsonObject eventData) {
logger.debug("Event TimeChange, BeforeModifyTime: {}, ModifiedTime: {}",
eventData.get("BeforeModifyTime").getAsString(), eventData.get("ModifiedTime").getAsString());
}
protected void handleBackKeyLight(JsonObject eventData) {
logger.debug("Event BackKeyLight with State {} ", eventData.get("State").getAsString());
}
protected void handleRTSPDisconnect(JsonObject eventList, JsonObject eventData) {
if ("Start".equals(eventList.get("Action").getAsString())) {
logger.debug("Event Rtsp-Session from {} disconnected",
eventData.get("Device").getAsString().replace("::ffff:", ""));
} else if ("Stop".equals(eventList.get("Action").getAsString())) {
logger.debug("Event Rtsp-Session from {} connected",
eventData.get("Device").getAsString().replace("::ffff:", ""));
}
}
protected void handleMotionEvent() {
logger.debug("Event VideoMotion");
}
protected void handleVTHAnswer() {
logger.debug("Event VTH answered call from VTO");
}
protected void handleVTOCall() {
logger.debug("Event Call from VTO - subclass should override this method");
}
/**
* Abstract method to handle button press events with lock number identification.
* Subclasses must implement this to handle single or multi-button devices.
*
* @param lockNumber The lock number from the Invite event (1 or 2)
*/
protected abstract void onButtonPressed(int lockNumber);
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link DahuaDoorBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class DahuaDoorBindingConstants {
private static final String BINDING_ID = "dahuadoor";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_VTO2202 = new ThingTypeUID(BINDING_ID, "vto2202");
public static final ThingTypeUID THING_TYPE_VTO3211 = new ThingTypeUID(BINDING_ID, "vto3211");
// List of all Channel ids
public static final String CHANNEL_BELL_BUTTON = "bell-button";
public static final String CHANNEL_BELL_BUTTON_1 = "bell-button-1";
public static final String CHANNEL_BELL_BUTTON_2 = "bell-button-2";
public static final String CHANNEL_DOOR_IMAGE = "door-image";
public static final String CHANNEL_DOOR_IMAGE_1 = "door-image-1";
public static final String CHANNEL_DOOR_IMAGE_2 = "door-image-2";
public static final String CHANNEL_OPEN_DOOR_1 = "open-door-1";
public static final String CHANNEL_OPEN_DOOR_2 = "open-door-2";
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link DahuaDoorConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class DahuaDoorConfiguration {
/**
* Configuration parameters for the Dahua door device, including hostname, authentication
* credentials, and snapshot path.
*/
public String hostname = "";
public String username = "";
public String password = "";
public String snapshotPath = "";
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import static org.openhab.binding.dahuadoor.internal.DahuaDoorBindingConstants.*;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Component;
/**
* The {@link DahuaDoorHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.dahuadoor", service = ThingHandlerFactory.class)
public class DahuaDoorHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_VTO2202, THING_TYPE_VTO3211);
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_VTO2202.equals(thingTypeUID)) {
return new DahuaVto2202Handler(thing);
} else if (THING_TYPE_VTO3211.equals(thingTypeUID)) {
return new DahuaVto3211Handler(thing);
}
return null;
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Enum representing all known Dahua VTO event codes.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public enum DahuaEventCode {
ACCESS_CONTROL("AccessControl"),
ACCESS_SNAP("AccessSnap"),
ADD_CARD("AddCard"),
ALARM_LOCAL("AlarmLocal"),
BACK_KEY_LIGHT("BackKeyLight"),
CALL_NO_ANSWERED("CallNoAnswered"),
CALL_SNAP("CallSnap"),
DGS_ERROR_REPORT("DGSErrorReport"),
DOOR_CARD("DoorCard"),
DOOR_CONTROL("DoorControl"),
DOOR_NOT_CLOSED("DoorNotClosed"),
DOOR_STATUS("DoorStatus"),
FINGER_PRINT_CHECK("FingerPrintCheck"),
HANGUP("Hangup"),
HANGUP_PHONE("HangupPhone"),
HUNGUP_PHONE("HungupPhone"),
IGNORE_INVITE("IgnoreInvite"),
INVITE("Invite"),
KEEP_LIGHT_ON("KeepLightOn"),
NETWORK_CHANGE("NetworkChange"),
NEW_FILE("NewFile"),
NTP_ADJUST_TIME("NTPAdjustTime"),
PASSIVE_HANGUP("PassiveHangup"),
PROFILE_ALARM_TRANSMIT("ProfileAlarmTransmit"),
REBOOT("Reboot"),
REQUEST_CALL_STATE("RequestCallState"),
RTSP_SESSION_DISCONNECT("RtspSessionDisconnect"),
SECURITY_IM_EXPORT("SecurityImExport"),
SEND_CARD("SendCard"),
SIP_REGISTER_RESULT("SIPRegisterResult"),
TIME_CHANGE("TimeChange"),
UPDATE_FILE("UpdateFile"),
UPGRADE("Upgrade"),
VIDEO_BLIND("VideoBlind"),
VIDEO_MOTION("VideoMotion"),
UNKNOWN("");
private final String code;
DahuaEventCode(String code) {
this.code = code;
}
public String getCode() {
return code;
}
/**
* Convert a string to the corresponding enum value.
*
* @param code the event code string
* @return the matching enum value, or UNKNOWN if not found
*/
public static DahuaEventCode fromString(@Nullable String code) {
if (code == null || code.isEmpty()) {
return UNKNOWN;
}
for (DahuaEventCode eventCode : values()) {
if (eventCode.code.equals(code)) {
return eventCode;
}
}
return UNKNOWN;
}
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.dahuadoor.internal.dahuaeventhandler.DahuaEventClient;
import org.openhab.core.library.types.RawType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.Thing;
import com.google.gson.JsonObject;
/**
* The {@link DahuaVto2202Handler} handles VTO2202 devices (single button)
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class DahuaVto2202Handler extends DahuaDoorBaseHandler {
public DahuaVto2202Handler(Thing thing) {
super(thing);
}
@Override
protected void handleInvite(JsonObject eventList, JsonObject eventData) {
logger.debug("Event Invite from VTO2202 (single button)");
// VTO2202 has only one button, always use lockNumber 1
onButtonPressed(1);
}
@Override
protected void handleVTOCall() {
logger.debug("Event Call from VTO2202 (single button)");
// VTO2202 has only one button, always use lockNumber 1
onButtonPressed(1);
}
@Override
protected void onButtonPressed(int lockNumber) {
logger.debug("Button pressed on VTO2202 (lockNumber ignored, single button)");
// Trigger bell button channel synchronously (fast, no blocking)
Channel channel = this.getThing().getChannel(DahuaDoorBindingConstants.CHANNEL_BELL_BUTTON);
if (channel == null) {
logger.warn("Bell button channel not found");
return;
}
triggerChannel(channel.getUID(), "PRESSED");
// Retrieve image asynchronously to avoid blocking the keepAlive event loop
DahuaEventClient localClient = client;
if (localClient == null) {
logger.warn("Client not initialized, cannot retrieve doorbell image");
return;
}
scheduler.submit(() -> {
byte[] buffer = localClient.requestImage();
if (buffer != null && buffer.length > 0) {
// Update image channel
RawType image = new RawType(buffer, "image/jpeg");
updateState(DahuaDoorBindingConstants.CHANNEL_DOOR_IMAGE, image);
// Save snapshot
saveSnapshot(buffer);
} else {
logger.warn("Received empty or null image buffer from VTO2202");
}
});
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.dahuadoor.internal.dahuaeventhandler.DahuaEventClient;
import org.openhab.core.library.types.RawType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.Thing;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* The {@link DahuaVto3211Handler} handles VTO3211 devices (dual button with LockNum detection)
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class DahuaVto3211Handler extends DahuaDoorBaseHandler {
public DahuaVto3211Handler(Thing thing) {
super(thing);
}
@Override
protected void handleInvite(JsonObject eventList, JsonObject eventData) {
logger.debug("Event Invite from VTO3211 (dual button)");
// Extract LockNum from event data to determine which button was pressed
int lockNumber = 1; // default to button 1
JsonElement lockNumElement = eventData.get("LockNum");
if (lockNumElement != null && !lockNumElement.isJsonNull()) {
lockNumber = lockNumElement.getAsInt();
logger.debug("Extracted LockNum: {}", lockNumber);
} else {
logger.warn("LockNum not found in Invite event, defaulting to button 1");
}
onButtonPressed(lockNumber);
}
@Override
protected void handleVTOCall() {
logger.debug("Event Call from VTO3211 (dual button) - no LockNum available, defaulting to button 1");
// VTOCall events don't contain LockNum, default to button 1
onButtonPressed(1);
}
@Override
protected void onButtonPressed(int lockNumber) {
logger.debug("Button {} pressed on VTO3211", lockNumber);
// Determine channel IDs based on lock number
String bellButtonChannelId;
String doorImageChannelId;
if (lockNumber == 2) {
bellButtonChannelId = DahuaDoorBindingConstants.CHANNEL_BELL_BUTTON_2;
doorImageChannelId = DahuaDoorBindingConstants.CHANNEL_DOOR_IMAGE_2;
} else {
// Default to button 1 for lockNumber 1 or any other value
bellButtonChannelId = DahuaDoorBindingConstants.CHANNEL_BELL_BUTTON_1;
doorImageChannelId = DahuaDoorBindingConstants.CHANNEL_DOOR_IMAGE_1;
}
// Trigger bell button channel
Channel bellChannel = this.getThing().getChannel(bellButtonChannelId);
if (bellChannel == null) {
logger.warn("Bell button channel '{}' not found", bellButtonChannelId);
return;
}
triggerChannel(bellChannel.getUID(), "PRESSED");
// Retrieve image asynchronously to avoid blocking the keepAlive event loop
DahuaEventClient localClient = client;
if (localClient == null) {
logger.warn("Client not initialized, cannot retrieve doorbell image");
return;
}
final String doorImageChannelIdFinal = doorImageChannelId;
scheduler.submit(() -> {
byte[] buffer = localClient.requestImage();
if (buffer != null && buffer.length > 0) {
// Update image channel for the specific button
RawType image = new RawType(buffer, "image/jpeg");
updateState(doorImageChannelIdFinal, image);
// Save snapshot image
saveSnapshot(buffer);
} else {
logger.warn("Received empty or null image buffer from VTO3211 button {}", lockNumber);
}
});
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal.dahuaeventhandler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonObject;
/**
* The {@link DHIPEventListener} interface defines the callback for DHIP protocol events.
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public interface DHIPEventListener {
/**
* Handles incoming DHIP events from the Dahua device.
*
* @param eventData JSON object containing the event data from the device
*/
void onEvent(JsonObject eventData);
}
@@ -0,0 +1,835 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.dahuadoor.internal.dahuaeventhandler;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.ThreadPoolManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
/**
* The {@link DahuaEventClient} client polls the Dahua device
*
*
* @author Sven Schad - Initial contribution
*/
@NonNullByDefault
public class DahuaEventClient implements Runnable {
private final Logger logger = LoggerFactory.getLogger(DahuaEventClient.class);
private final String host;
private final String username;
private final String password;
private int id = 0; // Our Request / Response ID that must be in all requests and initiated by us
private int sessionId = 0; // Session ID will be returned after successful login
private String fakeIpAddr = "(null)"; // WebGUI: mask our real IP
private String clientType = ""; // WebGUI: We do not show up in logs or online users
private int keepAliveInterval = 60;
private long lastKeepAlive = 0;
private DHIPEventListener eventListener;
private @Nullable Socket sock;
private final Gson gson = new Gson();
private volatile boolean execThread = true;
private Consumer<String> errorInformer;
private ByteBuffer residualBuffer = ByteBuffer.allocate(0); // Buffer for incomplete frames across reads
private static final int HTTP_TIMEOUT_SECONDS = 10;
private static final String SNAPSHOT_PATH = "/cgi-bin/snapshot.cgi";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
public DahuaEventClient(String host, String username, String password, DHIPEventListener eventListener,
Consumer<String> errorInformer) {
this.host = host;
this.username = username;
this.password = password;
this.eventListener = eventListener;
this.errorInformer = errorInformer;
this.execThread = true;
ThreadPoolManager.getPool("binding.dahuadoor").submit(this);
}
public void dispose() {
this.execThread = false;
// Close socket to unblock any pending read() operations
final Socket localSock = sock;
if (localSock != null) {
try {
localSock.close();
} catch (IOException e) {
logger.trace("Error closing socket during dispose: {}", e.getMessage());
}
}
}
/**
* Requests a JPEG snapshot image from the device via HTTP.
*
* @return JPEG image bytes, or null if the request failed
*/
public byte @Nullable [] requestImage() {
try {
SnapshotHttpResponse response = sendSnapshotRequest(null);
if (response.statusCode == 200) {
return response.body;
}
if (response.statusCode == 401) {
String challenge = response.headers.get("www-authenticate");
if (challenge != null) {
String digestHeader = createDigestAuthorizationHeader(challenge, SNAPSHOT_PATH);
if (digestHeader != null) {
SnapshotHttpResponse authResponse = sendSnapshotRequest(digestHeader);
if (authResponse.statusCode == 200) {
return authResponse.body;
}
logger.debug("Authenticated snapshot request failed with HTTP status {} from {}",
authResponse.statusCode, host);
}
}
}
logger.debug("Snapshot request failed with HTTP status {} from {}", response.statusCode, host);
} catch (Exception e) {
logger.warn("Could not retrieve snapshot from {}", host, e);
}
return null;
}
/**
* Opens the specified door via HTTP.
*
* @param doorNo door number (1 or 2)
*/
public void openDoor(int doorNo) {
try {
String path = "/cgi-bin/accessControl.cgi?action=openDoor&UserID=101&Type=Remote&channel=" + doorNo;
OpenDoorHttpResponse response = sendOpenDoorRequest(path, null);
if (response.statusCode == 200) {
logger.debug("Open Door Success");
} else if (response.statusCode == 401) {
String challenge = response.wwwAuthenticate;
if (challenge != null) {
String digestHeader = createDigestAuthorizationHeader(challenge, path);
if (digestHeader != null) {
OpenDoorHttpResponse authResponse = sendOpenDoorRequest(path, digestHeader);
if (authResponse.statusCode == 200) {
logger.debug("Open Door Success (with authentication)");
} else {
logger.debug("Open door request failed with HTTP status {} for door {} on {}",
authResponse.statusCode, doorNo, host);
}
}
} else {
logger.debug("Open door request failed with HTTP status {} for door {} on {}", response.statusCode,
doorNo, host);
}
} else {
logger.debug("Open door request failed with HTTP status {} for door {} on {}", response.statusCode,
doorNo, host);
}
} catch (Exception e) {
logger.warn("Could not open door {} on {}", doorNo, host, e);
}
}
private SnapshotHttpResponse sendSnapshotRequest(@Nullable String authorizationHeader) throws Exception {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, 80), HTTP_TIMEOUT_SECONDS * 1000);
socket.setSoTimeout(HTTP_TIMEOUT_SECONDS * 1000);
StringBuilder request = new StringBuilder();
request.append("GET ").append(SNAPSHOT_PATH).append(" HTTP/1.1\r\n");
request.append("Host: ").append(host).append("\r\n");
request.append("Connection: close\r\n");
request.append("Accept: */*\r\n");
if (authorizationHeader != null) {
request.append("Authorization: ").append(authorizationHeader).append("\r\n");
}
request.append("\r\n");
OutputStream outputStream = socket.getOutputStream();
outputStream.write(request.toString().getBytes(StandardCharsets.ISO_8859_1));
outputStream.flush();
byte[] rawResponse = readHttpResponse(socket.getInputStream());
return parseSnapshotResponse(rawResponse);
}
}
private OpenDoorHttpResponse sendOpenDoorRequest(String path, @Nullable String authorizationHeader)
throws Exception {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, 80), HTTP_TIMEOUT_SECONDS * 1000);
socket.setSoTimeout(HTTP_TIMEOUT_SECONDS * 1000);
StringBuilder request = new StringBuilder();
request.append("GET ").append(path).append(" HTTP/1.1\r\n");
request.append("Host: ").append(host).append("\r\n");
request.append("Connection: close\r\n");
if (authorizationHeader != null) {
request.append("Authorization: ").append(authorizationHeader).append("\r\n");
}
request.append("\r\n");
OutputStream outputStream = socket.getOutputStream();
outputStream.write(request.toString().getBytes(StandardCharsets.ISO_8859_1));
outputStream.flush();
byte[] rawResponse = readHttpResponse(socket.getInputStream());
return parseOpenDoorResponse(rawResponse);
}
}
private byte[] readHttpResponse(InputStream inputStream) throws Exception {
ByteArrayOutputStream headerStream = new ByteArrayOutputStream();
int state = 0;
while (true) {
int next = inputStream.read();
if (next == -1) {
break;
}
headerStream.write(next);
if (state == 0 && next == '\r') {
state = 1;
} else if (state == 1 && next == '\n') {
state = 2;
} else if (state == 2 && next == '\r') {
state = 3;
} else if (state == 3 && next == '\n') {
break;
} else {
state = 0;
}
}
byte[] headerBytes = headerStream.toByteArray();
int contentLength = extractContentLength(headerBytes);
ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
responseStream.write(headerBytes);
byte[] buffer = new byte[4096];
if (contentLength >= 0) {
int remaining = contentLength;
while (remaining > 0) {
int read = inputStream.read(buffer, 0, Math.min(buffer.length, remaining));
if (read == -1) {
break;
}
responseStream.write(buffer, 0, read);
remaining -= read;
}
} else {
while (true) {
try {
int read = inputStream.read(buffer);
if (read == -1) {
break;
}
responseStream.write(buffer, 0, read);
} catch (SocketTimeoutException e) {
logger.debug("Snapshot response read timed out without Content-Length; using partial body");
break;
}
}
}
return responseStream.toByteArray();
}
private int extractContentLength(byte[] headerBytes) {
String headerText = new String(headerBytes, StandardCharsets.ISO_8859_1);
for (String line : headerText.split("\\r\\n")) {
if (line.regionMatches(true, 0, "Content-Length:", 0, "Content-Length:".length())) {
try {
return Integer.parseInt(line.substring("Content-Length:".length()).trim());
} catch (NumberFormatException e) {
return -1;
}
}
}
return -1;
}
private SnapshotHttpResponse parseSnapshotResponse(byte[] rawResponse) {
int headerEnd = -1;
for (int i = 0; i < rawResponse.length - 3; i++) {
if (rawResponse[i] == '\r' && rawResponse[i + 1] == '\n' && rawResponse[i + 2] == '\r'
&& rawResponse[i + 3] == '\n') {
headerEnd = i + 4;
break;
}
}
if (headerEnd < 0) {
return new SnapshotHttpResponse(0, Map.of(), new byte[0]);
}
String headerText = new String(rawResponse, 0, headerEnd, StandardCharsets.ISO_8859_1);
String[] lines = headerText.split("\\r\\n");
int statusCode = 0;
if (lines.length > 0) {
String[] statusParts = lines[0].split(" ");
if (statusParts.length > 1) {
try {
statusCode = Integer.parseInt(statusParts[1]);
} catch (NumberFormatException e) {
statusCode = 0;
}
}
}
Map<String, String> headers = new HashMap<>();
for (int i = 1; i < lines.length; i++) {
int colon = lines[i].indexOf(':');
if (colon > 0) {
headers.put(lines[i].substring(0, colon).trim().toLowerCase(Locale.ROOT),
lines[i].substring(colon + 1).trim());
}
}
byte[] body = Arrays.copyOfRange(rawResponse, headerEnd, rawResponse.length);
return new SnapshotHttpResponse(statusCode, headers, body);
}
private OpenDoorHttpResponse parseOpenDoorResponse(byte[] rawResponse) {
String headerText = new String(rawResponse, StandardCharsets.ISO_8859_1);
String[] lines = headerText.split("\\r\\n");
int statusCode = 0;
if (lines.length > 0) {
String[] statusParts = lines[0].split(" ");
if (statusParts.length > 1) {
try {
statusCode = Integer.parseInt(statusParts[1]);
} catch (NumberFormatException e) {
statusCode = 0;
}
}
}
String wwwAuthenticate = null;
for (int i = 1; i < lines.length; i++) {
if (lines[i].regionMatches(true, 0, "WWW-Authenticate:", 0, "WWW-Authenticate:".length())) {
wwwAuthenticate = lines[i].substring("WWW-Authenticate:".length()).trim();
break;
}
}
return new OpenDoorHttpResponse(statusCode, wwwAuthenticate);
}
private @Nullable String createDigestAuthorizationHeader(String challenge, String requestPath) throws Exception {
if (!challenge.toLowerCase(Locale.ROOT).startsWith("digest")) {
return null;
}
Map<String, String> digestParams = parseDigestChallenge(challenge.substring(6).trim());
String realm = digestParams.get("realm");
String nonce = digestParams.get("nonce");
if (realm == null || nonce == null) {
return null;
}
String qop = digestParams.get("qop");
if (qop != null && qop.contains(",")) {
qop = qop.split(",")[0].trim();
}
String ha1 = md5Hex(username + ":" + realm + ":" + password);
String ha2 = md5Hex("GET:" + requestPath);
StringBuilder auth = new StringBuilder("Digest ");
auth.append("username=\"").append(escapeDigestValue(username)).append("\"");
auth.append(", realm=\"").append(escapeDigestValue(realm)).append("\"");
auth.append(", nonce=\"").append(escapeDigestValue(nonce)).append("\"");
auth.append(", uri=\"").append(requestPath).append("\"");
String response;
if (qop != null && !qop.isBlank()) {
String nonceCount = "00000001";
String cnonce = randomHex(16);
response = md5Hex(ha1 + ":" + nonce + ":" + nonceCount + ":" + cnonce + ":" + qop + ":" + ha2);
auth.append(", qop=").append(qop);
auth.append(", nc=").append(nonceCount);
auth.append(", cnonce=\"").append(cnonce).append("\"");
} else {
response = md5Hex(ha1 + ":" + nonce + ":" + ha2);
}
String opaque = digestParams.get("opaque");
if (opaque != null) {
auth.append(", opaque=\"").append(escapeDigestValue(opaque)).append("\"");
}
String algorithm = digestParams.get("algorithm");
if (algorithm != null) {
auth.append(", algorithm=").append(algorithm);
}
auth.append(", response=\"").append(response).append("\"");
return auth.toString();
}
private Map<String, String> parseDigestChallenge(String challenge) {
Map<String, String> result = new HashMap<>();
int index = 0;
while (index < challenge.length()) {
while (index < challenge.length()
&& (challenge.charAt(index) == ',' || Character.isWhitespace(challenge.charAt(index)))) {
index++;
}
int equals = challenge.indexOf('=', index);
if (equals < 0) {
break;
}
String key = challenge.substring(index, equals).trim().toLowerCase(Locale.ROOT);
index = equals + 1;
String value;
if (index < challenge.length() && challenge.charAt(index) == '"') {
index++;
int end = challenge.indexOf('"', index);
if (end < 0) {
break;
}
value = challenge.substring(index, end);
index = end + 1;
} else {
int comma = challenge.indexOf(',', index);
if (comma < 0) {
value = challenge.substring(index).trim();
index = challenge.length();
} else {
value = challenge.substring(index, comma).trim();
index = comma + 1;
}
}
result.put(key, value);
}
return result;
}
private String md5Hex(String value) throws Exception {
MessageDigest digest = MessageDigest.getInstance("MD5");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.ISO_8859_1));
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte b : hash) {
builder.append(String.format("%02x", b));
}
return builder.toString();
}
private String randomHex(int length) {
byte[] bytes = new byte[length / 2];
SECURE_RANDOM.nextBytes(bytes);
StringBuilder builder = new StringBuilder(length);
for (byte b : bytes) {
builder.append(String.format("%02x", b));
}
return builder.toString();
}
private String escapeDigestValue(String value) {
return value.replace("\\\\", "\\\\\\\\").replace("\"", "\\\"");
}
private record SnapshotHttpResponse(int statusCode, Map<String, String> headers, byte[] body) {
}
private record OpenDoorHttpResponse(int statusCode, @Nullable String wwwAuthenticate) {
}
public String generateMd5Hash(String dahuaRandom, String dahuaRealm, String username, String password)
throws Exception {
final String passwordDbHash = md5(String.join(":", username, dahuaRealm, password)).toUpperCase(Locale.ROOT);
final String pass = String.join(":", username, dahuaRandom, passwordDbHash);
return md5(pass).toUpperCase(Locale.ROOT);
}
/**
* MD5 hash function for Dahua DHIP protocol authentication.
* Note: MD5 is cryptographically weak, but is required by the Dahua device API
* for digest authentication. This is a protocol limitation, not a design choice.
*/
private String md5(String input) throws Exception {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
byte[] array = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : array) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public void keepAlive(int delay) {
final Socket localSock = sock;
if (localSock == null) {
return;
}
logger.trace("Started keepAlive thread");
while (execThread) {
Map<String, Object> queryArgs = new HashMap<>();
queryArgs.put("method", "global.keepAlive");
queryArgs.put("magic", "0x1234");
queryArgs.put("params", Map.of("timeout", delay, "active", true));
queryArgs.put("id", this.id);
queryArgs.put("session", this.sessionId);
try {
send(new Gson().toJson(queryArgs));
} catch (IOException e) {
logger.trace("Error sending keepAlive", e);
return;
}
lastKeepAlive = System.currentTimeMillis();
boolean keepAliveReceived = false;
while (lastKeepAlive + delay * 1000 > System.currentTimeMillis()) {
ArrayList<String> data;
try {
data = receive();
for (String packet : data) {
JsonObject jsonPacket = gson.fromJson(packet, JsonObject.class);
if (jsonPacket != null) {
if (jsonPacket.has("result")) {
logger.trace("keepAlive back");
keepAliveReceived = true;
} else if ("client.notifyEventStream".equals(jsonPacket.get("method").getAsString())) {
eventListener.onEvent(jsonPacket);
}
}
}
} catch (SocketTimeoutException e) {
// Read timeout: no data yet, keep waiting until the keep-alive deadline.
logger.trace("keepAlive receive timeout, continuing to wait");
} catch (IOException e) {
logger.trace("Error receiving keepAlive response", e);
return;
}
}
if (!keepAliveReceived) {
logger.trace("keepAlive failed");
return;
}
}
}
public void send(String packet) throws IOException {
byte[] payloadBytes = packet.getBytes(StandardCharsets.UTF_8);
ByteBuffer buffer = ByteBuffer.allocate(32 + payloadBytes.length);
buffer.order(ByteOrder.BIG_ENDIAN);
buffer.putInt(0x20000000);
buffer.putInt(0x44484950);
buffer.order(ByteOrder.LITTLE_ENDIAN);
buffer.putInt(sessionId);
buffer.putInt(id);
buffer.putInt(payloadBytes.length);
buffer.putInt(0);
buffer.putInt(payloadBytes.length);
buffer.putInt(0);
if (buffer.position() != 32) {
logger.trace("Binary header != 32");
return;
}
id += 1;
final Socket localSock = sock;
if (localSock == null) {
throw new IOException("Socket is not connected");
}
buffer.put(payloadBytes);
localSock.getOutputStream().write(buffer.array());
}
public ArrayList<String> receive() throws IOException {
ArrayList<String> p2pReturnData = new ArrayList<String>();
byte[] buffer = new byte[8192];
byte[] header = new byte[32];
ByteBuffer bbuffer;
int lenRecved = 1;
int timeout = 5;
final Socket localSock = sock;
if (localSock == null) {
logger.debug("Socket is not connected");
throw new IOException("Socket is not connected");
}
try {
localSock.setSoTimeout(timeout * 1000); // Set timeout in milliseconds
InputStream input = localSock.getInputStream();
int bytesRead = input.read(buffer);
if (bytesRead < 0) {
// End of stream - connection closed
throw new IOException("Connection closed by remote host");
}
// Combine residual buffer with new data
if (residualBuffer.hasRemaining()) {
ByteBuffer combined = ByteBuffer.allocate(residualBuffer.remaining() + bytesRead);
combined.put(residualBuffer);
combined.put(buffer, 0, bytesRead);
combined.flip();
bbuffer = combined;
residualBuffer = ByteBuffer.allocate(0); // Clear residual
} else {
bbuffer = ByteBuffer.wrap(buffer, 0, bytesRead);
}
} catch (IOException e) {
logger.trace("IOException in receive(): {}", e.getMessage());
throw e;
}
while (bbuffer.hasRemaining()) {
// Ensure we have enough bytes for at least the magic value
if (bbuffer.remaining() < Long.BYTES) {
// Save remaining bytes for next read
residualBuffer = ByteBuffer.allocate(bbuffer.remaining());
residualBuffer.put(bbuffer);
residualBuffer.flip();
break;
}
bbuffer.order(ByteOrder.BIG_ENDIAN);
if (bbuffer.getLong(bbuffer.position()) == 0x2000000044484950L) {
// Ensure we have a full header before reading it
if (bbuffer.remaining() < 32) {
// Save remaining bytes for next read
residualBuffer = ByteBuffer.allocate(bbuffer.remaining());
residualBuffer.put(bbuffer);
residualBuffer.flip();
break;
}
bbuffer.order(ByteOrder.LITTLE_ENDIAN);
// DHIP Protocol Header Structure (32 bytes):
// Offset 0-7: Magic value (0x2000000044484950)
// Offset 8-11: Session ID
// Offset 12-15: Sequence number
// Offset 16-19: Length of received data (lenRecved)
// Offset 20-23: Reserved
// Offset 24-27: Expected length for multi-part messages
// Offset 28-31: Reserved
int frameStart = bbuffer.position();
lenRecved = bbuffer.getInt(frameStart + 16);
bbuffer.get(header, 0, 32); // advances position by 32 to payload start
} else {
if (lenRecved > 0) {
// Ensure we have the full payload before reading it
if (bbuffer.remaining() < lenRecved) {
// Save from header start to preserve full frame for the next read.
ByteBuffer residualSlice = bbuffer.duplicate();
int headerStartPosition = Math.max(0, bbuffer.position() - 32);
residualSlice.position(headerStartPosition);
residualBuffer = ByteBuffer.allocate(residualSlice.remaining());
residualBuffer.put(residualSlice);
residualBuffer.flip();
break;
}
String p2pData = new String(bbuffer.array(), bbuffer.arrayOffset() + bbuffer.position(), lenRecved,
StandardCharsets.UTF_8);
bbuffer.position(bbuffer.position() + lenRecved);
p2pReturnData.add(p2pData);
lenRecved = 0;
} else {
break;
}
}
}
return p2pReturnData;
}
@SuppressWarnings("unchecked")
public boolean login() {
logger.trace("Start login");
Map<String, Object> queryArgs = new HashMap<>();
queryArgs.put("id", this.id);
queryArgs.put("magic", "0x1234");
queryArgs.put("method", "global.login");
queryArgs.put("params", Map.of("clientType", this.clientType, "ipAddr", this.fakeIpAddr, "loginType", "Direct",
"password", "", "userName", this.username));
queryArgs.put("session", 0);
try {
send(new Gson().toJson(queryArgs));
ArrayList<String> data = receive();
if (data.isEmpty()) {
logger.trace("global.login [random]");
return false;
}
Map<String, Object> jsonData = new Gson().fromJson(data.get(0), Map.class);
if (jsonData == null || !jsonData.containsKey("session") || !jsonData.containsKey("params")) {
logger.trace("Invalid JSON response from login");
return false;
}
Object sessionObj = jsonData.get("session");
if (sessionObj instanceof Number) {
this.sessionId = ((Number) sessionObj).intValue();
} else {
logger.trace("Invalid session type in response");
return false;
}
Map<String, Object> params = (Map<String, Object>) jsonData.get("params");
if (params == null) {
logger.trace("Missing params in response");
return false;
}
String random = (String) params.get("random");
String realm = (String) params.get("realm");
if (random == null || realm == null) {
logger.trace("Login failed: missing random or realm");
return false;
}
String hashedCredentials = generateMd5Hash(random, realm, this.username, this.password);
queryArgs = new HashMap<>();
queryArgs.put("id", this.id);
queryArgs.put("magic", "0x1234");
queryArgs.put("method", "global.login");
queryArgs.put("session", this.sessionId);
queryArgs.put("params", Map.of("userName", this.username, "password", hashedCredentials, "clientType",
this.clientType, "ipAddr", this.fakeIpAddr, "loginType", "Direct", "authorityType", "Default"));
send(new Gson().toJson(queryArgs));
data = receive();
if (data.isEmpty()) {
return false;
}
jsonData = new Gson().fromJson(data.get(0), Map.class);
if (jsonData != null) {
if (Boolean.TRUE.equals(jsonData.get("result"))) {
logger.trace("Login success");
Object paramsObj = jsonData.get("params");
if (paramsObj instanceof Map) {
Map<String, Object> paramsMap = (Map<String, Object>) paramsObj;
Object intervalObj = paramsMap.get("keepAliveInterval");
if (intervalObj instanceof Number) {
this.keepAliveInterval = ((Number) intervalObj).intValue();
}
}
return true;
}
Object errorObj = jsonData.get("error");
if (errorObj instanceof Map) {
Map<?, ?> errorMap = (Map<?, ?>) errorObj;
Object code = errorMap.get("code");
Object message = errorMap.get("message");
logger.trace("Login failed: {} {}", code, message);
} else {
logger.trace("Login failed: {}", jsonData);
}
} else {
logger.trace("Login failed: empty or invalid JSON response");
}
} catch (Exception e) {
logger.trace("Login error: {}", e.getMessage());
}
return false;
}
@Override
public void run() {
boolean error = false;
int loginTries = 0;
while (execThread) {
if (error) {
try {
for (int i = 0; i < 12 && execThread; i++) {
Thread.sleep(5000);
}
} catch (InterruptedException e) {
logger.debug("Thread interrupted during error wait", e);
Thread.currentThread().interrupt();
break;
}
}
error = true;
try {
Socket localSock = new Socket();
localSock.connect(new InetSocketAddress(host, 5000), 5000); // 5s connect timeout
sock = localSock;
localSock.setSoTimeout(5000); // Set timeout to 5 seconds
error = false;
if (!login()) {
loginTries++;
if (loginTries > 4) {
errorInformer.accept("Login failed. Verify the host configuration and credentials.");
execThread = false;
}
try {
Socket sockToClose = sock;
if (sockToClose != null) {
sockToClose.close();
}
} catch (IOException e) {
logger.trace("Error closing socket after login failure", e);
}
continue;
}
Map<String, Object> queryArgs = new HashMap<>();
queryArgs.put("id", this.id);
queryArgs.put("magic", "0x1234");
queryArgs.put("method", "eventManager.attach");
queryArgs.put("params", Map.of("codes", new String[] { "All" }));
queryArgs.put("session", this.sessionId);
send(new Gson().toJson(queryArgs));
ArrayList<String> data = receive();
JsonObject jsonData = data.isEmpty() ? null : gson.fromJson(data.get(0), JsonObject.class);
if (jsonData == null || !jsonData.has("result")) {
logger.trace("Failure eventManager.attach");
try {
Socket sockToClose = sock;
if (sockToClose != null) {
sockToClose.close();
}
} catch (IOException e) {
logger.trace("Error closing socket after attach failure", e);
}
continue;
} else {
for (String packet : data) {
JsonObject jsonPacket = gson.fromJson(packet, JsonObject.class);
if (jsonPacket != null && jsonPacket.has("method")) {
String method = jsonPacket.get("method").getAsString();
if ("client.notifyEventStream".equals(method)) {
eventListener.onEvent(jsonPacket);
}
}
}
}
keepAlive(this.keepAliveInterval);
logger.trace("Failure no keep alive received");
} catch (Exception e) {
logger.trace("Socket open failed", e);
}
}
try {
Socket sockToClose = sock;
if (sockToClose != null) {
sockToClose.close();
}
} catch (Exception e) {
logger.trace("Error while closing socket", e);
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="dahuadoor" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>DahuaDoor Binding</name>
<description>This is the binding for VTO2202 Dahua door controller.</description>
<connection>local</connection>
</addon:addon>
@@ -0,0 +1,60 @@
# add-on
addon.dahuadoor.name = DahuaDoor Binding
addon.dahuadoor.description = This is the binding for VTO2202 Dahua door controller.
# thing types
thing-type.dahuadoor.vto2202.label = Dahua VTO2202 Door Station
thing-type.dahuadoor.vto2202.description = Dahua VTO2202 outdoor station with single button
thing-type.dahuadoor.vto2202.channel.open-door-1.label = Open Door 1
thing-type.dahuadoor.vto2202.channel.open-door-1.description = Controls door relay 1
thing-type.dahuadoor.vto2202.channel.open-door-2.label = Open Door 2
thing-type.dahuadoor.vto2202.channel.open-door-2.description = Controls door relay 2
thing-type.dahuadoor.vto3211.label = Dahua VTO3211 Door Station
thing-type.dahuadoor.vto3211.description = Dahua VTO3211 outdoor station with dual buttons
thing-type.dahuadoor.vto3211.channel.bell-button-1.label = Door Button 1 Trigger
thing-type.dahuadoor.vto3211.channel.bell-button-1.description = Trigger channel for button 1
thing-type.dahuadoor.vto3211.channel.bell-button-2.label = Door Button 2 Trigger
thing-type.dahuadoor.vto3211.channel.bell-button-2.description = Trigger channel for button 2
thing-type.dahuadoor.vto3211.channel.door-image-1.label = Door Image 1
thing-type.dahuadoor.vto3211.channel.door-image-1.description = Image of door when button 1 is pressed
thing-type.dahuadoor.vto3211.channel.door-image-2.label = Door Image 2
thing-type.dahuadoor.vto3211.channel.door-image-2.description = Image of door when button 2 is pressed
thing-type.dahuadoor.vto3211.channel.open-door-1.label = Open Door 1
thing-type.dahuadoor.vto3211.channel.open-door-1.description = Relay control to open door 1
thing-type.dahuadoor.vto3211.channel.open-door-2.label = Open Door 2
thing-type.dahuadoor.vto3211.channel.open-door-2.description = Relay control to open door 2
# thing types config
thing-type.config.dahuadoor.vto2202.hostname.label = Hostname
thing-type.config.dahuadoor.vto2202.hostname.description = Hostname or IP address of the device
thing-type.config.dahuadoor.vto2202.password.label = Password
thing-type.config.dahuadoor.vto2202.password.description = Password to access the device
thing-type.config.dahuadoor.vto2202.snapshotPath.label = Snapshot Path
thing-type.config.dahuadoor.vto2202.snapshotPath.description = Path where snapshots will be saved (e.g., /var/lib/openhab/door-images)
thing-type.config.dahuadoor.vto2202.username.label = Username
thing-type.config.dahuadoor.vto2202.username.description = Username to access the device
thing-type.config.dahuadoor.vto3211.hostname.label = Hostname
thing-type.config.dahuadoor.vto3211.hostname.description = Hostname or IP address of the device
thing-type.config.dahuadoor.vto3211.password.label = Password
thing-type.config.dahuadoor.vto3211.password.description = Password to access the device
thing-type.config.dahuadoor.vto3211.snapshotPath.label = Snapshot Path
thing-type.config.dahuadoor.vto3211.snapshotPath.description = Path where snapshots will be saved (e.g., /var/lib/openhab/door-images)
thing-type.config.dahuadoor.vto3211.username.label = Username
thing-type.config.dahuadoor.vto3211.username.description = Username to access the device
# channel types
channel-type.dahuadoor.bell-button.label = Doorbell Button
channel-type.dahuadoor.bell-button.description = Triggered when doorbell button is pressed.
channel-type.dahuadoor.door-image.label = Door Image
channel-type.dahuadoor.door-image.description = Snapshot image taken when doorbell is pressed.
channel-type.dahuadoor.open-door.label = Open Door
channel-type.dahuadoor.open-door.description = Controls the door lock relay.
# thing status descriptions
offline.conf-error-missing-credentials = Hostname, username and password must be configured.
offline.conf-error-missing-snapshot-path = Snapshot path must be configured.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="dahuadoor"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-type id="bell-button">
<kind>trigger</kind>
<label>Doorbell Button</label>
<description>Triggered when doorbell button is pressed.</description>
<tags>
<tag>Alarm</tag>
</tags>
<event>
<options>
<option value="PRESSED">Pressed</option>
</options>
</event>
</channel-type>
<channel-type id="door-image">
<item-type>Image</item-type>
<label>Door Image</label>
<description>Snapshot image taken when doorbell is pressed.</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="open-door">
<item-type>Switch</item-type>
<label>Open Door</label>
<description>Controls the door lock relay.</description>
<tags>
<tag>Switch</tag>
</tags>
</channel-type>
</thing:thing-descriptions>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="dahuadoor"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="vto2202">
<label>Dahua VTO2202 Door Station</label>
<description>Dahua VTO2202 outdoor station with single button</description>
<semantic-equipment-tag>Doorbell</semantic-equipment-tag>
<channels>
<channel id="bell-button" typeId="bell-button"/>
<channel id="door-image" typeId="door-image"/>
<channel id="open-door-1" typeId="open-door">
<label>Open Door 1</label>
<description>Controls door relay 1</description>
</channel>
<channel id="open-door-2" typeId="open-door">
<label>Open Door 2</label>
<description>Controls door relay 2</description>
</channel>
</channels>
<config-description>
<parameter name="hostname" type="text" required="true">
<context>network-address</context>
<label>Hostname</label>
<description>Hostname or IP address of the device</description>
</parameter>
<parameter name="username" type="text" required="true">
<context>username</context>
<label>Username</label>
<description>Username to access the device</description>
</parameter>
<parameter name="password" type="text" required="true">
<context>password</context>
<label>Password</label>
<description>Password to access the device</description>
</parameter>
<parameter name="snapshotPath" type="text" required="true">
<label>Snapshot Path</label>
<description>Path where snapshots will be saved (e.g., /var/lib/openhab/door-images)</description>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="dahuadoor"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="vto3211">
<label>Dahua VTO3211 Door Station</label>
<description>Dahua VTO3211 outdoor station with dual buttons</description>
<semantic-equipment-tag>Doorbell</semantic-equipment-tag>
<channels>
<channel id="bell-button-1" typeId="bell-button">
<label>Door Button 1 Trigger</label>
<description>Trigger channel for button 1</description>
</channel>
<channel id="bell-button-2" typeId="bell-button">
<label>Door Button 2 Trigger</label>
<description>Trigger channel for button 2</description>
</channel>
<channel id="door-image-1" typeId="door-image">
<label>Door Image 1</label>
<description>Image of door when button 1 is pressed</description>
</channel>
<channel id="door-image-2" typeId="door-image">
<label>Door Image 2</label>
<description>Image of door when button 2 is pressed</description>
</channel>
<channel id="open-door-1" typeId="open-door">
<label>Open Door 1</label>
<description>Relay control to open door 1</description>
</channel>
<channel id="open-door-2" typeId="open-door">
<label>Open Door 2</label>
<description>Relay control to open door 2</description>
</channel>
</channels>
<config-description>
<parameter name="hostname" type="text" required="true">
<context>network-address</context>
<label>Hostname</label>
<description>Hostname or IP address of the device</description>
</parameter>
<parameter name="username" type="text" required="true">
<context>username</context>
<label>Username</label>
<description>Username to access the device</description>
</parameter>
<parameter name="password" type="text" required="true">
<context>password</context>
<label>Password</label>
<description>Password to access the device</description>
</parameter>
<parameter name="snapshotPath" type="text" required="true">
<label>Snapshot Path</label>
<description>Path where snapshots will be saved (e.g., /var/lib/openhab/door-images)</description>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>
+1
View File
@@ -111,6 +111,7 @@
<module>org.openhab.binding.cm11a</module>
<module>org.openhab.binding.comfoair</module>
<module>org.openhab.binding.coolmasternet</module>
<module>org.openhab.binding.dahuadoor</module>
<module>org.openhab.binding.daikin</module>
<module>org.openhab.binding.dali</module>
<module>org.openhab.binding.danfossairunit</module>