Hi all,
I've spent some time doing network analysis on the FoxESS M1 microinverter trying to achieve local data access without cloud dependency. I'm posting my full findings here since I haven't seen this documented anywhere for the M1 specifically. Hopefully useful for others going down the same path.
---
## Setup
- Inverter: FoxESS M1 microinverter
- Network: M1 on isolated IoT VLAN
- Goal: local real-time data access, no FoxESS cloud
---
## Step 1 — Modbus TCP (Not Working!)
The obvious first attempt. Full TCP port scan of all 65535 ports returns zero open ports. The M1 does not run a Modbus TCP server, an HTTP server, or any other inbound TCP service. This is fundamentally different from the H/KH hybrid inverters.
UDP scan found three ports in open|filtered state: 6364, 6366, 61721.
---
## Step 2 — Traffic capture (tcpdump)
Capturing all traffic from the M1 revealed two types of broadcast packets it sends spontaneously.
**Port 6364 UDP — ESP-MESH join request, broadcast every ~2 seconds:**
```json
{
"monkey": 12345678,
"type": "join_me_req",
"seq": 181092959,
"payload": {
"router": -74,
"vendor_id": 45324,
"mesh_id": 24,
"fix_root": 0,
"node_number": 0,
"max_node_number": 0,
"temp_mesh_id": 185,
"mac": "xx:xx:xx:xx:xx:xx"
}
}
```
**Port 3334 UDP — binary heartbeat, broadcast every ~12 seconds:**
```
7f7f ff69 ce7b 7100 0101 xxxx f7f7
```
The 6th byte increments by 0x0c each packet — an uptime counter. Fixed header `7f7f`, fixed device ID bytes, checksum at end.
---
## Step 3 — What the M1 is doing
The M1 runs on an **ESP32 chip** and implements **Espressif ESP-MESH protocol**. It continuously broadcasts `join_me_req` looking for a mesh root/gateway node. Key identifiers:
- `vendor_id: 45324` — FoxESS-specific identifier
- `mesh_id: 24` — mesh network ID
In a normal M1 installation this mesh root would be the **FoxESS M1 gateway/hub device**. Without it, the M1 loops forever broadcasting join requests and never sends solar data locally.
**There is no local API.** The M1 does not expose any queryable interface over WiFi.
---
## Step 4 — Cloud connection analysis
Despite having no gateway, the M1 does eventually establish an outbound TCP connection to a FoxESS cloud server on a non-standard high port. This connection carries encrypted data — likely TLS. The M1 also repeatedly attempts TCP connections to a secondary IP (192.168.1.1) on port 6666 which never succeeds in my setup.
The M1 also ARPs directly for the cloud server's IP address rather than routing via its default gateway — this appears to be a firmware bug or quirk where it treats the cloud server IP as if it were on the local subnet. This means it can only reach the cloud if it happens to get an ARP reply, which normally comes from the FoxESS hub acting as a mesh root and providing connectivity.
---
## Conclusions
1. **The M1 has no local API** — no Modbus TCP, no HTTP, nothing inbound
2. **ESP-MESH is the local protocol** — the M1 is designed to join a mesh network rooted at a FoxESS hub device
3. **Solar data only flows after mesh join** — until `join_me_req` gets a `join_me_resp`, no data packets are sent
4. **The hub is the key** — whoever has an M1 gateway/hub device and can capture what it sends back as `join_me_resp` holds the answer to local integration
5. **Cloud traffic is encrypted** — intercepting the cloud connection is not a practical path
---
## Questions for the community
1. Does anyone own the **FoxESS M1 hub/gateway device**? Can you capture what it sends back to the M1 on port 6364 as a `join_me_resp`?
2. Has anyone found `vendor_id: 45324` or `mesh_id: 24` in any ESP-MESH open source project?
3. Does the M1 hub expose a local HTTP or Modbus interface once the mesh is established?
If someone can capture the hub's response, it should be possible to build a software mesh root — either a Python script or an ESP32 board — that impersonates the hub and receives the M1's solar data locally without any FoxESS cloud involvement.
Happy to share full tcpdump output if useful.
Thanks
Hi all,
Following up on my earlier post. I've spent a full day doing deep network analysis on two FoxESS M1 microinverters and have now mapped the complete local protocol. Posting the full findings here for the community.
TL;DR: The M1 has no local data API. Solar data goes exclusively to the FoxESS cloud over an encrypted connection. The local hub channel (port 6666) is a control/presence channel only. The missing piece is the FoxESS M1 gateway device (If this would exists. Meshing may be just a relict from the ESP framework).
---
## Setup
- Two FoxESS M1 microinverters on an isolated IoT VLAN
- OPNsense firewall with full packet capture capability
- A Debian Test-Server
- Both inverters actively generating power during testing
---
## Complete Protocol Map
### 1. UDP Port 6364 — ESP-MESH Discovery (broadcast, every ~2 seconds)
The M1 runs on an ESP32 chip and uses Espressif ESP-MESH protocol. It continuously broadcasts a join request looking for a mesh root/gateway:
```json
{
"monkey": 12345678,
"type": "join_me_req",
"seq": 181092959,
"payload": {
"router": -74,
"vendor_id": 45324,
"mesh_id": 24,
"fix_root": 0,
"node_number": 0,
"max_node_number": 0,
"temp_mesh_id": 185,
"mac": "xx:xx:xx:xx:xx:xx"
}
}
```
Key identifiers: `vendor_id: 45324`, `mesh_id: 24`. The `monkey` and `seq` values change each session.
### 2. UDP Port 3334 — Binary Heartbeat (broadcast, every ~12 seconds)
```
7f7f ff69 xxxx [counter] 0101 [checksum] f7f7
```
Fixed header `7f7f`, device ID bytes, uptime counter incrementing by 0x0c each packet, fixed footer `f7f7`. Pure keepalive, no solar data.
### 3. TCP Port 6666 → 192.168.1.1 — Hub Registration Channel
This is the most interesting finding. The M1 tries to connect to `192.168.1.1:6666` — a private IP, not a cloud server. This is where the FoxESS hub/gateway device normally sits. I successfully intercepted this channel by redirecting it to my own server. This might also be a local fixed IP when connecting to the Inverter from a mobile to do the initial setup.
**Packet structure:**
```
7f7f [4-byte device_id] [func] [2-byte length] [payload] [2-byte CRC16-LE] f7f7
```
**Handshake sequence:**
Step 1 — Inverter sends login on channel A (`device_id: ffffffff`):
```
func=0x02, payload contains: serial number (60Mxxxxxxxxx), firmware version (v1.31)
```
Step 2 — Inverter sends login on channel B (`device_id: fffefefe`):
```
func=0x02, shorter payload, same serial
```
Step 3 — Server must respond to both with:
```
wrap_packet(device_id, 0x03, b'\x05\x00\x00')
```
Step 4 — Inverter sends periodic heartbeats (`func=0x03, payload=\x00`) which must be mirrored back exactly.
Once this handshake is complete the connection stays stable indefinitely — I had it running for over an hour with both inverters connected simultaneously. **However, no solar data ever appears on this channel.** It is purely a presence/control channel.
**Working Python server for port 6666:**
```python
import socket, threading, datetime
def calculate_crc16_le(data):
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return crc.to_bytes(2, 'little')
def wrap_packet(device_id, func, payload):
body = bytearray(device_id)
body.append(func)
body.extend(len(payload).to_bytes(2, 'big'))
body.extend(payload)
return bytes(b'\x7f\x7f' + body + calculate_crc16_le(body) + b'\xf7\xf7')
def parse_packets(data):
packets = []
i = 0
while i < len(data) - 1:
if data == 0x7f and data[i+1] == 0x7f:
j = i + 2
while j < len(data) - 1:
if data[j] == 0xf7 and data[j+1] == 0xf7:
packets.append(data[i:j+2])
i = j + 2
break
j += 1
else:
i += 1
else:
i += 1
return packets
def handle(conn, addr):
buf = b''
while True:
chunk = conn.recv(4096)
if not chunk: break
buf += chunk
for raw in parse_packets(buf):
buf = buf[len(raw):]
device_id = raw[2:6]
func = raw[6]
length = int.from_bytes(raw[7:9], 'big')
payload = raw[9:9+length]
if func == 0x02:
conn.sendall(wrap_packet(device_id, 0x03, b'\x05\x00\x00'))
elif func == 0x03:
conn.sendall(raw) # mirror heartbeat
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 6666))
s.listen(10)
while True:
c, a = s.accept()
threading.Thread(target=handle, args=(c, a), daemon=True).start()
```
### 4. TCP Port 14431 → 47.87.128.106 — Encrypted Cloud Data Channel
This is where the actual solar data goes. The M1 maintains a persistent encrypted TCP connection to `47.87.128.106:14431` (a FoxESS cloud server). Packet analysis shows active data exchange. This connection is TLS encrypted and is not a practical target for local interception. I dont want to mess with the connection, my intention was to have local data access and not break the cloud connection. MiM would be thinkable... but not wanted.
---
## What I Tried
- Full port scan: zero open TCP ports on the M1 itself
- Modbus TCP on port 502: closed
- Responding to ESP-MESH join_me_req with join_me_resp: no effect
- Intercepting port 6666 and implementing full handshake: stable connection but no data
- Multiple poll function codes (0x11, 0x22, 0x44, 0x45, 0x08 time sync): inverter ignores or resets
- Intercepting cloud connection at `47.87.128.106`: encrypted, not practical
---
## Conclusions
1. The M1 has **no local data API** of any kind
2. Port 6666 / `192.168.1.1` is a **hub presence channel** — registration and keepalive only
3. All solar data flows over the **encrypted cloud connection** to `47.87.128.106:14431`
4. The M1 is designed to work with a **FoxESS hub/gateway/mobile (App) device** that sits at `192.168.1.1` on the local network
5. The hub likely acts as the ESP-MESH root AND may have its own local API — **the hub is the missing piece**
---
I have contacted FoxESS support asking about local API access and the hub device availability. Will update this thread with their response.
Thanks
Following up on my earlier post. I've spent a full day doing deep network analysis on two FoxESS M1 microinverters and have now mapped the complete local protocol. Posting the full findings here for the community.
TL;DR: The M1 has no local data API. Solar data goes exclusively to the FoxESS cloud over an encrypted connection. The local hub channel (port 6666) is a control/presence channel only. The missing piece is the FoxESS M1 gateway device (If this would exists. Meshing may be just a relict from the ESP framework).
---
## Setup
- Two FoxESS M1 microinverters on an isolated IoT VLAN
- OPNsense firewall with full packet capture capability
- A Debian Test-Server
- Both inverters actively generating power during testing
---
## Complete Protocol Map
### 1. UDP Port 6364 — ESP-MESH Discovery (broadcast, every ~2 seconds)
The M1 runs on an ESP32 chip and uses Espressif ESP-MESH protocol. It continuously broadcasts a join request looking for a mesh root/gateway:
```json
{
"monkey": 12345678,
"type": "join_me_req",
"seq": 181092959,
"payload": {
"router": -74,
"vendor_id": 45324,
"mesh_id": 24,
"fix_root": 0,
"node_number": 0,
"max_node_number": 0,
"temp_mesh_id": 185,
"mac": "xx:xx:xx:xx:xx:xx"
}
}
```
Key identifiers: `vendor_id: 45324`, `mesh_id: 24`. The `monkey` and `seq` values change each session.
### 2. UDP Port 3334 — Binary Heartbeat (broadcast, every ~12 seconds)
```
7f7f ff69 xxxx [counter] 0101 [checksum] f7f7
```
Fixed header `7f7f`, device ID bytes, uptime counter incrementing by 0x0c each packet, fixed footer `f7f7`. Pure keepalive, no solar data.
### 3. TCP Port 6666 → 192.168.1.1 — Hub Registration Channel
This is the most interesting finding. The M1 tries to connect to `192.168.1.1:6666` — a private IP, not a cloud server. This is where the FoxESS hub/gateway device normally sits. I successfully intercepted this channel by redirecting it to my own server. This might also be a local fixed IP when connecting to the Inverter from a mobile to do the initial setup.
**Packet structure:**
```
7f7f [4-byte device_id] [func] [2-byte length] [payload] [2-byte CRC16-LE] f7f7
```
**Handshake sequence:**
Step 1 — Inverter sends login on channel A (`device_id: ffffffff`):
```
func=0x02, payload contains: serial number (60Mxxxxxxxxx), firmware version (v1.31)
```
Step 2 — Inverter sends login on channel B (`device_id: fffefefe`):
```
func=0x02, shorter payload, same serial
```
Step 3 — Server must respond to both with:
```
wrap_packet(device_id, 0x03, b'\x05\x00\x00')
```
Step 4 — Inverter sends periodic heartbeats (`func=0x03, payload=\x00`) which must be mirrored back exactly.
Once this handshake is complete the connection stays stable indefinitely — I had it running for over an hour with both inverters connected simultaneously. **However, no solar data ever appears on this channel.** It is purely a presence/control channel.
**Working Python server for port 6666:**
```python
import socket, threading, datetime
def calculate_crc16_le(data):
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return crc.to_bytes(2, 'little')
def wrap_packet(device_id, func, payload):
body = bytearray(device_id)
body.append(func)
body.extend(len(payload).to_bytes(2, 'big'))
body.extend(payload)
return bytes(b'\x7f\x7f' + body + calculate_crc16_le(body) + b'\xf7\xf7')
def parse_packets(data):
packets = []
i = 0
while i < len(data) - 1:
if data == 0x7f and data[i+1] == 0x7f:
j = i + 2
while j < len(data) - 1:
if data[j] == 0xf7 and data[j+1] == 0xf7:
packets.append(data[i:j+2])
i = j + 2
break
j += 1
else:
i += 1
else:
i += 1
return packets
def handle(conn, addr):
buf = b''
while True:
chunk = conn.recv(4096)
if not chunk: break
buf += chunk
for raw in parse_packets(buf):
buf = buf[len(raw):]
device_id = raw[2:6]
func = raw[6]
length = int.from_bytes(raw[7:9], 'big')
payload = raw[9:9+length]
if func == 0x02:
conn.sendall(wrap_packet(device_id, 0x03, b'\x05\x00\x00'))
elif func == 0x03:
conn.sendall(raw) # mirror heartbeat
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 6666))
s.listen(10)
while True:
c, a = s.accept()
threading.Thread(target=handle, args=(c, a), daemon=True).start()
```
### 4. TCP Port 14431 → 47.87.128.106 — Encrypted Cloud Data Channel
This is where the actual solar data goes. The M1 maintains a persistent encrypted TCP connection to `47.87.128.106:14431` (a FoxESS cloud server). Packet analysis shows active data exchange. This connection is TLS encrypted and is not a practical target for local interception. I dont want to mess with the connection, my intention was to have local data access and not break the cloud connection. MiM would be thinkable... but not wanted.
---
## What I Tried
- Full port scan: zero open TCP ports on the M1 itself
- Modbus TCP on port 502: closed
- Responding to ESP-MESH join_me_req with join_me_resp: no effect
- Intercepting port 6666 and implementing full handshake: stable connection but no data
- Multiple poll function codes (0x11, 0x22, 0x44, 0x45, 0x08 time sync): inverter ignores or resets
- Intercepting cloud connection at `47.87.128.106`: encrypted, not practical
---
## Conclusions
1. The M1 has **no local data API** of any kind
2. Port 6666 / `192.168.1.1` is a **hub presence channel** — registration and keepalive only
3. All solar data flows over the **encrypted cloud connection** to `47.87.128.106:14431`
4. The M1 is designed to work with a **FoxESS hub/gateway/mobile (App) device** that sits at `192.168.1.1` on the local network
5. The hub likely acts as the ESP-MESH root AND may have its own local API — **the hub is the missing piece**
---
I have contacted FoxESS support asking about local API access and the hub device availability. Will update this thread with their response.
Thanks