Skip to content

Commit fd8afd3

Browse files
Optimized the TCP client and server code for data loss
Thanks to Doug Williams (@williamsdoug) for the new protocol format to minimize data loss during continuous streaming of ECG through TCP/WiFi. New packet format: Offset | Byte Value | Description ------ | ----------- | ------------------ 0 | 0x0A | Start of frame 1 | 0xFA | Start of frame 2| Payload Size LSB | 3| Payload Size MSB | 4| Protocol version | (currently 0x03) 5-8| Packet sequence | incremental number 9-16| Timestamp | From ESP32 gettimeofday() 17-20| R-R Interval | 18-...| ECG Data samples | Currently 8 samples / packet ...| 0x0B | End of Frame
2 parents 4a269ac + 7565164 commit fd8afd3

File tree

7 files changed

+711
-126
lines changed

7 files changed

+711
-126
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
# coding: utf-8
2+
3+
#
4+
# HeartyPatch Client
5+
#
6+
# Copyright Douglas Williams, 2018
7+
#
8+
# Licensed under terms of MIT License (http://opensource.org/licenses/MIT).
9+
#
10+
11+
# To Do:
12+
# 1) Support incremental Saves
13+
14+
15+
import socket
16+
from pprint import pprint
17+
import os
18+
import sys
19+
import signal as sys_signal
20+
import struct
21+
22+
import numpy as np
23+
import matplotlib.pyplot as plt
24+
import scipy.signal as signal
25+
import time
26+
from datetime import datetime
27+
28+
hp_host = '192.168.0.106'
29+
hp_port = 4567
30+
fname = 'log.csv'
31+
32+
33+
# from: https://stackoverflow.com/questions/20766813/how-to-convert-signed-to-unsigned-integer-in-python
34+
def is_interactive():
35+
import __main__ as main
36+
return not hasattr(main, '__file__')
37+
38+
39+
class HeartyPatch_TCP_Parser:
40+
# Packet Validation
41+
CESState_Init = 0
42+
CESState_SOF1_Found = 1
43+
CESState_SOF2_Found = 2
44+
CESState_PktLen_Found = 3
45+
46+
# CES CMD IF Packet Format
47+
CES_CMDIF_PKT_START_1 = 0x0A
48+
CES_CMDIF_PKT_START_2 = 0xFA
49+
CES_CMDIF_PKT_STOP = 0x0B
50+
51+
# CES CMD IF Packet Indices
52+
CES_CMDIF_IND_LEN = 2
53+
CES_CMDIF_IND_LEN_MSB = 3
54+
CES_CMDIF_IND_PKTTYPE = 4
55+
CES_CMDIF_PKT_OVERHEAD = 5
56+
CES_CMDIF_PKT_DATA = CES_CMDIF_PKT_OVERHEAD
57+
58+
59+
ces_pkt_seq_bytes = 4 # Buffer for Sequence ID
60+
ces_pkt_ts_bytes = 8 # Buffer for Timestamp
61+
ces_pkt_rtor_bytes = 4 # R-R Interval Buffer
62+
ces_pkt_ecg_bytes = 4 # Field(s) to hold ECG data
63+
64+
Expected_Type = 3 # new format
65+
66+
min_packet_size = 19
67+
68+
def __init__(self):
69+
self.state = self.CESState_Init
70+
self.data = ''
71+
self.packet_count = 0
72+
self.bad_packet_count = 0
73+
self.bytes_skipped = 0
74+
self.total_bytes = 0
75+
self.all_seq = []
76+
self.all_ts = []
77+
self.all_rtor = []
78+
self.all_hr = []
79+
self.all_ecg = []
80+
pass
81+
82+
def add_data(self, new_data):
83+
self.data += new_data
84+
self.total_bytes += len(new_data)
85+
86+
87+
def process_packets(self):
88+
while len(self.data) >= self.min_packet_size:
89+
if self.state == self.CESState_Init:
90+
if ord(self.data[0]) == self.CES_CMDIF_PKT_START_1:
91+
self.state = self.CESState_SOF1_Found
92+
else:
93+
self.data = self.data[1:] # skip to next byte
94+
self.bytes_skipped += 1
95+
continue
96+
elif self.state == self.CESState_SOF1_Found:
97+
if ord(self.data[1]) == self.CES_CMDIF_PKT_START_2:
98+
self.state = self.CESState_SOF2_Found
99+
else:
100+
self.state = self.CESState_Init
101+
self.data = self.data[1:] # start from beginning
102+
self.bytes_skipped += 1
103+
continue
104+
elif self.state == self.CESState_SOF2_Found:
105+
# sanity check header for expected values
106+
107+
pkt_len = 256 * ord(self.data[self.CES_CMDIF_IND_LEN_MSB]) + ord(self.data[self.CES_CMDIF_IND_LEN])
108+
# Make sure we have a full packet
109+
if len(self.data) < (self.CES_CMDIF_PKT_OVERHEAD + pkt_len + 2):
110+
break
111+
112+
113+
if (ord(self.data[self.CES_CMDIF_IND_PKTTYPE]) != self.Expected_Type
114+
or ord(self.data[self.CES_CMDIF_PKT_OVERHEAD+pkt_len+1]) != self.CES_CMDIF_PKT_STOP):
115+
116+
if True:
117+
print 'pkt_len', pkt_len
118+
print ord(self.data[self.CES_CMDIF_IND_PKTTYPE]), self.Expected_Type,
119+
print ord(self.data[self.CES_CMDIF_IND_PKTTYPE]) != self.Expected_Type
120+
121+
for j in range(0, self.CES_CMDIF_PKT_OVERHEAD):
122+
print format(ord(self.data[j]),'02x'),
123+
print
124+
125+
for j in range(self.CES_CMDIF_PKT_OVERHEAD, self.CES_CMDIF_PKT_OVERHEAD+pkt_len):
126+
print format(ord(self.data[j]),'02x'),
127+
print
128+
129+
for j in range(self.CES_CMDIF_PKT_OVERHEAD+pkt_len, self.CES_CMDIF_PKT_OVERHEAD+pkt_len+2):
130+
print format(ord(self.data[j]),'02x'),
131+
print
132+
print self.CES_CMDIF_PKT_STOP,
133+
print ord(self.data[self.CES_CMDIF_PKT_OVERHEAD+pkt_len+2]) != self.CES_CMDIF_PKT_STOP
134+
print
135+
136+
# unexpected packet format
137+
self.state = self.CESState_Init
138+
self.data = self.data[1:] # start from beginning
139+
self.bytes_skipped += 1
140+
self.bad_packet_count += 1
141+
continue
142+
143+
# Parse Payload
144+
payload = self.data[self.CES_CMDIF_PKT_OVERHEAD:self.CES_CMDIF_PKT_OVERHEAD+pkt_len+1]
145+
146+
ptr = 0
147+
# Process Sequence ID
148+
seq_id = struct.unpack('<I', payload[ptr:ptr+4])[0]
149+
self.all_seq.append(seq_id)
150+
ptr += self.ces_pkt_seq_bytes
151+
152+
# Process Timestamp
153+
ts_s = struct.unpack('<I', payload[ptr:ptr+4])[0]
154+
ts_us = struct.unpack('<I', payload[ptr+4:ptr+8])[0]
155+
timestamp = ts_s + ts_us/1000000.0
156+
self.all_ts.append(timestamp)
157+
ptr += self.ces_pkt_ts_bytes
158+
159+
# Process R-R Interval
160+
rtor = struct.unpack('<I', payload[ptr:ptr+4])[0]
161+
self.all_rtor.append(rtor)
162+
if rtor == 0:
163+
self.all_hr.append(0)
164+
else:
165+
self.all_hr.append(60000.0/rtor)
166+
167+
ptr += self.ces_pkt_rtor_bytes
168+
169+
170+
assert ptr == 16
171+
assert pkt_len == (16 + 8 * 4)
172+
# Process Sequence ID
173+
while ptr < pkt_len:
174+
ecg = struct.unpack('<i', payload[ptr:ptr+4])[0] / 1000.0
175+
self.all_ecg.append(ecg)
176+
ptr += self.ces_pkt_ecg_bytes
177+
178+
self.packet_count += 1
179+
self.state = self.CESState_Init
180+
self.data = self.data[self.CES_CMDIF_PKT_OVERHEAD+pkt_len+2:] # start from beginning
181+
182+
183+
soc = None
184+
hp = None
185+
tStart = None
186+
def get_heartypatch_data(max_packets=10000, hp_host='192.168.0.106', max_seconds=-1):
187+
global soc
188+
global hp
189+
global tStart
190+
191+
tcp_reads = 0
192+
193+
hp = HeartyPatch_TCP_Parser()
194+
print hp_host
195+
196+
try:
197+
soc = socket.create_connection((hp_host, hp_port))
198+
except Exception:
199+
try:
200+
soc.close()
201+
except Exception:
202+
pass
203+
soc = socket.create_connection((hp_host, hp_port))
204+
205+
i = 0
206+
pkt_last = 0
207+
txt = soc.recv(16*1024) # discard any initial results
208+
tStart = time.time()
209+
while max_packets == -1 or hp.packet_count < max_packets:
210+
txt = soc.recv(16*1024)
211+
hp.add_data(txt)
212+
hp.process_packets()
213+
i += 1
214+
215+
tcp_reads += 1
216+
if tcp_reads % 50 == 0:
217+
print '.',
218+
sys.stdout.flush()
219+
220+
if hp.packet_count - pkt_last >= 1000:
221+
pkt_last = pkt_last + 1000
222+
print hp.packet_count//1000,
223+
sys.stdout.flush()
224+
if time.time() - tStart > max_seconds:
225+
break
226+
227+
228+
def finish(show_plot):
229+
global soc
230+
global hp
231+
global tStart
232+
global fname
233+
234+
duration = time.time() - tStart
235+
if soc is not None:
236+
soc.close()
237+
238+
packet_rate = float(hp.packet_count)/duration
239+
ecg_sample_rate = float(len(hp.all_ecg)) / duration
240+
print
241+
print 'Recording duration: {:0.1f} sec -- Packet Rate: {:0.1f} pkt/sec ECG Sample Rate: {:0.1f} samp/sec'.format(
242+
duration, packet_rate, ecg_sample_rate)
243+
print 'Packets: {} Bytes_per_packet: {} Bad Packets: {} Bytes Skipped: {}'.format(
244+
hp.packet_count, hp.total_bytes/float(hp.packet_count), hp.bad_packet_count, hp.bytes_skipped)
245+
print
246+
if len(hp.all_ecg) > 0:
247+
print 'Signal -- Max: {:0.0f} Min: {:0.0f} Range: {:0.0f}'.format(
248+
np.max(hp.all_ecg), np.min(hp.all_ecg), np.max(hp.all_ecg)- np.min(hp.all_ecg))
249+
print 'Index of largest and smallest values -- max: {} min:{}'.format(
250+
np.argmax(hp.all_ecg), np.argmin(hp.all_ecg))
251+
252+
253+
ptr = fname.rfind('.')
254+
fname_ecg = fname[:ptr] + '_ecg' + fname[ptr:]
255+
256+
header = '{} Epoch: {} Duration: {:0.1f} sec Rate: {:0.1f}'.format(
257+
time.ctime(tStart), tStart, duration, ecg_sample_rate)
258+
np.savetxt(fname_ecg, hp.all_ecg, header=header)
259+
260+
header = 'seq, timestamp, rtor, hr'
261+
np.savetxt(fname, zip(hp.all_seq, hp.all_ts, hp.all_rtor, hp.all_hr),
262+
fmt=('%d','%.3f','%d','%d'), header=header, delimiter=',')
263+
264+
for i in range(1, len(hp.all_seq)):
265+
if hp.all_seq[i] != hp.all_seq[i-1] + 1:
266+
print 'gap at:', i, hp.all_ts[i-1], hp.all_ts[i]
267+
268+
print 'Timestamps -- duration: {} start: {} end: {}'.format(
269+
hp.all_ts[-1]- hp.all_ts[0], hp.all_ts[0], hp.all_ts[-1])
270+
271+
if show_plot:
272+
n = int(5*ecg_sample_rate)
273+
plt.figure(figsize=(11, 2))
274+
plt.title('HeartyPatch Output -- First 5 seconds')
275+
plt.plot(np.arange(n)*5.0/n, hp.all_ecg[:n])
276+
plt.ylabel('ecg')
277+
plt.xlabel('time in sec')
278+
plt.xlim(0, )
279+
plt.show()
280+
281+
n = int(5*60 * packet_rate)
282+
subset_hr = hp.all_hr[:n]
283+
284+
plt.figure(figsize=(11, 2))
285+
plt.title('HeartyPatch Output -- First 5 minute')
286+
plt.plot(np.arange(len(subset_hr))*5.0/n, subset_hr)
287+
plt.ylabel('HR')
288+
plt.xlabel('time in min')
289+
plt.xlim(0, )
290+
plt.ylim(0, 240)
291+
plt.show()
292+
293+
294+
295+
296+
def signal_handler(signal, frame):
297+
global soc
298+
print('Interrupted by Ctrl+C!')
299+
finish()
300+
sys.exit(0)
301+
302+
303+
def help():
304+
print 'usage: python heartypatch_downloader_protocol3.py -f <fname> -s <total samples> -m <total_minutes> -i <ip address> -n'
305+
print 'Where:'
306+
print ' -f <fname> is filename, used tocopatch_recordings folder and .csv suffix by default'
307+
print ' -s <total samples> -- total sample count, not needed is minutes specified'
308+
print ' -m <total_minutes> -- recording duration im minutes (defaault 10min)'
309+
print ' -i <ip address> full IP address or address within in 192.168.43.x subnet>'
310+
print ' -p show plot upon completion of recording'
311+
sys.exit(0)
312+
313+
if __name__ == "__main__" and not is_interactive():
314+
max_packets= -1
315+
max_seconds = 10*60 # default recording duration is 10min
316+
fname = 'log.csv'
317+
hp_host = 'heartypatch.local'
318+
show_plot = False
319+
320+
i = 1
321+
while i < len(sys.argv):
322+
if sys.argv[i] == '-f' and i < len(sys.argv)-1:
323+
fname = sys.argv[i+1]
324+
i += 2
325+
elif sys.argv[i] == '-s' and i < len(sys.argv)-1:
326+
max_packets = int(sys.argv[i+1])
327+
max_seconds = -1
328+
i += 2
329+
elif sys.argv[i] == '-m' and i < len(sys.argv)-1:
330+
max_seconds = int(sys.argv[i+1])*60
331+
max_packets = -1
332+
i += 2
333+
elif sys.argv[i] == '-i' and i < len(sys.argv)-1:
334+
try:
335+
foo = int(sys.argv[i+1])
336+
hp_host = '192.168.43.'+sys.argv[i+1]
337+
except Exception:
338+
hp_host = sys.argv[i + 1]
339+
i += 2
340+
elif sys.argv[i] in '-p':
341+
show_plot = True
342+
i += 1
343+
elif sys.argv[i] in ['-h', '--help']:
344+
help()
345+
else:
346+
print 'Unknown argument', sys.argv[i]
347+
help()
348+
349+
sys_signal.signal(sys_signal.SIGINT, signal_handler)
350+
get_heartypatch_data(max_packets=max_packets, max_seconds=max_seconds, hp_host=hp_host)
351+
finish(show_plot)
352+
353+

firmware/heartypatch-stream-tcp/main/Kconfig.projbuild

+30-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ config WIFI_PASSWORD
1414

1515
Can be left blank if the network has no security set.
1616

17+
choice
18+
bool "Custom Samplerate"
19+
config SPS_128
20+
bool "Samplerate 128 sps"
21+
help
22+
Set samplerate to 128 sps
23+
config SPS_256
24+
bool "Samplerate 256 sps"
25+
help
26+
Set samplerate to 256 sps
27+
config SPS_512
28+
bool "Samplerate 512 sps"
29+
help
30+
Set samplerate to 512 sps (not recommended)
31+
endchoice
32+
33+
config DHPF_ENABLE
34+
bool "Enable DHPF"
35+
default y
36+
help
37+
Enable/Disable Digital High Pass Filter
38+
1739
config TCP_ENABLE
1840
bool "Enable TCP"
1941
help
@@ -23,4 +45,11 @@ config MDNS_ENABLE
2345
bool "Enable MDNS"
2446
help
2547
Enable/Disable MDNS
26-
endmenu
48+
49+
config MAX30003_STATS_ENABLE
50+
bool "Enable Stats"
51+
default n
52+
help
53+
Enable/Disable Max30003 stats reporting to console
54+
55+
endmenu

0 commit comments

Comments
 (0)