【秋月謎SoC】TypeScriptでスマートメーターとお喋りしてみる
いきなりだがこちらをご存知だろうか
これは通称「秋月謎SoC」と呼ばれる、3G+HEMS+ARM+その他諸々が載った謎の基板
(簡単に言うと逸般人向けRaspberry Piみたいなもの)
今回はこれを使って消費電力を取ってみようという記事
前置き
謎SoC単体でwebhook飛ばしたりする記事は何個も転がっているので、謎SoCをプロキシにして直接UART触ってみようという記事です
謎SoC自体はDebianで動かしました。Debian慣れてるので。
逸般人界隈でよく見かけるこたまご氏がDebian化の記事を書いており、この通りにするとサクサクっと動いた。感謝🙏
そもそもWi-SUNモジュールとは
スマートメーターと通信できるやつ!(920MHzだのECHONET Liteだの話長くなるので割愛)
謎SoCにはこのモジュールが載っていて、UARTで通信可能
つまり謎SoCなんて使わなくてもモジュール引っこ抜けば単体で使えるのである
(ちなみに単体で買うより謎SoC買った方が安い)
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