いっぺーちゃんの いろいろやってみよ~

micropython on ESP32 で名前解決

別に子供の名前を決めてくれるわけではない。。。(スベった)

インターネットアクセスでマシン名からIPアドレスを取得するアレです。

公開されているサーバであれば、DNSで解決できるのですが、家の中のようなローカルな環境のマシンも名前でアクセスできたらいいですよね。
家でDNSサーバを動かす!というわけにもいかないし、、と思っていたら、
mDNS(Multicast Domain Name Service)とかLLMNR(Link-Local Multicast Name Resolution)というのがあると知りました。

茶番をスキップ

で、micropython上で使えないかなぁ、と色々調べてみました。
どうやら、mDNSはマルチキャスト通信でデータのやり取りを行うらしい(名前にMulticastって書いてあるやん)。
問い合わせをマルチキャストで投げたら、応答が問い合わせ元にユニキャストで返ってくるのかと思っていたんですが、応答もマルチキャストで返ってくるようです。
つまり、mDNSを使うにはマルチキャストアドレスでの待ち受けができないとダメということです。
micropythonのソースを眺めてみたけど、マルチキャストには対応していないようなので、あきらめました。
LLMNRの方はあんまり調べてないけど、似たようなもんだと思います。
どうしてもESP32でmDNS使いたい人は、ESP32のドライバのサンプル
esp-idf/examples/protocols/mdns/
があるので、そっちを参照してください。
まぁ、別タスクでこれを動かしておいて、そっちに問い合わせるという使い方も考えられますが。

ちゃんちゃん、
と終わっては話にならないので、さらに考えました。

mDNSの勉強のためにWiresharkでパケットキャプチャしていたのですが、mDNSとLLMNRのパケットの近くにいつも出ているNBNSというパケット、
中身を見てみるとどうやらこれも名前解決のためのパケットのようです。
で、ググりまくってみたところ、NetBT(NetBIOS over TCP/IP)のName Service らしい。
これだと、問い合わせはブロードキャストで送信、応答はユニキャストで返ってくるので、なんとかなりそう。

NetBTにはあまり良い印象はないのですが(情報も少ないし)、Windowsマシンでは必ず動いていますし、ubuntuでもsamba(windowsマシンへ共有ディレクトリを見せるためのプログラム)が入っていれば動いているので家庭内での環境としては悪くないと思います。
(nmbd というデーモンがこの機能を実現しています)

確かめていませんが、同様にRaspberryPiでもsambaをインストールすれば使えそうな気がします。

さらにググりまくって、仕様をみつけました。

その他、参考になりそうなサイト。

基礎から学ぶWindowsネットワーク:第18回 NetBIOS over TCP/IPプロトコル(その1) (3/3) - @IT

NetBIOSネームサービスでネットワーク内の端末をリアルタイムに列挙する:CodeZine(コードジン)

 

よし、なんとかなりそうだ。

カタカタ・・・カッターン。・・・カタカタ・・・カッターン。←プログラムを入力している音

が、、、、まさかの事実発覚!
ESP32版 micropythonはブロードキャストをサポートしていない!!
ここまで来て。。。。
しかーし、以下の修正でブロードキャスト対応できることを発見。
(正確には設定用のシンボルが定義されていなかっただけ)

diff --git a/esp32/modsocket.c b/esp32/modsocket.c
index fad42e9..6337938 100644
--- a/esp32/modsocket.c
+++ b/esp32/modsocket.c
@@ -559,6 +559,7 @@ STATIC const mp_map_elem_t mp_module_socket_globals_table[] = {
     { MP_OBJ_NEW_QSTR(MP_QSTR_IPPROTO_UDP), MP_OBJ_NEW_SMALL_INT(IPPROTO_UDP) },
     { MP_OBJ_NEW_QSTR(MP_QSTR_SOL_SOCKET), MP_OBJ_NEW_SMALL_INT(SOL_SOCKET) },
     { MP_OBJ_NEW_QSTR(MP_QSTR_SO_REUSEADDR), MP_OBJ_NEW_SMALL_INT(SO_REUSEADDR) },
+    { MP_OBJ_NEW_QSTR(MP_QSTR_SO_BROADCAST), MP_OBJ_NEW_SMALL_INT(SO_BROADCAST) },
 };
 
 STATIC MP_DEFINE_CONST_DICT(mp_module_socket_globals, mp_module_socket_globals_table);

変更しなくても、SO_BROADCASTの代わりに0x0020と数値で指定すればOKな気もする。。。。

で、ソースはこちら。
nbns.pyという名前で保存し、いつものようにupipmでインストールしてください

try:
    import usocket as socket        # for micropython
except:
    import socket                   # for python

try:
    import utime as time            # for micropython
except:
    import time                     # for python

try:
    import ustruct as struct        # for micropython
except:
    import struct                   # for python

# デバッグ用バイナリデータのダンプ
def dumpData(data) :
    # print(data)
    for i in range(len(data)) :
        print("%02x" % (data[i]), end=' ')
        if (i % 0x10) == 0x0f :
            print("")
    print("")


# NBNS
# http://www.ietf.org/rfc/rfc1002.txt
# http://www.atmarkit.co.jp/ait/articles/0405/20/news085_3.html
# NBNSのコンピュータ名のエンコード
# http://codezine.jp/article/detail/192


# 文字列のニブルエンコードを行う
# name   : string
# return : bytes
def NibbleEncode(name) :
    if len(name) > 15 :                 # 文字列は15文字以下でなければならない
        raise ValueError("name too long")

    # 大文字に変換
    name = name.upper()
    
    # 15文字に足りない分はスペースで埋める
                 # 123456789012345
    name = name + "               "     # 後ろにスペースをくっつけて
    name = name[0:15]                   # 15文字にする
    # print('"%s"'% (name))
    
    name_byte = name.encode('ascii')         # バイト列に変換
    name_byte += b'\0'                       # 後ろにidとして00をつける
    
    name_nibble=""              # 文字列初期化
    for i in range(len(name_byte)) :
        name_nibble += chr((name_byte[i] >> 4  ) + 0x41)    # ord('A') = 0x41 
        name_nibble += chr((name_byte[i] & 0x0f) + 0x41)
    # print('"%s"' % (name_nibble))
    Q_name = b'\x20'                            # size
    Q_name += name_nibble.encode('ascii')       # ニブルエンコードしたものをバイト列に変換
    Q_name += b'\x00'                           # terminate
    return Q_name


# ニブルエンコードされたバイト列のデコードを行う
# name   : bytes
# return : string, int
def NibbleDecode(name) :
    if not len(name) == 34 :                            # サイズは34バイトでなければならない(1+32+1)
        raise ValueError("name length is wrong")
    if not (name[0] == 0x20 and name[-1] == 0x00) :     # 先頭は0x20、末尾は0x00でなければならない
        raise ValueError("data is wrong")

    # 名前本体部分を取り出し
    name_byte = name[1:-1]
    
    # 0~15文字目をデコード
    name_decpded = ""
    for i in range(15) :                        # 15 = int(len(name_byte) / 2) - 1
        data_u = name_byte[i * 2    ] - 0x41    # ord('A') = 0x41 
        data_l = name_byte[i * 2 + 1] - 0x41
        name_decpded += chr((data_u << 4) + data_l)
    
    # 16文字目は識別子
    data_u = name_byte[15 * 2    ] - 0x41
    data_l = name_byte[15 * 2 + 1] - 0x41
    id = (data_u << 4) + data_l
    
    # print('"%s" <%02x>' % (name_decpded, id))
    # デコードした名前の右側の空白を削除して返す
    return name_decpded.rstrip(), id

# IPアドレスを32bit数値化(Cのinet_addrと同等の処理)
def inet_addr(addr) :
    if not addr.count('.') == 3 :
        # '.' が3つでないとIPアドレスではない
        raise ValueError("IP address format error")
    tmp = addr.split('.')       # '.' を区切り文字として分割
    n0 = int(tmp[0])            # それぞれを数値化
    n1 = int(tmp[1])
    n2 = int(tmp[2])
    n3 = int(tmp[3])
    if n0 > 255 or n1 > 255 or n2 > 255 or n3 > 255 :
        # 255より大きい値はIPアドレスでない
        raise ValueError("IP address format error")
    return (n0 << 24) | (n1 << 16) | (n2 << 8) | n3 

# 問い合わせパケットの作成
def makePacket(transaction_id, name) :
    OP_flag = 0x0110      # flag           15: R   14-11: OPCODE   10-4: NMflag 3-0: RCODE
        # 15    : R        0:request
        # 14-11 : OPCODE   0000  query
        # 10-4  : NMflag   0010001 
        #                  ||||  +--- B broadcast
        #                  |||+------ RA
        #                  ||+------- RD
        #                  |+-------- TC
        #                  +--------- AA
        # 3-0   : RCODE    0000
    QD_count = 1        # questions
    AN_count = 0        # answers
    NS_count = 0        # authorities
    AR_count = 0        # additionals
    Q_name = NibbleEncode(name)         # ニブルエンコードしてバイト列に変換
    Q_type = 0x0020     # 0x0020:  NB
    Q_class = 0x0001    # 0x0001:  Internet class

    # 質問エントリ
    q_entry  = Q_name
    q_entry += struct.pack("!HH", Q_type, Q_class)

    pack = struct.pack("!HHHHHH", transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count)
    pack += q_entry
    # dumpData(pack)
    return pack



# 応答パケットの解析
def analysisPacket(pack) :
    # 応答パケットの先頭部分の解析
    (transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count) = struct.unpack_from("!HHHHHH", pack, 0)
    # print("id=%04x  flag=%04x   QD=%04x   AN=%04x   NS=%04x   AR=%04x" % (transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count))
    if not (QD_count == 0 and AN_count == 1 and NS_count == 0 and AR_count == 0) :
        # 応答レコード数1だけ対応
        raise ValueError("response value not expected")
    
    # 本来はIDのチェックを行うべきだが、ローカルポートなので、他のデータは入ってこないハズなので省略
    
    res_record = pack[12:]        # 応答レコード部分を取り出し
    # dumpData(res_record)
    RR_name = res_record[0:34]          # 名前部分を取り出して
    name, id = NibbleDecode(RR_name)    # デコード
    # print('"%s" <%02x>' % (name, id))

    (RR_type, RR_class, RR_TTL, RD_length) = struct.unpack_from("!HHLH", res_record, 34)
    # print("RR_type=%04x  RR_class=%04x  RR_TTL=%08x  RD_length=%04x" % (RR_type, RR_class, RR_TTL, RD_length))
    # RD_lengthにIPアドレス部分の応答データサイズが入っている
    tmp = res_record[34+10:]
    ipaddr_strs = []
    for i in range(RD_length // 6) :    # IPアドレス1個あたりのデータは6Byte(Flag2ByteとIPアドレス4Byte)
        # ipaddr = tmp[i*6+2:i*6+6]
        # ipaddr_strs.append(socket.inet_ntoa(ipaddr))
        ipaddr_str = '%d.%d.%d.%d' % (tmp[i*6+2], tmp[i*6+3], tmp[i*6+4], tmp[i*6+5])
        ipaddr_strs.append(ipaddr_str)
    
    return ipaddr_strs

def get_addr(name, address, mask, timeout=10) :
    # 乱数の代わりに時刻を使う
    id = int(time.time()) & 0xffff
    
    # 問い合わせパケットの作成
    pack = makePacket(id, name)
    try :
        # micropythonはこれでaddrを取得しないとエラーになる
        addr = socket.getaddrinfo(address, 137)[0][-1]
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        s.sendto(pack, addr)
        try :
            s.settimeout(timeout)
        except :
            # linux版 micropythonはsettimeoutできない
            print("can not set socket timeout")
        repl, addr = s.recvfrom(512)
        # print("addr:" + str(addr))
        # dumpData(repl)
        s.close()
    except Exception as e:
        s.close()
        # 失敗したらNoneを返す
        print(e)
        return None
    
    # 受信データの解析
    ipaddrs = analysisPacket(repl)
    # WindowsだとNICが複数あると複数のデータが返ってくる
    # for i in range(len(ipaddrs)) :
    #     print("%d %s" % (i, ipaddrs[i]))
    
    # サブネットマスクをintに変換した値
    mask_addr = inet_addr(mask)
    # サブネットアドレスをintに変換した値
    subnet_addr = inet_addr(address) & mask_addr;
    # print("%08x   %08x" %(subnet_addr, mask_addr))
    
    # サブネットアドレスに一致するアドレスを検索する
    for i in range(len(ipaddrs)) :
        tmp = inet_addr(ipaddrs[i]) & mask_addr
        if tmp == subnet_addr :
            # 見つかった
            return ipaddrs[i]
    
    # 見つからなかったらNoneを返す
    return None

 

で、使い方はこんな感じ。
addressにブロードキャストアドレス、maskにサブネットマスクを指定し
nameにマシン名(コンピュータ名)を指定してnbns.get_addr()を呼び出すと
対象マシンのIPアドレスの「文字列」が返ります。
nameで指定した名前のマシンが見つからない場合は、Noneが返ります。
マシン名は大文字/小文字の区別は行いません。これはNetBTの仕様で、すべて大文字として取り扱われます。
マシン名に全角文字は指定できません。これはNetBTでは全角文字をShiftJISで管理していますがmicropythonではShiftJISを扱えないためです。

import nbns

address = "192.168.78.255"
mask    = "255.255.255.0"
name = "myPC"
print("==== %s ====" % name)
ipaddr = nbns.get_addr(name, address, mask)
print("ipaddr[%s] = %s" % (name, ipaddr))

 

結果はこんな感じで表示されます。

==== myPC ====
ipaddr[myPC] = 192.168.78.66

 

マシンが見つからなかったときなどのタイムアウト時間はパラメータtmoutで指定(単位:秒)できます。
デフォルトは10秒です。
以下の例ではタイムアウトまでの時間を3秒にしています。

ipaddr = nbns.get_addr(name, address, mask, timeout=3)

戻り値は文字列なので、得られたIPアドレスは socket.getaddrinfo()にそのまま渡すことができます。

このプログラムはLinux版micropythonやpython3(3.5.2で確認)でも動作します。
しかし、Linux版micropythonではsocketのタイムアウト時間を指定できません。よって、応答がなければ永遠に待ち続けてしまいますので注意してください。

 

以下解説。

 

ニブルエンコード処理部分です。
文字列を入力すると、ニブル変換したバイト列を返します。

def NibbleEncode(name) :
    ・・・・

ニブルデコード処理部分です。
ニブル変換されたバイト列を入力すると、名前の文字列と識別子を返します。

def NibbleDecode(name) :
    ・・・・

 

ニブル変換については以下のページの真ん中あたりに説明があります。

NetBIOSネームサービスでネットワーク内の端末をリアルタイムに列挙する:CodeZine(コードジン)

 

IPアドレス("xx.xx.xx.xx")の数値部分を接続して32bit整数値を生成します。
C言語のinet_addrと同等の処理です。
サブネットアドレスの比較を行いやすいようにIPアドレスのstring型をint型に変換したいときに使用します。

def inet_addr(addr) :
    ・・・・

 

問い合わせパケットの生成処理です。
名前部分以外固定値なので、b'~~'の固定値で書いても良いのですが、
分かりやすさを重視して各パラメータを設定したあと、struct.pack()でパケットを組み立てています。

def makePacket(transaction_id, name) :
    ・・・・

 

応答パケットの解析処理です。

def analysisPacket(pack) :
    ・・・・

応答パケットの解析処理はちょっと処理が多いので、さらに詳しく。
簡単にするため、応答レコード数=1、その他レコード数=0 のパケットのみ対応してしています。

    (transaction_id, OP_flag, QD_count, AN_count, NS_count, AR_count) = struct.unpack_from("!HHHHHH", pack, 0)
    if not (QD_count == 0 and AN_count == 1 and NS_count == 0 and AR_count == 0) :
        # 応答レコード数1だけ対応
        raise ValueError("response value not expected")

 

名前部分を取り出してデコードしていますが、使ってないのでコメントアウトしてしまっても良いでしょう。

    RR_name = res_record[0:34]          # 名前部分を取り出して
    name, id = NibbleDecode(RR_name)    # デコード

 

名前以降の固定長部分を取り出しています。
以降で参照しているのはRD_lengthだけです。

    (RR_type, RR_class, RR_TTL, RD_length) = struct.unpack_from("!HHLH", res_record, 34)

 

RD_length 以降にフラグ(2Byte)とIPアドレス(4Byte)が入っているのですが、このデータが1つとは限りません。
複数のNICが接続されている場合、NICの数だけデータが入っています(Windowsの場合)。
したがって、(RD_length // 6) でデータ組数を求めて、すべてのIPアドレスをリストに格納しています。
ここではフラグは使用しないので、フラグ部分(tmp[i*6+0]、tmp[i*6+1])は読み込んでいません。
Pythonだと、socket.inet_ntoa()でスマートに変換できるのですが、microputhonにはないので、泥臭い手法で行っています。

    tmp = res_record[34+10:]
    ipaddr_strs = []
    for i in range(RD_length // 6) :    # IPアドレス1個あたりのデータは6Byte(Flag2ByteとIPアドレス4Byte)
        # ipaddr = tmp[i*6+2:i*6+6]
        # ipaddr_strs.append(socket.inet_ntoa(ipaddr))
        ipaddr_str = '%d.%d.%d.%d' % (tmp[i*6+2], tmp[i*6+3], tmp[i*6+4], tmp[i*6+5])
        ipaddr_strs.append(ipaddr_str)

取得したIPアドレスのリストを返します。

    return ipaddr_strs

応答パケットの解析処理はここまで。

 

このモジュールのAPIです。
タイムアウトのデフォルト値はここで10に設定しています。

def get_addr(name, address, mask, timeout=10) :
    ・・・・

APIも中身を詳しく見ていきましょう。
idは問い合わせパケットを識別するためのユニークな値が入っていれば良いので、時刻データで代用しています。
ただ、このモジュールでは複数の問い合わせパケットを同時に送受信することはないので、固定値でも良いかもしれません。

    id = int(time.time()) & 0xffff

 

問い合わせパケットを作成します。

    pack = makePacket(id, name)

 

UDPソケットを生成してブロードキャストに設定し、データを送信しています。

        addr = socket.getaddrinfo(address, 137)[0][-1]
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        s.sendto(pack, addr)

 

応答がなかったときのために、受信タイムアウトを設定しています。
linux版micropythonではsocket.settimeout()は用意されていないので例外が発生します。
例外が発生したときはタイムアウトを設定できない旨表示して処理を続けます。

        try :
            s.settimeout(timeout)
        except :
            print("can not set socket timeout")

 

応答を受信します。
replに受信データが、addrに送信元アドレスが入ります。

        repl, addr = s.recvfrom(512)

実質、ここのaddrが取得したいIPアドレスだという気もしますが。。。

 

送受信処理の例外処理部です。
実質、受信タイムアウト処理です。ソケットをcloseしてNoneを返しています。

    except Exception as e:
        s.close()
        print(e)
        return None

 

受信パケットを解析してIPアドレスのリストを取得します。

    ipaddrs = analysisPacket(repl)

 

サブネットマスクとサブネットアドレスをint型に変換します。
mask:"255.255.255.0" (0xff.0xff.0xff.0x0) → 0xffffff00
addr:"192.168.78.255" (0xc0.0xa8.0x4e.0xff) → 0xc0a84eff
subnet_addr : 0xc0a84eff & 0xffffff00 → 0xc0a84e00

    mask_addr = inet_addr(mask)
    subnet_addr = inet_addr(address) & mask_addr;

 

取得したIPアドレスのリストからサブネットアドレスに一致するIPアドレスを検索します。
処理としては、IPアドレスサブネットマスクでマスクし、サブネットアドレスと一致するものを探します。
見つかったらそのIPアドレスを返します。
もし、複数のサブネットアドレスに一致するIPアドレスがあっても、最初に出てきたものが有効になります。

    for i in range(len(ipaddrs)) :
        tmp = inet_addr(ipaddrs[i]) & mask_addr
        if tmp == subnet_addr :
            return ipaddrs[i]

 

見つからなかったらNoneを返します(ここには来ないハズ)。

    return None