2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.snmp.internal;
15 import static org.openhab.binding.snmp.internal.SnmpBindingConstants.*;
17 import java.io.IOException;
18 import java.net.InetAddress;
19 import java.net.UnknownHostException;
20 import java.util.Collections;
21 import java.util.Objects;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
27 import java.util.stream.Collectors;
29 import javax.measure.Unit;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.snmp.internal.config.SnmpChannelConfiguration;
34 import org.openhab.binding.snmp.internal.config.SnmpInternalChannelConfiguration;
35 import org.openhab.binding.snmp.internal.config.SnmpTargetConfiguration;
36 import org.openhab.binding.snmp.internal.types.SnmpChannelMode;
37 import org.openhab.binding.snmp.internal.types.SnmpDatatype;
38 import org.openhab.binding.snmp.internal.types.SnmpProtocolVersion;
39 import org.openhab.binding.snmp.internal.types.SnmpSecurityModel;
40 import org.openhab.core.library.types.DecimalType;
41 import org.openhab.core.library.types.OnOffType;
42 import org.openhab.core.library.types.QuantityType;
43 import org.openhab.core.library.types.StringType;
44 import org.openhab.core.thing.Channel;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.thing.util.ThingHandlerHelper;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.State;
54 import org.openhab.core.types.UnDefType;
55 import org.openhab.core.types.util.UnitUtils;
56 import org.openhab.core.util.HexUtils;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59 import org.snmp4j.AbstractTarget;
60 import org.snmp4j.CommandResponder;
61 import org.snmp4j.CommandResponderEvent;
62 import org.snmp4j.CommunityTarget;
63 import org.snmp4j.PDU;
64 import org.snmp4j.PDUv1;
65 import org.snmp4j.ScopedPDU;
66 import org.snmp4j.Snmp;
67 import org.snmp4j.UserTarget;
68 import org.snmp4j.event.ResponseEvent;
69 import org.snmp4j.event.ResponseListener;
70 import org.snmp4j.mp.SnmpConstants;
71 import org.snmp4j.smi.Counter64;
72 import org.snmp4j.smi.Integer32;
73 import org.snmp4j.smi.IpAddress;
74 import org.snmp4j.smi.OID;
75 import org.snmp4j.smi.OctetString;
76 import org.snmp4j.smi.Opaque;
77 import org.snmp4j.smi.UdpAddress;
78 import org.snmp4j.smi.UnsignedInteger32;
79 import org.snmp4j.smi.Variable;
80 import org.snmp4j.smi.VariableBinding;
83 * The {@link SnmpTargetHandler} is responsible for handling commands, which are
84 * sent to one of the channels or update remote channels
86 * @author Jan N. Klug - Initial contribution
89 public class SnmpTargetHandler extends BaseThingHandler implements ResponseListener, CommandResponder {
90 private static final Pattern HEXSTRING_VALIDITY = Pattern.compile("([a-f0-9]{2}[ :-]?)+");
91 private static final Pattern HEXSTRING_EXTRACTOR = Pattern.compile("[^a-f0-9]");
93 private final Logger logger = LoggerFactory.getLogger(SnmpTargetHandler.class);
95 private @NonNullByDefault({}) SnmpTargetConfiguration config;
96 private final SnmpService snmpService;
97 private @Nullable ScheduledFuture<?> refresh;
98 private int timeoutCounter = 0;
100 private @NonNullByDefault({}) AbstractTarget target;
101 private @NonNullByDefault({}) String targetAddressString;
103 private @NonNullByDefault({}) Set<SnmpInternalChannelConfiguration> readChannelSet;
104 private @NonNullByDefault({}) Set<SnmpInternalChannelConfiguration> writeChannelSet;
105 private @NonNullByDefault({}) Set<SnmpInternalChannelConfiguration> trapChannelSet;
107 public SnmpTargetHandler(Thing thing, SnmpService snmpService) {
109 this.snmpService = snmpService;
113 public void handleCommand(ChannelUID channelUID, Command command) {
114 if (target.getAddress() == null && !renewTargetAddress()) {
115 logger.info("failed to renew target address, can't process '{}' to '{}'.", command, channelUID);
120 if (command instanceof RefreshType) {
121 SnmpInternalChannelConfiguration channel = readChannelSet.stream()
122 .filter(c -> channelUID.equals(c.channelUID)).findFirst()
123 .orElseThrow(() -> new IllegalArgumentException("no readable channel found"));
125 pdu.setType(PDU.GET);
126 pdu.add(new VariableBinding(channel.oid));
127 snmpService.send(pdu, target, null, this);
128 } else if (command instanceof DecimalType || command instanceof QuantityType
129 || command instanceof StringType || command instanceof OnOffType) {
130 SnmpInternalChannelConfiguration channel = writeChannelSet.stream()
131 .filter(config -> channelUID.equals(config.channelUID)).findFirst()
132 .orElseThrow(() -> new IllegalArgumentException("no writable channel found"));
134 if (command instanceof OnOffType) {
135 variable = OnOffType.ON.equals(command) ? channel.onValue : channel.offValue;
136 if (variable == null) {
137 logger.debug("skipping {} to {}: no value defined", command, channelUID);
141 Command rawValue = command;
142 if (command instanceof QuantityType quantityCommand) {
143 Unit<?> channelUnit = channel.unit;
144 if (channelUnit == null) {
145 rawValue = new DecimalType(quantityCommand.toBigDecimal());
147 QuantityType<?> convertedValue = quantityCommand.toUnit(channelUnit);
148 if (convertedValue == null) {
149 logger.warn("Cannot convert '{}' to configured unit '{}'", command, channelUnit);
152 rawValue = new DecimalType(convertedValue.toBigDecimal());
155 variable = convertDatatype(rawValue, channel.datatype);
158 pdu.setType(PDU.SET);
159 pdu.add(new VariableBinding(channel.oid, variable));
160 snmpService.send(pdu, target, null, this);
162 } catch (IllegalArgumentException e) {
163 logger.warn("can't process command {} to {}: {}", command, channelUID, e.getMessage());
164 } catch (IOException e) {
165 logger.warn("Could not send PDU while processing command {} to {}", command, channelUID);
170 public void initialize() {
171 config = getConfigAs(SnmpTargetConfiguration.class);
173 generateChannelConfigs();
175 if (thing.getThingTypeUID().equals(THING_TYPE_TARGET3)) {
176 // override default for target3 things
177 config.protocol = SnmpProtocolVersion.v3;
181 if (config.protocol.toInteger() == SnmpConstants.version1
182 || config.protocol.toInteger() == SnmpConstants.version2c) {
183 CommunityTarget target = new CommunityTarget();
184 target.setCommunity(new OctetString(config.community));
185 this.target = target;
186 } else if (config.protocol.toInteger() == SnmpConstants.version3) {
187 String userName = config.user;
188 if (userName == null) {
189 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "user not set");
192 String engineIdHexString = config.engineId;
193 if (engineIdHexString == null) {
194 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "engineId not set");
197 String authPassphrase = config.authPassphrase;
198 if ((config.securityModel == SnmpSecurityModel.AUTH_PRIV
199 || config.securityModel == SnmpSecurityModel.AUTH_NO_PRIV)
200 && (authPassphrase == null || authPassphrase.isEmpty())) {
201 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
202 "Authentication passphrase not configured");
205 String privPassphrase = config.privPassphrase;
206 if (config.securityModel == SnmpSecurityModel.AUTH_PRIV
207 && (privPassphrase == null || privPassphrase.isEmpty())) {
208 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
209 "Privacy passphrase not configured");
212 byte[] engineId = HexUtils.hexToBytes(engineIdHexString);
213 snmpService.addUser(userName, config.authProtocol, authPassphrase, config.privProtocol, privPassphrase,
215 UserTarget target = new UserTarget();
216 target.setAuthoritativeEngineID(engineId);
217 target.setSecurityName(new OctetString(config.user));
218 target.setSecurityLevel(config.securityModel.getSecurityLevel());
219 this.target = target;
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "SNMP version not supported");
225 snmpService.addCommandResponder(this);
227 target.setRetries(config.retries);
228 target.setTimeout(config.timeout);
229 target.setVersion(config.protocol.toInteger());
230 target.setAddress(null);
233 } catch (IllegalArgumentException e) {
234 // some methods of SNMP4J throw an unchecked IllegalArgumentException if they receive invalid values
235 String message = "Exception during initialization: " + e.getMessage();
236 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
240 updateStatus(ThingStatus.UNKNOWN);
241 refresh = scheduler.scheduleWithFixedDelay(this::refresh, 0, config.refresh, TimeUnit.SECONDS);
245 public void dispose() {
246 final ScheduledFuture<?> r = refresh;
247 if (r != null && !r.isCancelled()) {
250 snmpService.removeCommandResponder(this);
254 public void onResponse(@Nullable ResponseEvent event) {
259 if (event.getSource() instanceof Snmp) {
260 // Always cancel async request when response has been received
261 // otherwise a memory leak is created! Not canceling a request
262 // immediately can be useful when sending a request to a broadcast
263 // address (Comment is taken from the snmp4j API doc).
264 ((Snmp) event.getSource()).cancel(event.getRequest(), this);
267 PDU response = event.getResponse();
268 if (response == null) {
269 Exception e = event.getError();
270 if (e == null) { // no response, no error -> request timed out
272 if (timeoutCounter > config.retries) {
273 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "request timed out");
274 target.setAddress(null);
278 logger.warn("{} requested {} and got error: {}", thing.getUID(), event.getRequest(), e.getMessage());
282 if (ThingHandlerHelper.isHandlerInitialized(this)) {
283 updateStatus(ThingStatus.ONLINE);
285 logger.trace("{} received {}", thing.getUID(), response);
287 response.getVariableBindings().forEach(variable -> {
288 if (variable != null) {
289 updateChannels(variable.getOid(), variable.getVariable(), readChannelSet);
295 public void processPdu(@Nullable CommandResponderEvent event) {
299 logger.trace("{} received trap {}", thing.getUID(), event);
301 final PDU pdu = event.getPDU();
302 final String address = ((UdpAddress) event.getPeerAddress()).getInetAddress().getHostAddress();
303 final String community = new String(event.getSecurityName());
305 if ((pdu.getType() == PDU.V1TRAP) && config.community.equals(community) && (pdu instanceof PDUv1 pduv1)) {
306 logger.trace("{} received trap is PDUv1.", thing.getUID());
307 OID oidEnterprise = pduv1.getEnterprise();
308 int trapValue = pduv1.getGenericTrap();
309 if (trapValue == PDUv1.ENTERPRISE_SPECIFIC) {
310 trapValue = pduv1.getSpecificTrap();
312 updateChannels(oidEnterprise, new UnsignedInteger32(trapValue), trapChannelSet);
314 if ((pdu.getType() == PDU.TRAP || pdu.getType() == PDU.V1TRAP) && config.community.equals(community)
315 && targetAddressString.equals(address)) {
316 pdu.getVariableBindings().forEach(variable -> {
317 if (variable != null) {
318 updateChannels(variable.getOid(), variable.getVariable(), trapChannelSet);
324 private @Nullable SnmpInternalChannelConfiguration getChannelConfigFromChannel(Channel channel) {
325 SnmpChannelConfiguration config = channel.getConfiguration().as(SnmpChannelConfiguration.class);
327 String oid = config.oid;
329 logger.warn("oid must not be null");
333 SnmpDatatype datatype = config.datatype; // maybe null, override later
334 Variable onValue = null;
335 Variable offValue = null;
337 State exceptionValue = UnDefType.UNDEF;
339 if (CHANNEL_TYPE_UID_NUMBER.equals(channel.getChannelTypeUID())) {
340 if (datatype == null) {
341 datatype = SnmpDatatype.INT32;
342 } else if (datatype == SnmpDatatype.IPADDRESS || datatype == SnmpDatatype.STRING) {
345 String configExceptionValue = config.exceptionValue;
346 if (configExceptionValue != null) {
347 exceptionValue = DecimalType.valueOf(configExceptionValue);
349 String configUnit = config.unit;
350 if (configUnit != null) {
351 unit = UnitUtils.parseUnit(configUnit);
353 logger.warn("Failed to parse unit from '{}'for channel '{}'", unit, channel.getUID());
356 } else if (CHANNEL_TYPE_UID_STRING.equals(channel.getChannelTypeUID())) {
357 if (datatype == null) {
358 datatype = SnmpDatatype.STRING;
359 } else if (datatype != SnmpDatatype.IPADDRESS && datatype != SnmpDatatype.STRING
360 && datatype != SnmpDatatype.HEXSTRING) {
363 String configExceptionValue = config.exceptionValue;
364 if (configExceptionValue != null) {
365 exceptionValue = StringType.valueOf(configExceptionValue);
367 } else if (CHANNEL_TYPE_UID_SWITCH.equals(channel.getChannelTypeUID())) {
368 if (datatype == null) {
369 datatype = SnmpDatatype.UINT32;
372 final String configOnValue = config.onvalue;
373 if (configOnValue != null) {
374 onValue = convertDatatype(new StringType(configOnValue), datatype);
376 final String configOffValue = config.offvalue;
377 if (configOffValue != null) {
378 offValue = convertDatatype(new StringType(configOffValue), datatype);
380 } catch (IllegalArgumentException e) {
381 logger.warn("illegal value configuration for channel {}", channel.getUID());
384 String configExceptionValue = config.exceptionValue;
385 if (configExceptionValue != null) {
386 exceptionValue = OnOffType.from(configExceptionValue);
389 logger.warn("unknown channel type found for channel {}", channel.getUID());
392 return new SnmpInternalChannelConfiguration(channel.getUID(), new OID(oid), config.mode, datatype, onValue,
393 offValue, exceptionValue, unit, config.doNotLogException);
396 private void generateChannelConfigs() {
397 Set<SnmpInternalChannelConfiguration> channelConfigs = Collections.unmodifiableSet(thing.getChannels().stream()
398 .map(this::getChannelConfigFromChannel).filter(Objects::nonNull).collect(Collectors.toSet()));
399 this.readChannelSet = channelConfigs.stream()
400 .filter(c -> c.mode == SnmpChannelMode.READ || c.mode == SnmpChannelMode.READ_WRITE)
401 .collect(Collectors.toSet());
402 this.writeChannelSet = channelConfigs.stream()
403 .filter(c -> c.mode == SnmpChannelMode.WRITE || c.mode == SnmpChannelMode.READ_WRITE)
404 .collect(Collectors.toSet());
405 this.trapChannelSet = channelConfigs.stream().filter(c -> c.mode == SnmpChannelMode.TRAP)
406 .collect(Collectors.toSet());
409 private void updateChannels(OID oid, Variable value, Set<SnmpInternalChannelConfiguration> channelConfigs) {
410 Set<SnmpInternalChannelConfiguration> updateChannelConfigs = channelConfigs.stream()
411 .filter(c -> c.oid.equals(oid)).collect(Collectors.toSet());
412 if (!updateChannelConfigs.isEmpty()) {
413 updateChannelConfigs.forEach(channelConfig -> {
414 ChannelUID channelUID = channelConfig.channelUID;
415 final Channel channel = thing.getChannel(channelUID);
417 if (channel == null) {
418 logger.warn("channel uid {} in channel config set but channel not found", channelUID);
421 if (value.isException()) {
422 if (!channelConfig.doNotLogException) {
423 logger.info("SNMP Exception: request {} returned '{}'", oid, value);
425 state = channelConfig.exceptionValue;
426 } else if (CHANNEL_TYPE_UID_NUMBER.equals(channel.getChannelTypeUID())) {
428 if (channelConfig.datatype == SnmpDatatype.FLOAT) {
429 if (value instanceof Opaque opaque) {
430 byte[] octets = opaque.toByteArray();
431 if (octets.length < 3) {
432 // two bytes identifier and one byte length should always be present
433 throw new UnsupportedOperationException("Not enough octets");
435 if (octets.length != (3 + octets[2])) {
436 // octet 3 contains the lengths of the value
437 throw new UnsupportedOperationException("Not enough octets");
439 if (octets[0] == (byte) 0x9f && octets[1] == 0x78 && octets[2] == 0x04) {
440 // floating point value
441 Unit<?> channelUnit = channelConfig.unit;
442 float floatValue = Float.intBitsToFloat(
443 octets[3] << 24 | octets[4] << 16 | octets[5] << 8 | octets[6]);
444 state = channelUnit == null ? new DecimalType(floatValue)
445 : new QuantityType<>(floatValue, channelUnit);
447 throw new UnsupportedOperationException("Unknown opaque datatype" + value);
450 Unit<?> channelUnit = channelConfig.unit;
451 state = channelUnit == null ? new DecimalType(value.toString())
452 : new QuantityType<>(value + channelUnit.getSymbol());
455 Unit<?> channelUnit = channelConfig.unit;
456 state = channelUnit == null ? new DecimalType(value.toLong())
457 : new QuantityType<>(value.toLong(), channelUnit);
459 } catch (UnsupportedOperationException e) {
460 logger.warn("could not convert {} to number for channel {}", value, channelUID);
463 } else if (CHANNEL_TYPE_UID_STRING.equals(channel.getChannelTypeUID())) {
464 if (channelConfig.datatype == SnmpDatatype.HEXSTRING) {
465 String rawString = ((OctetString) value).toHexString(' ');
466 state = new StringType(rawString.toLowerCase());
468 state = new StringType(value.toString());
470 } else if (CHANNEL_TYPE_UID_SWITCH.equals(channel.getChannelTypeUID())) {
471 if (value.equals(channelConfig.onValue)) {
472 state = OnOffType.ON;
473 } else if (value.equals(channelConfig.offValue)) {
474 state = OnOffType.OFF;
476 logger.debug("channel {} received unmapped value {} ", channelUID, value);
480 logger.warn("channel {} has unknown ChannelTypeUID", channelUID);
483 updateState(channelUID, state);
486 logger.debug("received value {} for unknown OID {}, skipping", value, oid);
490 private Variable convertDatatype(Command command, SnmpDatatype datatype) {
493 if (command instanceof DecimalType decimalCommand) {
494 return new Integer32(decimalCommand.intValue());
495 } else if (command instanceof StringType stringCommand) {
496 return new Integer32((new DecimalType(stringCommand.toString())).intValue());
500 if (command instanceof DecimalType decimalCommand) {
501 return new UnsignedInteger32(decimalCommand.intValue());
502 } else if (command instanceof StringType stringCommand) {
503 return new UnsignedInteger32((new DecimalType(stringCommand.toString())).intValue());
507 if (command instanceof DecimalType decimalCommand) {
508 return new Counter64(decimalCommand.longValue());
509 } else if (command instanceof StringType stringCommand) {
510 return new Counter64((new DecimalType(stringCommand.toString())).longValue());
513 case FLOAT, STRING -> {
514 if (command instanceof DecimalType decimalCommand) {
515 return new OctetString(decimalCommand.toString());
516 } else if (command instanceof StringType stringCommand) {
517 return new OctetString(stringCommand.toString());
521 if (command instanceof StringType stringCommand) {
522 String commandString = stringCommand.toString().toLowerCase();
523 Matcher commandMatcher = HEXSTRING_VALIDITY.matcher(commandString);
524 if (commandMatcher.matches()) {
525 commandString = HEXSTRING_EXTRACTOR.matcher(commandString).replaceAll("");
526 return OctetString.fromHexStringPairs(commandString);
531 if (command instanceof StringType stringCommand) {
532 return new IpAddress(stringCommand.toString());
538 throw new IllegalArgumentException("illegal conversion of " + command + " to " + datatype);
541 private boolean renewTargetAddress() {
543 target.setAddress(new UdpAddress(InetAddress.getByName(config.hostname), config.port));
544 targetAddressString = ((UdpAddress) target.getAddress()).getInetAddress().getHostAddress();
546 } catch (UnknownHostException e) {
547 target.setAddress(null);
548 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Cannot resolve target host");
553 private void refresh() {
554 if (target.getAddress() == null) {
555 if (!renewTargetAddress()) {
556 logger.info("failed to renew target address, waiting for next refresh cycle");
561 pdu.setType(PDU.GET);
562 readChannelSet.stream().map(c -> new VariableBinding(c.oid)).forEach(pdu::add);
563 if (!pdu.getVariableBindings().isEmpty()) {
565 snmpService.send(pdu, target, null, this);
566 } catch (IOException e) {
567 logger.info("Could not send PDU", e);
572 private PDU getPDU() {
573 if (config.protocol == SnmpProtocolVersion.v3 || config.protocol == SnmpProtocolVersion.V3) {
574 return new ScopedPDU();