diff --git a/tools/rpi/ahoy.py b/tools/rpi/ahoy.py new file mode 100644 index 00000000..93bd6411 --- /dev/null +++ b/tools/rpi/ahoy.py @@ -0,0 +1,134 @@ +""" +First attempt at providing basic 'master' ('DTU') functionality +for Hoymiles micro inverters. +Based in particular on demostrated first contact by 'of22'. +""" +import sys +import argparse +import time +import struct +import crcmod +from RF24 import RF24, RF24_PA_LOW, RF24_PA_MAX, RF24_250KBPS + +radio = RF24(22, 0, 1000000) + +# Master Address ('DTU') +dtu_ser = 99912345678 + +# inverter serial numbers +inv_ser = 99972220200 + +# all inverters +#... + +f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') +f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) + +def ser_to_hm_addr(s): + """ + Calculate the 4 bytes that the HM devices use in their internal messages to + address each other. + """ + bcd = int(str(s)[-8:], base=16) + return struct.pack('>L', bcd) + +def ser_to_esb_addr(s): + """ + Convert a Hoymiles inverter/DTU serial number into its + corresponding NRF24 'enhanced shockburst' address byte sequence (5 bytes). + * + The inverters use a BCD representation of the last 8 + digits of their serial number, in reverse byte order, + followed by \x01. + """ + return ser_to_hm_addr(s)[::-1] + b'\x01' + +def compose_0x80_msg(dst_ser_no=72220200, src_ser_no=72220200, ts=None): + """ + Create a valid 0x80 request with the given parameters, and containing the current system time. + """ + + if not ts: + ts = 1644758171 + + # "framing" + p = b'' + p = p + b'\x15' + p = p + ser_to_hm_addr(dst_ser_no) + p = p + ser_to_hm_addr(src_ser_no) + p = p + b'\x80' + + # encapsulated payload + pp = b'\x0b\x00' + pp = pp + struct.pack('>L', ts) # big-endian: msb at low address + pp = pp + b'\x00' * 8 # of22 adds a \x05 at position 19 + + # CRC_M + crc_m = f_crc_m(pp) + + p = p + pp + p = p + struct.pack('>H', crc_m) + + crc8 = f_crc8(p) + p = p + struct.pack('B', crc8) + return p + + +def main_loop(): + """ + Keep receiving on channel 3. Every once in a while, transmit a request + to one of our inverters on channel 40. + """ + ctr = 1 + while True: + radio.setChannel(3) + radio.enableDynamicPayloads() + radio.setAutoAck(False) + radio.setPALevel(RF24_PA_MAX) + radio.setDataRate(RF24_250KBPS) + radio.openWritingPipe(ser_to_esb_addr(inv_ser)) + radio.flush_rx() + radio.flush_tx() + radio.openReadingPipe(0,ser_to_esb_addr(dtu_ser)) + radio.openReadingPipe(1,ser_to_esb_addr(inv_ser)) + radio.startListening() + + if ctr==1: + radio.printPrettyDetails() + + t_end = time.monotonic_ns()+1e9 + while time.monotonic_ns() < t_end: + has_payload, pipe_number = radio.available_pipe() + if has_payload: + size = radio.payloadSize + payload = radio.read(size) + print(f"Received {size} bytes on pipe {pipe_number}: {payload}") + + radio.stopListening() # put radio in TX mode + radio.setChannel(41) + radio.openWritingPipe(ser_to_esb_addr(inv_ser)) + payload = compose_0x80_msg(src_ser_no=dtu_ser, dst_ser_no=inv_ser) + print(f"{ctr:5d}: len={len(payload)} | " + " ".join([f"{b:02x}" for b in payload])) + radio.write(payload) # will always yield 'True' b/c auto-ack is disabled + ctr = ctr + 1 + + + + +if __name__ == "__main__": + + if not radio.begin(): + raise RuntimeError("radio hardware is not responding") + + radio.setPALevel(RF24_PA_LOW) # RF24_PA_MAX is default + + # radio.printDetails(); # (smaller) function that prints raw register values + radio.printPrettyDetails(); # (larger) function that prints human readable data + + try: + main_loop() + + except KeyboardInterrupt: + print(" Keyboard Interrupt detected. Exiting...") + radio.powerDown() + sys.exit()