💬

【秋月謎SoC】TypeScriptでスマートメーターとお喋りしてみる

2024/03/26に公開

いきなりだがこちらをご存知だろうか
https://akizukidenshi.com/catalog/g/g117437/

これは通称「秋月謎SoC」と呼ばれる、3G+HEMS+ARM+その他諸々が載った謎の基板
(簡単に言うと逸般人向けRaspberry Piみたいなもの)
今回はこれを使って消費電力を取ってみようという記事

前置き

謎SoC単体でwebhook飛ばしたりする記事は何個も転がっているので、謎SoCをプロキシにして直接UART触ってみようという記事です

謎SoC自体はDebianで動かしました。Debian慣れてるので。
逸般人界隈でよく見かけるこたまご氏がDebian化の記事を書いており、この通りにするとサクサクっと動いた。感謝🙏
https://qiita.com/chibiegg/items/4b1b70a5ba09c4a52a12

そもそもWi-SUNモジュールとは

スマートメーターと通信できるやつ!(920MHzだのECHONET Liteだの話長くなるので割愛)

謎SoCにはこのモジュールが載っていて、UARTで通信可能
つまり謎SoCなんて使わなくてもモジュール引っこ抜けば単体で使えるのである
(ちなみに単体で買うより謎SoC買った方が安い)
https://akizukidenshi.com/catalog/g/g117450/

UARTを謎SoC"経由"で使う

今回は文字通り "経由" して使ってみる
ここでの "経由" とは、謎SoC上でアプリを動かすのではなく、SerialPortに直接リモートアクセスできるようにするという意味である

巷ではPythonでスタンドアローンしてる人が多く見受けられるが、以下の理由によりプロキシ化を選んだ

  • 宗教上の理由によりNode.jsで動かしたかった
  • 謎SoC上で動かしたくなかった(経験上microSDブートはすぐ壊れるので読み書き避けたい)
  • モジュールと直接会話したかった

プロキシ化していく

今回はSerialPort over TCPでやっていくので、適当にsocatでプロキシする
謎SoCに限らず普通のLinuxマシンでも使えるテクなので、名前だけでも覚えておくと便利かも

$ sudo socat -d -d tcp-l:54321,reuseaddr,fork /dev/ttyS1,raw,b115200,nonblock,waitlock=/var/run/ttyS1.lock

こんな表示になったらakiduki.local:54321で開いてる

2024/03/26 00:00:53 socat[5088] W ioctl(5, IOCTL_VM_SOCKETS_GET_LOCAL_CID, ...): Inappropriate ioctl for device
2024/03/26 00:00:53 socat[5088] N listening on AF=2 0.0.0.0:54321

イメージはこんな感じ
TCP client → 謎SoC(socat(TCP server → SerialPort) → Wi-SUNモジュール)

プロキシ化完了!!!

外部から触ってみる

ぶっちゃけ普通にTCP使えばいいです。なんならtelnet akiduki.local 54321でOK

Node.jsから瞬間値取るコード書いたのでご自由にどうぞ
全部MB_RL7023_11_DSSクラスにカプセル化したので結構使いやすいと思います

(力尽きたのでコード見てください... 再接続処理甘いかも)
JavaScriptでASCIIとバイナリ見ながら生きたい人用にパケット表示機能付けてます。debugフラグがそれ。

import * as net from 'node:net'

const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms))

class MB_RL7023_11_DSS {
  private static newLine = '\r\n'

  static connect(
    connectionInfo: {
      address: string,
      port: number
    },
    options?: Partial<{
      debug: boolean
      onDisconnected?: () => void
    }>
  ): Promise<MB_RL7023_11_DSS> {
    return new Promise((res, rej) => {
      if (options?.debug) console.debug('🔗', `Connecting to ${connectionInfo.address}:${connectionInfo.port}`)

      const errHandler = (err: Error) => {
        if (options?.debug) console.debug('🔗', err)
        rej(err)
      }
      const client = net.connect(
        {
          host: connectionInfo.address,
          port: connectionInfo.port,
          timeout: 10 * 1000
        },
        () => {
          client.off('error', errHandler)
          res(new MB_RL7023_11_DSS(client, options))
        }
      )
      client.once('error', errHandler)
    })
  }

  private _tcpSocket: net.Socket
  private _options: {
    debug: boolean
    onDisconnected: () => void
  }

  private _receivedBuffer: Buffer = Buffer.from([])

  constructor(
    socket: net.Socket,
    options?: Partial<{
      debug: boolean
      onDisconnected?: () => void
    }>
  ) {
    this._tcpSocket = socket
    this._options = {
      debug: options?.debug ?? false,
      onDisconnected: options?.onDisconnected ?? (() => {})
    }

    this.setup()
  }

  destroy() {
    this._receivedBuffer = Buffer.from([])

    if (this._tcpSocket.closed === false) {
      this._tcpSocket.destroy()
    }
  }

  private setup() {
    this._tcpSocket.on('data', (data) => {
      if (this._options.debug) console.debug('🔽', data.toString().replaceAll('\r\n', '\\r\\n'))
      this._receivedBuffer = Buffer.concat([this._receivedBuffer, data])
    })

    this._tcpSocket.on('error', (err) => {
      if (this._options.debug) console.debug('🔗', err)
    })

    this._tcpSocket.on('close', () => {
      if (this._options.debug) console.debug('🔗', 'Connection closed')

      this._options.onDisconnected()
    })
  }

  private getRxData(startIdentifier: string | null, endIdentifier: string, endOffset: number) {
    const startPosition = startIdentifier === null ? 0 : this._receivedBuffer.indexOf(startIdentifier)
    if (startPosition === -1) return null

    const beginTruncatedBuffer = this._receivedBuffer.subarray(startPosition)
    const endBeginPosition = beginTruncatedBuffer.indexOf(endIdentifier, startPosition)
    if (endBeginPosition === -1) return null

    const result = beginTruncatedBuffer.subarray(0, endBeginPosition + endIdentifier.length + endOffset)
    const remainingBuffer = beginTruncatedBuffer.subarray(endBeginPosition + endIdentifier.length + endOffset)
    this._receivedBuffer = remainingBuffer

    return result
  }

  private async * waitRxGenerator(startIdentifier: string | null, endIdentifier: string, endOffset = 0, timeout = 5 * 1000): AsyncGenerator<Buffer, Buffer, unknown> {
    let receivedDatetime = new Date()
    while (new Date().valueOf() - receivedDatetime.valueOf() < timeout) {
      const receive = this.getRxData(startIdentifier, endIdentifier, endOffset)
      if (receive !== null) {
        receivedDatetime = new Date()

        yield receive
      }

      await sleep(100)
    }

    if (this._tcpSocket.closed) return

    this._receivedBuffer = Buffer.from([]) // BufferOverflow対策
    throw new Error('Timeout')
  }

  private async waitRx(startIdentifier: string | null, endIdentifier: string, endOffset?: number, timeout?: number) {
    const receive = await this.waitRxGenerator(startIdentifier, endIdentifier, endOffset, timeout).next()
    return receive.value
  }

  private writeTx(data: string | Buffer) {
    let payload = data
    if (typeof payload === 'string') payload += '\r\n'

    if (this._options.debug) console.debug('🔼', payload.toString().replaceAll('\r\n', '\\r\\n'))
    this._tcpSocket.write(payload)
  }

  async setAuthId(id: string): Promise<void> {
    this.writeTx(`SKSETRBID ${id}`)
    await this.waitRx('SKSETRBID', `OK${MB_RL7023_11_DSS.newLine}`)
  }

  async setAuthPassword(password: string): Promise<void> {
    this.writeTx(`SKSETPWD C ${password}`)
    await this.waitRx('SKSETPWD', `OK${MB_RL7023_11_DSS.newLine}`)
  }

  async scanDevice() {
    this.writeTx('SKSCAN 2 FFFFFFFF 6 0') // スキャン開始(非同期)
    const result = await this.waitRx('SKSCAN', 'EVENT 22', 44, 40 * 1000) // スキャン完了イベント(35秒ぐらいで返ってくる)
    const rows = result.toString().split(MB_RL7023_11_DSS.newLine)
    if (rows.length !== 13) return null // デバイスが見つからなかったらもっと短い

    // EVENT 20の中身を抽出
    const deviceDesc: Record<string, string> = {}
    const propertyRows = rows.slice(4, 10)
    for (const row of propertyRows) {
      const [propertyName, value] = row.trim().split(':')
      deviceDesc[propertyName.replaceAll(' ', '')] = value
    }

    return deviceDesc
  }

  async setChannel(channel: string): Promise<void> {
    this.writeTx(`SKSREG S2 ${channel}`)
    await this.waitRx('SKSREG S2', `OK${MB_RL7023_11_DSS.newLine}`)
  }

  async setPanId(panId: string): Promise<void> {
    this.writeTx(`SKSREG S3 ${panId}`)
    await this.waitRx('SKSREG S3', `OK${MB_RL7023_11_DSS.newLine}`)
  }

  async getLinkLocalAddress(macAddress: string): Promise<string> {
    this.writeTx(`SKLL64 ${macAddress}`)
    const result = await this.waitRx('FE80', MB_RL7023_11_DSS.newLine, -2)

    return result.toString()
  }

  async joinNetwork(linkLocalIp: string): Promise<void> {
    this.writeTx(`SKJOIN ${linkLocalIp}`)
    await this.waitRx('SKJOIN', `OK${MB_RL7023_11_DSS.newLine}`)

    for await (const event of this.waitRxGenerator(null, MB_RL7023_11_DSS.newLine)) {
      // PANA接続完了イベントが来るのを待つ
      // waitRx使うとバッファが溢れる可能性があるので使わない
      if (event.indexOf('EVENT 25', 0, 'utf-8') === 0) {
        if (this._options.debug) console.debug('🔔', 'PANA接続完了')

        break
      }
    }
  }

  async getPowerW(localIp64: string) {
    // 詳しくは「ECHONET Lite 通信ミドルウェア仕様」参照
    const szEchonetCommandHeader = Buffer.from([0x10, 0x81, 0x00, 0x01, 0x05, 0xFF, 0x01, 0x02, 0x88, 0x01, 0x62, 0x01])
    const szEchonetCommand = Buffer.concat([szEchonetCommandHeader, Buffer.from([0xE7]), Buffer.from([0x00])])
    const szCommand = Buffer.concat([Buffer.from(`SKSENDTO 1 ${localIp64} 0E1A 1 0 ${szEchonetCommand.length.toString(16).toUpperCase().padStart(4, '0')} `), szEchonetCommand])

    this.writeTx(szCommand)
    for await (const r of this.waitRxGenerator('ERXUDP', MB_RL7023_11_DSS.newLine, -2)) {
      const szData = r.toString('utf-8').split(' ')
      const szEData = Buffer.from(szData[9], 'hex')
      const ehd1 = szEData.subarray(0, 0 + 1)
      const ehd2 = szEData.subarray(1, 1 + 1)
      const tid = szEData.subarray(2, 2 + 2)
      const seoj = szEData.subarray(4, 4 + 3)
      const deoj = szEData.subarray(7, 7 + 3)
      const esv = szEData.subarray(10, 10 + 1)
      const opc = szEData.subarray(11, 11 + 1)
      const epc = szEData.subarray(12, 12 + 1)
      const pdc = szEData.subarray(13, 13 + 1)

      if (esv[0] === 0x72 && 0 < pdc[0]) {
        const edt = szEData.subarray(14, 14 + pdc[0])
        const powerW = edt.readUIntBE(0, edt.byteLength)

        return powerW
      }
    }
  }
}

async function example() {
  let soc: MB_RL7023_11_DSS
  try {
    soc = await MB_RL7023_11_DSS.connect(
      {
        address: 'akiduki.local',
        port: 54321
      },
      {
        debug: true,
        onDisconnected: example,
      }
    )

    console.log('-----Set Auth ID-----')
    await soc.setAuthId('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX')

    console.log('-----Set Auth Password-----')
    await soc.setAuthPassword('XXXXXXXXXXXX')

    // スマートメーターが見つかったり見つからなかったりするので10回までリトライする
    for (let i = 0; i < 10; i++) {
      console.log('-----Scan Device-----')
      const smartMeter = await soc.scanDevice()
      if (smartMeter === null) continue

      console.log('-----Set Channel-----')
      await soc.setChannel(smartMeter.Channel)

      console.log('-----Set Pan ID-----')
      await soc.setPanId(smartMeter.PanID)

      console.log('-----Get Link Local Address-----')
      const localIp64 = await soc.getLinkLocalAddress(smartMeter.Addr)

      console.log('-----Join Network-----')
      await soc.joinNetwork(localIp64)

      while (true) {
        console.log('-----Request Power Status-----')
        const powerW = await soc.getPowerW(localIp64)
        console.log('🔌', `瞬間電力計測値: ${powerW}W`)

        await sleep(60 * 1000)
      }
    }
  } catch (err) {
    console.error(err)

    await sleep(10 * 1000)

    if (soc) soc.destroy()
    else example()
  }
}
void example()

Output

🔗 Connecting to akiduki.local:54321
-----Set Auth ID-----
-----Set Auth Password-----
-----Scan Device-----
-----Scan Device-----
-----Scan Device-----
-----Set Channel-----
-----Set Pan ID-----
-----Get Link Local Address-----
-----Join Network-----
-----Request Power Status-----
🔌 瞬間電力計測値: 760W

Q and A

デバイス(スマートメーター)が見つからない

IDとパスワードが合っているか確認してください(1敗(1日溶かした))

Discussion