feat: dhcp client implementation
This commit is contained in:
commit
1ea86e8c2d
5 changed files with 554 additions and 0 deletions
355
SoftDhcp.cpp
Normal file
355
SoftDhcp.cpp
Normal file
|
@ -0,0 +1,355 @@
|
|||
|
||||
#include "SoftDhcp.h"
|
||||
|
||||
#define PORT_BOOTPS 67
|
||||
#define PORT_BOOTPC 68
|
||||
|
||||
const uint32_t DHCP_MAGIC = htonl(0x63825363);
|
||||
const IPAddress BROADCAST(0xffffffffUL);
|
||||
|
||||
SoftDhcp::SoftDhcp() {
|
||||
this->leaseHandler = nullptr;
|
||||
this->offerHandler = nullptr;
|
||||
this->errorHandler = nullptr;
|
||||
}
|
||||
|
||||
void SoftDhcp::initializePacket() {
|
||||
memset((uint8_t *) &this->packet, 0, sizeof(DhcpPacket));
|
||||
WiFi.macAddress((uint8_t *) this->packet.chaddr);
|
||||
this->packet.magic = DHCP_MAGIC;
|
||||
this->clearOptions();
|
||||
}
|
||||
|
||||
void SoftDhcp::clearOptions() {
|
||||
this->packet.options[0] = DhcpOption::END;
|
||||
this->optptr = this->packet.options;
|
||||
}
|
||||
|
||||
bool SoftDhcp::addOption(uint8_t type, uint8_t len, uint8_t *value) {
|
||||
if (this->optptr + len + 3 >= this->packet.options + 308) { // 3 = type+len+end
|
||||
return false;
|
||||
}
|
||||
*(this->optptr++) = type;
|
||||
*(this->optptr++) = len;
|
||||
memcpy(this->optptr, value, len);
|
||||
this->optptr += len;
|
||||
*this->optptr = DhcpOption::END;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SoftDhcp::addOption(uint8_t type, uint8_t value) {
|
||||
return this->addOption(type, 1, &value);
|
||||
}
|
||||
|
||||
uint8_t *SoftDhcp::getOption(uint8_t type) {
|
||||
uint8_t *optptr = this->packet.options;
|
||||
while (optptr < this->packet.options + 305 && *optptr != DhcpOption::END && *optptr != DhcpOption::PAD) {
|
||||
if (*optptr == type) {
|
||||
return optptr;
|
||||
} else {
|
||||
uint8_t optlen = *(optptr+1);
|
||||
optptr += optlen + 2;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
ssize_t SoftDhcp::getOption(uint8_t type, void *buf, size_t len, ssize_t item) {
|
||||
uint8_t *optptr = this->packet.options;
|
||||
while (optptr < this->packet.options + 305 && *optptr != DhcpOption::END && *optptr != DhcpOption::PAD) {
|
||||
uint8_t opt = *(optptr++);
|
||||
uint8_t optlen = *(optptr++);
|
||||
if (opt != type) {
|
||||
optptr += optlen;
|
||||
continue;
|
||||
}
|
||||
if (item < 0) {
|
||||
if (optlen > len) {
|
||||
return -2;
|
||||
} else {
|
||||
memcpy(buf, optptr, optlen);
|
||||
return optlen;
|
||||
}
|
||||
} else {
|
||||
if (optlen < len * (item + 1) || optlen % len != 0) {
|
||||
return -2;
|
||||
} else {
|
||||
memcpy(buf, optptr + len * item, len);
|
||||
return len;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
ssize_t SoftDhcp::getOption(uint8_t type, void *buf, size_t len) {
|
||||
return this->getOption(type, buf, len, -1);
|
||||
}
|
||||
|
||||
|
||||
void SoftDhcp::request() {
|
||||
uint8_t macbuf[7];
|
||||
if (this->last_xid == 0) {
|
||||
// Send DHCP DISCOVER
|
||||
this->initializePacket();
|
||||
this->request_sent = true;
|
||||
while ((this->last_xid = random(0xffffffffUL)) == 0);
|
||||
this->packet.op = 1;
|
||||
this->packet.htype = 1;
|
||||
this->packet.hlen = 6;
|
||||
this->packet.xid = this->last_xid;
|
||||
this->addOption(DhcpOption::MSG_TYPE, DhcpMessageType::DHCPDISCOVER);
|
||||
macbuf[0] = 1; // 1 = ethernet
|
||||
WiFi.macAddress(macbuf+1);
|
||||
this->addOption(DhcpOption::CLIENT_ID, 7, macbuf);
|
||||
this->addOption(DhcpOption::PARAMETER_LIST, this->prl_count, this->prl);
|
||||
this->udp.beginPacket(this->serverIP, PORT_BOOTPS);
|
||||
this->udp.write((uint8_t *) &this->packet, sizeof(DhcpPacket));
|
||||
this->udp.endPacket();
|
||||
} else {
|
||||
this->getOption(DhcpOption::SERVER_ID, &this->sid.addr, 4);
|
||||
if (this->packet.yiaddr.addr) {
|
||||
this->yiaddr.addr = this->packet.yiaddr.addr;
|
||||
}
|
||||
// Send DHCP REQUEST
|
||||
this->initializePacket();
|
||||
this->request_sent = true;
|
||||
this->packet.op = 1;
|
||||
this->packet.htype = 1;
|
||||
this->packet.hlen = 6;
|
||||
this->packet.xid = this->last_xid;
|
||||
this->addOption(DhcpOption::MSG_TYPE, DhcpMessageType::DHCPREQUEST);
|
||||
macbuf[0] = 1; // 1 = ethernet
|
||||
WiFi.macAddress(macbuf+1);
|
||||
this->addOption(DhcpOption::CLIENT_ID, 7, macbuf);
|
||||
this->addOption(DhcpOption::PARAMETER_LIST, this->prl_count, this->prl);
|
||||
this->addOption(DhcpOption::SERVER_ID, 4, this->sid.octets);
|
||||
this->addOption(DhcpOption::ADDRESS_REQUEST, 4, this->yiaddr.octets);
|
||||
this->udp.beginPacket(this->serverIP, PORT_BOOTPS);
|
||||
this->udp.write((uint8_t *) &this->packet, sizeof(DhcpPacket));
|
||||
this->udp.endPacket();
|
||||
}
|
||||
}
|
||||
|
||||
int32_t SoftDhcp::getTimeoutOption(uint8_t option, int32_t def) {
|
||||
uint32_t time;
|
||||
if (this->getOption(option, (uint8_t *) &time, 4) < 0) {
|
||||
time = def;
|
||||
} else {
|
||||
time = ntohl(time);
|
||||
}
|
||||
if (time > INT32_MAX) {
|
||||
time = INT32_MAX;
|
||||
}
|
||||
return (int32_t) time;
|
||||
}
|
||||
|
||||
void SoftDhcp::respond() {
|
||||
if (this->packet.magic != DHCP_MAGIC) {
|
||||
// not a DHCP packet
|
||||
return;
|
||||
}
|
||||
if (this->packet.xid != this->last_xid) {
|
||||
// not my DHCP packet
|
||||
return;
|
||||
}
|
||||
uint8_t type;
|
||||
if (this->getOption(DhcpOption::MSG_TYPE, &type, 1) != 1) {
|
||||
// Malformed DHCP packet
|
||||
return;
|
||||
}
|
||||
switch (type) {
|
||||
case DhcpMessageType::DHCPOFFER:
|
||||
if (this->offerHandler) {
|
||||
this->offerHandler();
|
||||
}
|
||||
this->request();
|
||||
return;
|
||||
case DhcpMessageType::DHCPACK:
|
||||
this->request_sent = false;
|
||||
this->last_update = millis();
|
||||
this->clientIP = this->packet.yiaddr.addr;
|
||||
if (packet.giaddr.addr) {
|
||||
this->serverIP = this->packet.giaddr.addr;
|
||||
} else {
|
||||
this->serverIP = this->packet.siaddr.addr;
|
||||
}
|
||||
uint32_t addr;
|
||||
this->getOption(DhcpOption::SUBNET_MASK, &addr, 4);
|
||||
this->netmask = addr;
|
||||
this->getOption(DhcpOption::ROUTER, &addr, 4, 0);
|
||||
this->routerIP = addr;
|
||||
this->getOption(DhcpOption::DOMAIN_SERVER, &addr, 4, 0);
|
||||
this->dnsIP1 = addr;
|
||||
this->getOption(DhcpOption::DOMAIN_SERVER, &addr, 4, 1);
|
||||
this->dnsIP2 = addr;
|
||||
WiFi.config(this->clientIP, this->routerIP, this->netmask, this->dnsIP1, this->dnsIP2);
|
||||
// Set countdowns
|
||||
this->renew_countdown = this->getTimeoutOption(DhcpOption::RENEWAL_TIME, 1800);
|
||||
this->rebind_countdown = this->getTimeoutOption(DhcpOption::REBINDING_TIME, 3600);
|
||||
this->expire_countdown = this->getTimeoutOption(DhcpOption::ADDRESS_TIME, INT32_MAX);
|
||||
|
||||
if (this->leaseHandler) {
|
||||
this->leaseHandler();
|
||||
}
|
||||
return;
|
||||
default:
|
||||
this->begin();
|
||||
if (this->errorHandler) {
|
||||
this->errorHandler();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void SoftDhcp::requestOption(uint8_t option) {
|
||||
if (!memchr(this->prl, option, this->prl_count)) {
|
||||
this->prl[this->prl_count++] = option;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void SoftDhcp::begin() {
|
||||
this->request_sent = false;
|
||||
this->last_update = millis();
|
||||
this->last_xid = 0;
|
||||
this->prl_count = 0;
|
||||
this->requestOption(DhcpOption::SUBNET_MASK);
|
||||
this->requestOption(DhcpOption::ROUTER);
|
||||
this->requestOption(DhcpOption::DOMAIN_SERVER);
|
||||
this->serverIP = BROADCAST;
|
||||
this->clientIP = BROADCAST;
|
||||
this->udp.begin(PORT_BOOTPC);
|
||||
// Disable builtin dhcpc
|
||||
WiFi.config(BROADCAST, BROADCAST, BROADCAST, 0, 0);
|
||||
}
|
||||
|
||||
void SoftDhcp::update() {
|
||||
if (this->request_sent) {
|
||||
size_t len = this->udp.parsePacket();
|
||||
if (len < 240) {
|
||||
// not a DHCP packet
|
||||
return;
|
||||
}
|
||||
// TODO check length
|
||||
this->udp.read((uint8_t *) &this->packet, sizeof(DhcpPacket));
|
||||
this->respond();
|
||||
} else {
|
||||
// Count down renew countdown if more than 1s has passed
|
||||
uint32_t now = millis();
|
||||
uint32_t td = (now - this->last_update) / 1000UL;
|
||||
if (td) {
|
||||
this->last_update = now;
|
||||
this->renew_countdown -= td;
|
||||
this->rebind_countdown -= td;
|
||||
this->expire_countdown -= td;
|
||||
}
|
||||
if (this->rebind_countdown <= 0) {
|
||||
this->serverIP = BROADCAST;
|
||||
this->rebind_countdown = INT_MAX;
|
||||
}
|
||||
if (this->expire_countdown <= 0) {
|
||||
this->begin();
|
||||
if (this->expireHandler) {
|
||||
this->expireHandler();
|
||||
}
|
||||
}
|
||||
if (this->renew_countdown <= 0) {
|
||||
this->request();
|
||||
this->renew_countdown = 60;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
IPAddress SoftDhcp::localIP() {
|
||||
return this->clientIP;
|
||||
}
|
||||
|
||||
IPAddress SoftDhcp::subnetMask() {
|
||||
return this->netmask;
|
||||
}
|
||||
|
||||
IPAddress SoftDhcp::gatewayIP() {
|
||||
return this->routerIP;
|
||||
}
|
||||
|
||||
IPAddress SoftDhcp::dnsIP(uint8_t dns_no) {
|
||||
if (dns_no == 0) {
|
||||
return this->dnsIP1;
|
||||
} else if (dns_no == 1) {
|
||||
return this->dnsIP2;
|
||||
} else {
|
||||
return IPAddress(0);
|
||||
}
|
||||
}
|
||||
|
||||
void SoftDhcp::onLease(void (*handler)()) {
|
||||
this->leaseHandler = handler;
|
||||
}
|
||||
|
||||
void SoftDhcp::onOffer(void (*handler)()) {
|
||||
this->offerHandler = handler;
|
||||
}
|
||||
|
||||
void SoftDhcp::onError(void (*handler)()) {
|
||||
this->errorHandler = handler;
|
||||
}
|
||||
|
||||
void SoftDhcp::onExpire(void (*handler)()) {
|
||||
this->expireHandler = handler;
|
||||
}
|
||||
|
||||
|
||||
void SoftDhcp::dump() {
|
||||
uint8_t type;
|
||||
this->getOption(DhcpOption::MSG_TYPE, &type, 1);
|
||||
switch (type) {
|
||||
case DhcpMessageType::DHCPDISCOVER:
|
||||
Serial.println("DHCPDISCOVER:");
|
||||
break;
|
||||
case DhcpMessageType::DHCPOFFER:
|
||||
Serial.println("DHCPOFFER:");
|
||||
break;
|
||||
case DhcpMessageType::DHCPREQUEST:
|
||||
Serial.println("DHCPREQUEST:");
|
||||
break;
|
||||
case DhcpMessageType::DHCPACK:
|
||||
Serial.println("DHCPACK:");
|
||||
break;
|
||||
case DhcpMessageType::DHCPNAK:
|
||||
Serial.println("DHCPNAK:");
|
||||
break;
|
||||
default:
|
||||
Serial.println("OTHER:");
|
||||
break;
|
||||
}
|
||||
Serial.printf(" op: %d\r\n", this->packet.op);
|
||||
Serial.printf(" htype: %d\r\n", this->packet.htype);
|
||||
Serial.printf(" hlen: %d\r\n", this->packet.hlen);
|
||||
Serial.printf(" hops: %d\r\n", this->packet.hops);
|
||||
Serial.printf(" xid: %08x\r\n", this->packet.xid);
|
||||
Serial.printf(" secs: %d\r\n", this->packet.secs);
|
||||
Serial.printf(" flags: %d\r\n", this->packet.flags);
|
||||
Serial.printf(" ciaddr: %d.%d.%d.%d\r\n", this->packet.ciaddr.octets[0], this->packet.ciaddr.octets[1], this->packet.ciaddr.octets[2], this->packet.ciaddr.octets[3]);
|
||||
Serial.printf(" yiaddr: %d.%d.%d.%d\r\n", this->packet.yiaddr.octets[0], this->packet.yiaddr.octets[1], this->packet.yiaddr.octets[2], this->packet.yiaddr.octets[3]);
|
||||
Serial.printf(" siaddr: %d.%d.%d.%d\r\n", this->packet.siaddr.octets[0], this->packet.siaddr.octets[1], this->packet.siaddr.octets[2], this->packet.siaddr.octets[3]);
|
||||
Serial.printf(" giaddr: %d.%d.%d.%d\r\n", this->packet.giaddr.octets[0], this->packet.giaddr.octets[1], this->packet.giaddr.octets[2], this->packet.giaddr.octets[3]);
|
||||
Serial.printf(" chaddr: %s\r\n", this->packet.chaddr);
|
||||
Serial.printf(" sname: %s\r\n", this->packet.sname);
|
||||
Serial.printf(" file: %s\r\n", this->packet.file);
|
||||
Serial.printf(" magic: %08x\r\n", this->packet.magic);
|
||||
Serial.println(" options:");
|
||||
uint8_t *optptr = this->packet.options;
|
||||
while (optptr < this->packet.options + 305 && *optptr != DhcpOption::END && *optptr != DhcpOption::PAD) {
|
||||
uint8_t opttype = *(optptr++);
|
||||
uint8_t optlen = *(optptr++);
|
||||
Serial.printf(" option %02x length %02x:\r\n ", opttype, optlen);
|
||||
for (uint8_t i = 0; i < optlen; ++i) {
|
||||
Serial.printf(" %02x", optptr[i]);
|
||||
}
|
||||
Serial.println();
|
||||
optptr += optlen;
|
||||
}
|
||||
}
|
101
SoftDhcp.h
Normal file
101
SoftDhcp.h
Normal file
|
@ -0,0 +1,101 @@
|
|||
#ifndef _SOFT_DHCP_H_
|
||||
#define _SOFT_DHCP_H_
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <WiFiUdp.h>
|
||||
|
||||
#include "SoftDhcpConsts.h"
|
||||
|
||||
|
||||
typedef union {
|
||||
uint8_t octets[4];
|
||||
uint32_t addr;
|
||||
} ipaddr;
|
||||
|
||||
|
||||
typedef struct __attribute__ ((packed)) DhcpPacket {
|
||||
uint8_t op;
|
||||
uint8_t htype;
|
||||
uint8_t hlen;
|
||||
uint8_t hops;
|
||||
uint32_t xid;
|
||||
uint16_t secs;
|
||||
uint16_t flags;
|
||||
ipaddr ciaddr;
|
||||
ipaddr yiaddr;
|
||||
ipaddr siaddr;
|
||||
ipaddr giaddr;
|
||||
uint8_t chaddr[16];
|
||||
uint8_t sname[64];
|
||||
uint8_t file[128];
|
||||
uint32_t magic;
|
||||
uint8_t options[308];
|
||||
} DhcpPacket;
|
||||
|
||||
|
||||
class SoftDhcp {
|
||||
|
||||
private:
|
||||
DhcpPacket packet;
|
||||
uint8_t *optptr;
|
||||
uint8_t prl[64];
|
||||
uint8_t prl_count;
|
||||
IPAddress clientIP;
|
||||
IPAddress serverIP;
|
||||
IPAddress routerIP;
|
||||
IPAddress netmask;
|
||||
IPAddress dnsIP1;
|
||||
IPAddress dnsIP2;
|
||||
|
||||
WiFiUDP udp;
|
||||
bool request_sent;
|
||||
uint32_t last_xid;
|
||||
uint32_t last_update;
|
||||
ipaddr sid;
|
||||
ipaddr yiaddr;
|
||||
int32_t renew_countdown;
|
||||
int32_t rebind_countdown;
|
||||
int32_t expire_countdown;
|
||||
|
||||
void (*leaseHandler)();
|
||||
void (*offerHandler)();
|
||||
void (*errorHandler)();
|
||||
void (*expireHandler)();
|
||||
|
||||
void initializePacket();
|
||||
void request();
|
||||
void respond();
|
||||
void clearOptions();
|
||||
bool addOption(uint8_t type, uint8_t len, uint8_t *value);
|
||||
bool addOption(uint8_t type, uint8_t value);
|
||||
uint8_t *getOption(uint8_t type);
|
||||
int32_t getTimeoutOption(uint8_t option, int32_t def);
|
||||
|
||||
void dump();
|
||||
|
||||
public:
|
||||
|
||||
SoftDhcp();
|
||||
|
||||
void onLease(void (*handler)());
|
||||
void onOffer(void (*handler)());
|
||||
void onError(void (*handler)());
|
||||
void onExpire(void (*handler)());
|
||||
|
||||
void begin();
|
||||
void update();
|
||||
|
||||
IPAddress localIP();
|
||||
IPAddress subnetMask();
|
||||
IPAddress gatewayIP();
|
||||
IPAddress dnsIP(uint8_t dns_no = 0);
|
||||
|
||||
ssize_t getOption(uint8_t type, void *buf, size_t len);
|
||||
ssize_t getOption(uint8_t type, void *buf, size_t len, ssize_t item);
|
||||
void requestOption(uint8_t type);
|
||||
bool addRequestOption(uint8_t type, uint8_t len, uint8_t *value);
|
||||
};
|
||||
|
||||
|
||||
#endif // _SOFT_DHCP_H_
|
31
SoftDhcpConsts.h
Normal file
31
SoftDhcpConsts.h
Normal file
|
@ -0,0 +1,31 @@
|
|||
#ifndef _SOFT_DHCP_CONSTS_H_
|
||||
#define _SOFT_DHCP_CONSTS_H_
|
||||
|
||||
enum DhcpMessageType: uint8_t {
|
||||
DHCPDISCOVER = 1,
|
||||
DHCPOFFER = 2,
|
||||
DHCPREQUEST = 3,
|
||||
DHCPDECLINE = 4,
|
||||
DHCPACK = 5,
|
||||
DHCPNAK = 6,
|
||||
DHCPRELEASE = 7,
|
||||
DHCPINFORM = 8
|
||||
};
|
||||
|
||||
enum DhcpOption: uint8_t {
|
||||
PAD = 0,
|
||||
SUBNET_MASK = 1,
|
||||
ROUTER = 3,
|
||||
DOMAIN_SERVER = 6,
|
||||
ADDRESS_REQUEST = 50,
|
||||
ADDRESS_TIME = 51,
|
||||
MSG_TYPE = 53,
|
||||
SERVER_ID = 54,
|
||||
PARAMETER_LIST = 55,
|
||||
RENEWAL_TIME = 58,
|
||||
REBINDING_TIME = 59,
|
||||
CLIENT_ID = 61,
|
||||
END = 255
|
||||
};
|
||||
|
||||
#endif // _SOFT_DHCP_CONSTS_H_
|
57
examples/BasicUsage/BasicUsage.ino
Normal file
57
examples/BasicUsage/BasicUsage.ino
Normal file
|
@ -0,0 +1,57 @@
|
|||
#include <Arduino.h>
|
||||
#include <SoftDhcp.h>
|
||||
|
||||
#define DHCP_DOMAIN_NAME 15
|
||||
#define DHCP_TIME_SERVERS 4
|
||||
|
||||
SoftDhcp dhcp;
|
||||
|
||||
|
||||
void dhcpLease() {
|
||||
// Requested options can be retrieved like this:
|
||||
char domainName[64];
|
||||
// getOption returns the number of bytes retrieved, or a negative
|
||||
// number if the option is missing or too long for the buffer
|
||||
ssize_t dnlen = dhcp.getOption(DHCP_DOMAIN_NAME, domainName, 63);
|
||||
if (dnlen > 0) {
|
||||
// The domain name in the DNS option is not zero-terminated. You
|
||||
// need to add your own terminator for strings.
|
||||
domainName[dnlen] = 0;
|
||||
Serial.printf("Domain name: %s\r\n", domainName);
|
||||
}
|
||||
uint32_t addr;
|
||||
IPAddress ipa;
|
||||
// Multi-valued options can be retrieved like this
|
||||
for (uint8_t i = 0, ssize_t len; (len = dhcp.getOption(DHCP_TIME_SERVERS, &addr, 4, i)) > 0; ++i) {
|
||||
ipa = addr;
|
||||
Serial.printf("Time server: %s\r\n", ipa.toString());
|
||||
}
|
||||
|
||||
// Start your network services (e.g. HTTP server/client) here!
|
||||
}
|
||||
|
||||
void dhcpExpire() {
|
||||
// This function is called when the DHCP lease has expired and the IP
|
||||
// address has been removed from the interface.
|
||||
|
||||
// Stop your network services here!
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
// SoftDhcp is event driven; you must register your own callback
|
||||
// functions for these events (onOffer, onLease, onError, onExpire)
|
||||
dhcp.onLease(dhcpLease); // Called on DHCPACK
|
||||
dhcp.onExpire(dhcpExpire); // Called when the lease expires
|
||||
// SoftDhcp.begin must be called before WiFi.begin so that the builtin
|
||||
// DHCP client can be disabled.
|
||||
dhcp.begin();
|
||||
// You can request additional DHCP options like this:
|
||||
dhcp.requestOption(DHCP_DOMAIN_NAME);
|
||||
dhcp.requestOption(DHCP_TIME_SERVERS);
|
||||
WiFi.begin("ssid", "psk");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
dhcp.update();
|
||||
}
|
10
library.properties
Normal file
10
library.properties
Normal file
|
@ -0,0 +1,10 @@
|
|||
name=SoftDhcp
|
||||
version=0.0.1
|
||||
author=s3lph
|
||||
maintainer=s3lph <s3lph@kabelsalat.ch>
|
||||
sentence=Software DHCP client implementation with support for additional options.
|
||||
paragraph=Software DHCP client implementation with support for additional options.
|
||||
category=Network
|
||||
url=https://git.kabelsalat.ch/s3lph/Arduino-Library-SoftDhcp
|
||||
architectures=*
|
||||
includes=SoftDhcp.h
|
Loading…
Add table
Reference in a new issue