diff --git a/Documentation/bluetooth/ecg.rst b/Documentation/bluetooth/ecg.rst
new file mode 100644
index 0000000000000000000000000000000000000000..42d39a331858cc6b7395a7378aa4e92e14d1e047
--- /dev/null
+++ b/Documentation/bluetooth/ecg.rst
@@ -0,0 +1,29 @@
+Bluetooth ECG Service
+========================
+
+.. warning::
+    The service is still work in progress and subject to change
+
+The ECG service provides access to the ECG sensor of the card10
+
+BLE Service
+-----------
+
+The current draft uses following service specification:
+
+- Service:
+
+  UUID: ``42230300-2342-2342-2342-234223422342``
+
+- ECG samples characteristic:
+
+  UUID: ``42230301-2342-2342-2342-234223422342``
+  notify
+
+ECG samples characteristic
+--------------------------
+
+List of 16 bit samples (big endian). Enable notifications to
+receive a stream of samples while the ECG app is open.
+
+The first 16 bit are a sample counter (big endian).
diff --git a/Documentation/index.rst b/Documentation/index.rst
index d7acad2eea356d26215186f0da41f0a85950fb32..5c8f389f0c4d34ec38a288aade40e75cd60d8847 100644
--- a/Documentation/index.rst
+++ b/Documentation/index.rst
@@ -75,6 +75,7 @@ Last but not least, if you want to start hacking the lower-level firmware, the
    bluetooth/ess
    bluetooth/file-transfer
    bluetooth/card10
+   bluetooth/ecg
    bluetooth/nimble
 
 Indices and tables
diff --git a/epicardium/ble/epic_ble_api.c b/epicardium/ble/epic_ble_api.c
index c1fa3e11ec5efdb4c8fe303115481d19650fb891..68ad55a3bd3ddede3d845ac51b8f60017ea0b129 100644
--- a/epicardium/ble/epic_ble_api.c
+++ b/epicardium/ble/epic_ble_api.c
@@ -97,6 +97,10 @@ void ble_epic_ble_api_trigger_event(enum epic_ble_event_type type, void *data)
 
 int epic_ble_get_event(struct epic_ble_event *e)
 {
+	if (!ble_is_enabled()) {
+		return -EIO;
+	}
+
 	if (xQueueReceive(ble_event_queue, e, 0) != pdTRUE) {
 		return -ENOENT;
 	}
@@ -162,6 +166,10 @@ void vDmTimerCallback()
 
 int epic_ble_init(void)
 {
+	if (!ble_is_enabled()) {
+		return -EIO;
+	}
+
 	if (dm_timer == NULL) {
 		dm_timer = xTimerCreateStatic(
 			"dmtimer",
diff --git a/preload/apps/ecg/__init__.py b/preload/apps/ecg/__init__.py
index 978141d0e7c6722ad31e4ec1f6e976bd4e37195a..3258406cadd5364a1ccabcf485a3de9fe0b483e2 100644
--- a/preload/apps/ecg/__init__.py
+++ b/preload/apps/ecg/__init__.py
@@ -7,9 +7,11 @@ import max30001
 import math
 import struct
 import itertools
-from ecg.settings import *
+import bluetooth
+import ecg.settings
+
+config = ecg.settings.ecg_settings()
 
-config = ecg_settings()
 WIDTH = 160
 HEIGHT = 80
 OFFSET_Y = 49
@@ -26,436 +28,503 @@ COLOR_MODE_FINGER = [0, 255, 0]
 COLOR_MODE_USB = [0, 0, 255]
 COLOR_WRITE_FG = [255, 255, 255]
 COLOR_WRITE_BG = [255, 0, 0]
-
-history = []
-
-# variables for file output
-filebuffer = bytearray()
-write = 0
-write_time_string = ""
-samples_since_start_of_write = 0
-
-update_screen = 0
-pause_screen = 0
-pause_graph = False
-graph_offset = 0
-sensor = 0
-disp = display.open()
-last_sample_count = 1
-
-leds.dim_top(1)
 COLORS = [((23 + (15 * i)) % 360, 1.0, 1.0) for i in range(11)]
 
-# variables for high-pass filter
-# note: corresponds to 1st order hpf with -3dB at ~18.7Hz
-# general formula: f(-3dB)=-(sample_rate/tau)*ln(1-betadash)
-moving_average = 0
-alpha = 2
-beta = 3
-betadash = beta / (alpha + beta)
-
-
-def update_history(datasets):
-    global history, moving_average, alpha, beta, last_sample_count
-    last_sample_count = len(datasets)
-    for val in datasets:
-        if "HP" in config.get_option("Filter"):
-            history.append(val - moving_average)
-            moving_average += betadash * (val - moving_average)
-            # identical to: moving_average = (alpha * moving_average + beta * val) / (alpha + beta)
-        else:
-            history.append(val)
-
-    # trim old elements
-    history = history[-HISTORY_MAX:]
-
+_IRQ_CENTRAL_CONNECT = const(1)
+_IRQ_CENTRAL_DISCONNECT = const(2)
+_IRQ_GATTS_WRITE = const(3)
 
-# variables for pulse detection
-pulse = -1
-samples_since_last_pulse = 0
-last_pulse_blink = 0
-q_threshold = -1
-r_threshold = 1
-q_spike = -500  # just needs to be long ago
 
+class ECG:
+    history = []
 
-def neighbours(n, lst):
-    """
-    neighbours(2, "ABCDE") = ("AB", "BC", "CD", "DE")
-    neighbours(3, "ABCDE") = ("ABC", "BCD", "CDE")
-    """
-
-    for i in range(len(lst) - (n - 1)):
-        yield lst[i : i + n]
-
-
-def detect_pulse(num_new_samples):
-    global history, pulse, samples_since_last_pulse, q_threshold, r_threshold, q_spike
-
-    # look at 3 consecutive samples, starting 2 samples before the samples that were just added, e.g.:
-    # existing samples: "ABCD"
-    # new samples: "EF" => "ABCDEF"
-    # consider ["CDE", "DEF"]
-    # new samples: "GHI" => "ABCDEFGHI"
-    # consider ["EFG", "FGH", "GHI"]
-    ecg_rate = config.get_option("Rate")
-
-    for [prev, cur, next_] in neighbours(3, history[-(num_new_samples + 2) :]):
-        samples_since_last_pulse += 1
-
-        if prev > cur < next_ and cur < q_threshold:
-            q_spike = samples_since_last_pulse
-            # we expect the next q-spike to be at least 60% as high as this one
-            q_threshold = (cur * 3) // 5
-        elif (
-            prev < cur > next_
-            and cur > r_threshold
-            and samples_since_last_pulse - q_spike < ecg_rate // 10
-        ):
-            # the full QRS complex is < 0.1s long, so the q and r spike in particular cannot be more than ecg_rate//10 samples apart
-            pulse = 60 * ecg_rate // samples_since_last_pulse
-            q_spike = -ecg_rate
-            if pulse < 30 or pulse > 210:
-                pulse = -1
-            elif write > 0 and "pulse" in config.get_option("Log"):
-                write_pulse()
-            # we expect the next r-spike to be at least 60% as high as this one
-            r_threshold = (cur * 3) // 5
-            samples_since_last_pulse = 0
-        elif samples_since_last_pulse > 2 * ecg_rate:
-            q_threshold = -1
-            r_threshold = 1
-            pulse = -1
-
-
-def callback_ecg(datasets):
-    global update_screen, history, filebuffer, write, samples_since_start_of_write
-    update_screen += len(datasets)
-    if write > 0:
-        samples_since_start_of_write += len(datasets)
-
-    # update graph datalist
-    if not pause_graph:
-        update_history(datasets)
-        detect_pulse(len(datasets))
-
-    # buffer for writes
-    if write > 0 and "graph" in config.get_option("Log"):
-        for value in datasets:
-            filebuffer.extend(struct.pack("h", value))
-            if len(filebuffer) >= FILEBUFFERBLOCK:
-                write_filebuffer()
-
-    # don't update on every callback
-    if update_screen >= DRAW_AFTER_SAMPLES:
-        draw_graph()
-
-
-def append_to_file(fileprefix, content):
-    global write, pause_screen
-    # write to file
-    filename = "/ecg_logs/{}-{}.log".format(fileprefix, write_time_string)
-
-    # write stuff to disk
-    try:
-        f = open(filename, "ab")
-        f.write(content)
-        f.close()
-    except OSError as e:
-        print("Please check the file or filesystem", e)
-        write = 0
-        pause_screen = -1
-        disp.clear(COLOR_BACKGROUND)
-        disp.print("IO Error", posy=0, fg=COLOR_TEXT)
-        disp.print("Please check", posy=20, fg=COLOR_TEXT)
-        disp.print("your", posy=40, fg=COLOR_TEXT)
-        disp.print("filesystem", posy=60, fg=COLOR_TEXT)
-        disp.update()
-        close_sensor()
-    except:
-        print("Unexpected error, stop writing logfile")
-        write = 0
-
-
-def write_pulse():
-    # estimates timestamp as calls to time.time() take too much time
-    approx_timestamp = write + samples_since_start_of_write // config.get_option("Rate")
-    append_to_file("pulse", struct.pack("ib", approx_timestamp, pulse))
-
-
-def write_filebuffer():
-    global filebuffer
-    append_to_file("ecg", filebuffer)
+    # variables for file output
     filebuffer = bytearray()
+    write = 0
+    write_time_string = ""
+    samples_since_start_of_write = 0
 
+    update_screen = 0
+    pause_screen = 0
+    pause_graph = False
+    graph_offset = 0
+    sensor = None
+    disp = display.open()
+    last_sample_count = 1
+
+    # variables for high-pass filter
+    # note: corresponds to 1st order hpf with -3dB at ~18.7Hz
+    # general formula: f(-3dB)=-(sample_rate/tau)*ln(1-betadash)
+    moving_average = 0
+    alpha = 2
+    beta = 3
+    betadash = beta / (alpha + beta)
+
+    # variables for pulse detection
+    pulse = -1
+    samples_since_last_pulse = 0
+    last_pulse_blink = 0
+    q_threshold = -1
+    r_threshold = 1
+    q_spike = -500  # just needs to be long ago
+
+    def ble_irq(self, event, data):
+        if event == _IRQ_CENTRAL_CONNECT:
+            print("BLE Connected")
+        elif event == _IRQ_CENTRAL_DISCONNECT:
+            print("BLE Disconnected")
+            self.ble_streaming = False
+            if not config.get_option("BLE Disp"):
+                self.disp.backlight(20)
+        elif event == _IRQ_GATTS_WRITE:
+            conn_handle, value_handle = data
+            if value_handle == self.ecg_cccd_handle:
+                value = self.b.gatts_read(value_handle)
+                print("New cccd value:", value)
+                # Value of 0 means notifcations off
+                if value == b"\x00\x00":
+                    self.ble_streaming = False
+                    if not config.get_option("BLE Disp"):
+                        self.disp.backlight(20)
+                else:
+                    self.ble_streaming = True
+                    self.ble_sample_count = 0
+                    if not config.get_option("BLE Disp"):
+                        self.disp.backlight(0)
 
-def open_sensor():
-    global sensor
-    sensor = max30001.MAX30001(
-        usb=(config.get_option("Mode") == "USB"),
-        bias=config.get_option("Bias"),
-        sample_rate=config.get_option("Rate"),
-        callback=callback_ecg,
-    )
-
-
-def close_sensor():
-    global sensor
-    sensor.close()
-
-
-def toggle_write():
-    global write, disp, pause_screen, filebuffer, samples_since_start_of_write, write_time_string
-    pause_screen = time.time_ms() + 1000
-    disp.clear(COLOR_BACKGROUND)
-    if write > 0:
-        write_filebuffer()
-        write = 0
-        disp.print("Stop", posx=50, posy=20, fg=COLOR_TEXT)
-        disp.print("logging", posx=30, posy=40, fg=COLOR_TEXT)
-    else:
-        filebuffer = bytearray()
-        write = time.time()
-        lt = time.localtime(write)
-        write_time_string = "{:04d}-{:02d}-{:02d}_{:02d}{:02d}{:02d}".format(*lt)
-        samples_since_start_of_write = 0
+    def __init__(self):
         try:
-            os.mkdir("ecg_logs")
-        except:
-            pass
-        disp.print("Start", posx=45, posy=20, fg=COLOR_TEXT)
-        disp.print("logging", posx=30, posy=40, fg=COLOR_TEXT)
+            self.b = bluetooth.BLE()
+            self.b.active(True)
+            self.b.irq(self.ble_irq)
+
+            _ECG_UUID = bluetooth.UUID("42230300-2342-2342-2342-234223422342")
+            _ECG_DATA = (
+                bluetooth.UUID("42230301-2342-2342-2342-234223422342"),
+                bluetooth.FLAG_READ | bluetooth.FLAG_NOTIFY,
+            )
 
-    disp.update()
+            _ECG_SERVICE = (_ECG_UUID, (_ECG_DATA,))
+            ((self.ecg_data_handle,),) = self.b.gatts_register_services((_ECG_SERVICE,))
+            self.ecg_cccd_handle = self.ecg_data_handle + 1
 
+            # Disable streaming by default
+            self.ble_streaming = False
+            self.b.gatts_write(self.ecg_cccd_handle, "\x00\x00")
+        except OSError:
+            self.ble_streaming = False
+            pass
 
-def toggle_pause():
-    global pause_graph, graph_offset, history, leds
-    if pause_graph:
-        pause_graph = False
-        history = []
-    else:
-        pause_graph = True
-    graph_offset = 0
-    leds.clear()
+        leds.dim_top(1)
+
+    def update_history(self, datasets):
+        self.last_sample_count = len(datasets)
+        for val in datasets:
+            if "HP" in config.get_option("Filter"):
+                self.history.append(val - self.moving_average)
+                self.moving_average += self.betadash * (val - self.moving_average)
+                # identical to: moving_average = (alpha * moving_average + beta * val) / (alpha + beta)
+            else:
+                self.history.append(val)
+
+        # trim old elements
+        self.history = self.history[-HISTORY_MAX:]
+
+    def detect_pulse(self, num_new_samples):
+        # look at 3 consecutive samples, starting 2 samples before the samples that were just added, e.g.:
+        # existing samples: "ABCD"
+        # new samples: "EF" => "ABCDEF"
+        # consider ["CDE", "DEF"]
+        # new samples: "GHI" => "ABCDEFGHI"
+        # consider ["EFG", "FGH", "GHI"]
+        ecg_rate = config.get_option("Rate")
+
+        def neighbours(n, lst):
+            """
+            neighbours(2, "ABCDE") = ("AB", "BC", "CD", "DE")
+            neighbours(3, "ABCDE") = ("ABC", "BCD", "CDE")
+            """
+
+            for i in range(len(lst) - (n - 1)):
+                yield lst[i : i + n]
+
+        for [prev, cur, next_] in neighbours(3, self.history[-(num_new_samples + 2) :]):
+            self.samples_since_last_pulse += 1
+
+            if prev > cur < next_ and cur < self.q_threshold:
+                self.q_spike = self.samples_since_last_pulse
+                # we expect the next q-spike to be at least 60% as high as this one
+                self.q_threshold = (cur * 3) // 5
+            elif (
+                prev < cur > next_
+                and cur > self.r_threshold
+                and self.samples_since_last_pulse - self.q_spike < ecg_rate // 10
+            ):
+                # the full QRS complex is < 0.1s long, so the q and r spike in particular cannot be more than ecg_rate//10 samples apart
+                self.pulse = 60 * ecg_rate // self.samples_since_last_pulse
+                self.q_spike = -ecg_rate
+                if self.pulse < 30 or self.pulse > 210:
+                    self.pulse = -1
+                elif self.write > 0 and "pulse" in config.get_option("Log"):
+                    self.write_pulse()
+                # we expect the next r-spike to be at least 60% as high as this one
+                self.r_threshold = (cur * 3) // 5
+                self.samples_since_last_pulse = 0
+            elif self.samples_since_last_pulse > 2 * ecg_rate:
+                self.q_threshold = -1
+                self.r_threshold = 1
+                self.pulse = -1
+
+    def callback_ecg(self, datasets):
+        if len(datasets) == 0:
+            return
 
+        if self.ble_streaming:
+            try:
+                self.b.gatts_notify(
+                    1,
+                    self.ecg_data_handle,
+                    struct.pack(">h", self.ble_sample_count & 0xFFFF)
+                    + struct.pack(">" + ("h" * len(datasets)), *datasets),
+                )
+            except OSError:
+                pass
+
+            # We count all samples, even if we failed to send them
+            self.ble_sample_count += len(datasets)
+
+            # Don't update the screen if it should be off during a connection
+            if not config.get_option("BLE Disp"):
+                return
+
+        self.update_screen += len(datasets)
+        if self.write > 0:
+            self.samples_since_start_of_write += len(datasets)
+
+        # update graph datalist
+        if not self.pause_graph:
+            self.update_history(datasets)
+            self.detect_pulse(len(datasets))
+
+        # buffer for writes
+        if self.write > 0 and "graph" in config.get_option("Log"):
+            for value in datasets:
+                self.filebuffer.extend(struct.pack("h", value))
+                if len(self.filebuffer) >= FILEBUFFERBLOCK:
+                    self.write_filebuffer()
+
+        # don't update on every callback
+        if self.update_screen >= DRAW_AFTER_SAMPLES:
+            self.draw_graph()
+
+    def append_to_file(self, fileprefix, content):
+        # write to file
+        filename = "/ecg_logs/{}-{}.log".format(fileprefix, self.write_time_string)
+
+        # write stuff to disk
+        try:
+            f = open(filename, "ab")
+            f.write(content)
+            f.close()
+        except OSError as e:
+            print("Please check the file or filesystem", e)
+            self.write = 0
+            self.pause_screen = -1
+            self.disp.clear(COLOR_BACKGROUND)
+            self.disp.print("IO Error", posy=0, fg=COLOR_TEXT)
+            self.disp.print("Please check", posy=20, fg=COLOR_TEXT)
+            self.disp.print("your", posy=40, fg=COLOR_TEXT)
+            self.disp.print("filesystem", posy=60, fg=COLOR_TEXT)
+            self.disp.update()
+            self.close_sensor()
+        except:
+            print("Unexpected error, stop writing logfile")
+            self.write = 0
 
-def draw_leds(vmin, vmax):
-    # vmin should be in [0, -1]
-    # vmax should be in [0, 1]
-    global pulse, samples_since_last_pulse, last_pulse_blink
+    def write_pulse(self):
+        # estimates timestamp as calls to time.time() take too much time
+        approx_timestamp = (
+            self.write + self.samples_since_start_of_write // config.get_option("Rate")
+        )
+        self.append_to_file("pulse", struct.pack("ib", approx_timestamp, self.pulse))
+
+    def write_filebuffer(self):
+        self.append_to_file("ecg", self.filebuffer)
+        self.filebuffer = bytearray()
+
+    def open_sensor(self):
+        self.sensor = max30001.MAX30001(
+            usb=(config.get_option("Mode") == "USB"),
+            bias=config.get_option("Bias"),
+            sample_rate=config.get_option("Rate"),
+            callback=self.callback_ecg,
+        )
 
-    led_mode = config.get_option("LEDs")
+    def close_sensor(self):
+        self.sensor.close()
+
+    def toggle_write(self):
+        self.pause_screen = time.time_ms() + 1000
+        self.disp.clear(COLOR_BACKGROUND)
+        if self.write > 0:
+            self.write_filebuffer()
+            self.write = 0
+            self.disp.print("Stop", posx=50, posy=20, fg=COLOR_TEXT)
+            self.disp.print("logging", posx=30, posy=40, fg=COLOR_TEXT)
+        else:
+            self.filebuffer = bytearray()
+            self.write = time.time()
+            lt = time.localtime(self.write)
+            self.write_time_string = "{:04d}-{:02d}-{:02d}_{:02d}{:02d}{:02d}".format(
+                *lt
+            )
+            self.samples_since_start_of_write = 0
+            try:
+                os.mkdir("ecg_logs")
+            except:
+                pass
+            self.disp.print("Start", posx=45, posy=20, fg=COLOR_TEXT)
+            self.disp.print("logging", posx=30, posy=40, fg=COLOR_TEXT)
+
+        self.disp.update()
+
+    def toggle_pause(self):
+        if self.pause_graph:
+            self.pause_graph = False
+            self.history = []
+        else:
+            self.pause_graph = True
+        self.graph_offset = 0
+        leds.clear()
 
-    # stop blinking
-    if not bool(led_mode):
-        return
+    def draw_leds(self, vmin, vmax):
+        # vmin should be in [0, -1]
+        # vmax should be in [0, 1]
 
-    # update led bar
-    if "bar" in led_mode:
-        for i in reversed(range(6)):
-            leds.prep_hsv(
-                5 + i, COLORS[5 + i] if vmin <= 0 and i <= vmin * -6 else (0, 0, 0)
-            )
-        for i in reversed(range(6)):
-            leds.prep_hsv(
-                i, COLORS[i] if vmax >= 0 and 5 - i <= vmax * 6 else (0, 0, 0)
-            )
+        led_mode = config.get_option("LEDs")
 
-    # blink red on pulse
-    if (
-        "pulse" in led_mode
-        and pulse > 0
-        and samples_since_last_pulse < last_pulse_blink
-    ):
-        for i in range(4):
-            leds.prep(11 + i, (255, 0, 0))
-    elif "pulse" in led_mode:
-        for i in range(4):
-            leds.prep(11 + i, (0, 0, 0))
-    last_pulse_blink = samples_since_last_pulse
-
-    leds.update()
-
-
-def draw_graph():
-    global disp, history, write, pause_screen, update_screen
-
-    # skip rendering due to message beeing shown
-    if pause_screen == -1:
-        return
-    elif pause_screen > 0:
-        t = time.time_ms()
-        if t > pause_screen:
-            pause_screen = 0
-        else:
+        # stop blinking
+        if not bool(led_mode):
             return
 
-    disp.clear(COLOR_BACKGROUND)
-
-    # offset in pause_graph mode
-    timeWindow = config.get_option("Window")
-    window_end = int(len(history) - graph_offset)
-    s_end = max(0, window_end)
-    s_start = max(0, s_end - WIDTH * timeWindow)
-
-    # get max value and calc scale
-    value_max = max(abs(x) for x in history[s_start:s_end])
-    scale = SCALE_FACTOR / (value_max if value_max > 0 else 1)
-
-    # draw graph
-    # values need to be inverted so high values are drawn with low pixel coordinates (at the top of the screen)
-    draw_points = (int(-x * scale + OFFSET_Y) for x in history[s_start:s_end])
-
-    prev = next(draw_points)
-    for x, value in enumerate(draw_points):
-        disp.line(x // timeWindow, prev, (x + 1) // timeWindow, value, col=COLOR_LINE)
-        prev = value
-
-    # draw text: mode/bias/write
-    if pause_graph:
-        disp.print(
-            "Pause"
-            + (
-                " -{:0.1f}s".format(graph_offset / config.get_option("Rate"))
-                if graph_offset > 0
-                else ""
-            ),
-            posx=0,
-            posy=0,
-            fg=COLOR_TEXT,
-        )
-    else:
-        led_range = last_sample_count if last_sample_count > 5 else 5
-        draw_leds(
-            min(history[-led_range:]) / value_max, max(history[-led_range:]) / value_max
-        )
-        if pulse < 0:
-            disp.print(
-                config.get_option("Mode")
-                + ("+Bias" if config.get_option("Bias") else ""),
+        # update led bar
+        if "bar" in led_mode:
+            for i in reversed(range(6)):
+                leds.prep_hsv(
+                    5 + i, COLORS[5 + i] if vmin <= 0 and i <= vmin * -6 else (0, 0, 0)
+                )
+            for i in reversed(range(6)):
+                leds.prep_hsv(
+                    i, COLORS[i] if vmax >= 0 and 5 - i <= vmax * 6 else (0, 0, 0)
+                )
+
+        # blink red on pulse
+        if (
+            "pulse" in led_mode
+            and self.pulse > 0
+            and self.samples_since_last_pulse < self.last_pulse_blink
+        ):
+            for i in range(4):
+                leds.prep(11 + i, (255, 0, 0))
+        elif "pulse" in led_mode:
+            for i in range(4):
+                leds.prep(11 + i, (0, 0, 0))
+        self.last_pulse_blink = self.samples_since_last_pulse
+
+        leds.update()
+
+    def draw_graph(self):
+        # skip rendering due to message beeing shown
+        if self.pause_screen == -1:
+            return
+        elif self.pause_screen > 0:
+            t = time.time_ms()
+            if t > self.pause_screen:
+                self.pause_screen = 0
+            else:
+                return
+
+        self.disp.clear(COLOR_BACKGROUND)
+
+        # offset in pause_graph mode
+        timeWindow = config.get_option("Window")
+        window_end = int(len(self.history) - self.graph_offset)
+        s_end = max(0, window_end)
+        s_start = max(0, s_end - WIDTH * timeWindow)
+
+        # get max value and calc scale
+        value_max = max(abs(x) for x in self.history[s_start:s_end])
+        scale = SCALE_FACTOR / (value_max if value_max > 0 else 1)
+
+        # draw graph
+        # values need to be inverted so high values are drawn with low pixel coordinates (at the top of the screen)
+        draw_points = (int(-x * scale + OFFSET_Y) for x in self.history[s_start:s_end])
+
+        prev = next(draw_points)
+        for x, value in enumerate(draw_points):
+            self.disp.line(
+                x // timeWindow, prev, (x + 1) // timeWindow, value, col=COLOR_LINE
+            )
+            prev = value
+
+        # draw text: mode/bias/write
+        if self.pause_graph:
+            self.disp.print(
+                "Pause"
+                + (
+                    " -{:0.1f}s".format(self.graph_offset / config.get_option("Rate"))
+                    if self.graph_offset > 0
+                    else ""
+                ),
                 posx=0,
                 posy=0,
-                fg=(
-                    COLOR_MODE_FINGER
-                    if config.get_option("Mode") == MODE_FINGER
-                    else COLOR_MODE_USB
-                ),
+                fg=COLOR_TEXT,
             )
         else:
-            disp.print(
-                "BPM: {}".format(pulse),
-                posx=0,
-                posy=0,
-                fg=(
-                    COLOR_MODE_FINGER
-                    if config.get_option("Mode") == MODE_FINGER
-                    else COLOR_MODE_USB
-                ),
+            led_range = self.last_sample_count if self.last_sample_count > 5 else 5
+            self.draw_leds(
+                min(self.history[-led_range:]) / value_max,
+                max(self.history[-led_range:]) / value_max,
             )
+            if self.pulse < 0:
+                self.disp.print(
+                    config.get_option("Mode")
+                    + ("+Bias" if config.get_option("Bias") else ""),
+                    posx=0,
+                    posy=0,
+                    fg=(
+                        COLOR_MODE_FINGER
+                        if config.get_option("Mode") == MODE_FINGER
+                        else COLOR_MODE_USB
+                    ),
+                )
+            else:
+                self.disp.print(
+                    "BPM: {}".format(self.pulse),
+                    posx=0,
+                    posy=0,
+                    fg=(
+                        COLOR_MODE_FINGER
+                        if config.get_option("Mode") == MODE_FINGER
+                        else COLOR_MODE_USB
+                    ),
+                )
+
+        # announce writing ecg log
+        if self.write > 0:
+            t = time.time()
+            if self.write > 0 and t % 2 == 0:
+                self.disp.print(
+                    "LOG", posx=0, posy=60, fg=COLOR_WRITE_FG, bg=COLOR_WRITE_BG
+                )
+
+        self.disp.update()
+        self.update_screen = 0
+
+    def main(self):
+        # show button layout
+        self.disp.clear(COLOR_BACKGROUND)
+        self.disp.print(
+            "  BUTTONS ", posx=0, posy=0, fg=COLOR_TEXT, font=display.FONT20
+        )
+        self.disp.line(0, 20, 159, 20, col=COLOR_LINE)
+        self.disp.print(
+            "       Pause >", posx=0, posy=28, fg=COLOR_MODE_FINGER, font=display.FONT16
+        )
+        self.disp.print(
+            "    Settings >", posx=0, posy=44, fg=COLOR_MODE_USB, font=display.FONT16
+        )
+        self.disp.print(
+            "< WriteLog    ", posx=0, posy=64, fg=COLOR_WRITE_BG, font=display.FONT16
+        )
+        self.disp.update()
+        time.sleep(3)
 
-    # announce writing ecg log
-    if write > 0:
-        t = time.time()
-        if write > 0 and t % 2 == 0:
-            disp.print("LOG", posx=0, posy=60, fg=COLOR_WRITE_FG, bg=COLOR_WRITE_BG)
-
-    disp.update()
-    update_screen = 0
-
-
-def main():
-    global pause_graph, graph_offset, pause_screen
-
-    # show button layout
-    disp.clear(COLOR_BACKGROUND)
-    disp.print("  BUTTONS ", posx=0, posy=0, fg=COLOR_TEXT, font=display.FONT20)
-    disp.line(0, 20, 159, 20, col=COLOR_LINE)
-    disp.print(
-        "       Pause >", posx=0, posy=28, fg=COLOR_MODE_FINGER, font=display.FONT16
-    )
-    disp.print(
-        "    Settings >", posx=0, posy=44, fg=COLOR_MODE_USB, font=display.FONT16
-    )
-    disp.print(
-        "< WriteLog    ", posx=0, posy=64, fg=COLOR_WRITE_BG, font=display.FONT16
-    )
-    disp.update()
-    time.sleep(3)
-
-    # start ecg
-    open_sensor()
-    while True:
-        button_pressed = {"BOTTOM_LEFT": 0, "BOTTOM_RIGHT": 0, "TOP_RIGHT": 0}
+        # start ecg
+        self.open_sensor()
         while True:
-            v = buttons.read(
-                buttons.BOTTOM_LEFT | buttons.BOTTOM_RIGHT | buttons.TOP_RIGHT
-            )
-
-            # TOP RIGHT
-            #
-            # pause
-
-            # down
-            if button_pressed["TOP_RIGHT"] == 0 and v & buttons.TOP_RIGHT != 0:
-                button_pressed["TOP_RIGHT"] = 1
-                toggle_pause()
-
-            # up
-            if button_pressed["TOP_RIGHT"] > 0 and v & buttons.TOP_RIGHT == 0:
-                button_pressed["TOP_RIGHT"] = 0
-
-            # BOTTOM LEFT
-            #
-            # on pause = shift view left
-            # else = toggle write
-
-            # down
-            if button_pressed["BOTTOM_LEFT"] == 0 and v & buttons.BOTTOM_LEFT != 0:
-                button_pressed["BOTTOM_LEFT"] = 1
-                if pause_graph:
-                    l = len(history)
-                    graph_offset += config.get_option("Rate") / 2
-                    if l - graph_offset < WIDTH * config.get_option("Window"):
-                        graph_offset = l - WIDTH * config.get_option("Window")
-                else:
-                    toggle_write()
-
-            # up
-            if button_pressed["BOTTOM_LEFT"] > 0 and v & buttons.BOTTOM_LEFT == 0:
-                button_pressed["BOTTOM_LEFT"] = 0
-
-            # BOTTOM RIGHT
-            #
-            # on pause = shift view right
-            # else = show settings
-
-            # down
-            if button_pressed["BOTTOM_RIGHT"] == 0 and v & buttons.BOTTOM_RIGHT != 0:
-                button_pressed["BOTTOM_RIGHT"] = 1
-                if pause_graph:
-                    graph_offset -= config.get_option("Rate") / 2
-                    graph_offset -= graph_offset % (config.get_option("Rate") / 2)
-                    if graph_offset < 0:
-                        graph_offset = 0
-                else:
-                    pause_screen = -1  # hide graph
-                    leds.clear()  # disable all LEDs
-                    config.run()  # show config menu
-                    close_sensor()  # reset sensor in case mode or bias was changed TODO do not close sensor otherwise?
-                    open_sensor()
-                    pause_screen = 0  # start plotting graph again
-                    # returning from menu was by pressing the TOP_RIGHT button
+            button_pressed = {"BOTTOM_LEFT": 0, "BOTTOM_RIGHT": 0, "TOP_RIGHT": 0}
+            while True:
+                v = buttons.read(
+                    buttons.BOTTOM_LEFT | buttons.BOTTOM_RIGHT | buttons.TOP_RIGHT
+                )
+
+                # TOP RIGHT
+                #
+                # pause
+
+                # down
+                if button_pressed["TOP_RIGHT"] == 0 and v & buttons.TOP_RIGHT != 0:
                     button_pressed["TOP_RIGHT"] = 1
-
-            # up
-            if button_pressed["BOTTOM_RIGHT"] > 0 and v & buttons.BOTTOM_RIGHT == 0:
-                button_pressed["BOTTOM_RIGHT"] = 0
+                    self.toggle_pause()
+
+                # up
+                if button_pressed["TOP_RIGHT"] > 0 and v & buttons.TOP_RIGHT == 0:
+                    button_pressed["TOP_RIGHT"] = 0
+
+                # BOTTOM LEFT
+                #
+                # on pause = shift view left
+                # else = toggle write
+
+                # down
+                if button_pressed["BOTTOM_LEFT"] == 0 and v & buttons.BOTTOM_LEFT != 0:
+                    button_pressed["BOTTOM_LEFT"] = 1
+                    if self.pause_graph:
+                        l = len(self.history)
+                        self.graph_offset += config.get_option("Rate") / 2
+                        if l - self.graph_offset < WIDTH * config.get_option("Window"):
+                            self.graph_offset = l - WIDTH * config.get_option("Window")
+                    else:
+                        self.toggle_write()
+
+                # up
+                if button_pressed["BOTTOM_LEFT"] > 0 and v & buttons.BOTTOM_LEFT == 0:
+                    button_pressed["BOTTOM_LEFT"] = 0
+
+                # BOTTOM RIGHT
+                #
+                # on pause = shift view right
+                # else = show settings
+
+                # down
+                if (
+                    button_pressed["BOTTOM_RIGHT"] == 0
+                    and v & buttons.BOTTOM_RIGHT != 0
+                ):
+                    button_pressed["BOTTOM_RIGHT"] = 1
+                    if self.pause_graph:
+                        self.graph_offset -= config.get_option("Rate") / 2
+                        self.graph_offset -= self.graph_offset % (
+                            config.get_option("Rate") / 2
+                        )
+                        if self.graph_offset < 0:
+                            self.graph_offset = 0
+                    else:
+                        self.pause_screen = -1  # hide graph
+                        leds.clear()  # disable all LEDs
+                        if self.ble_streaming and not config.get_option("BLE Disp"):
+                            self.disp.backlight(20)
+
+                        config.run()  # show config menu
+
+                        if self.ble_streaming and not config.get_option("BLE Disp"):
+                            self.disp.backlight(0)
+                        self.close_sensor()  # reset sensor in case mode or bias was changed TODO do not close sensor otherwise?
+                        self.open_sensor()
+                        self.pause_screen = 0  # start plotting graph again
+                        # returning from menu was by pressing the TOP_RIGHT button
+                        button_pressed["TOP_RIGHT"] = 1
+
+                # up
+                if button_pressed["BOTTOM_RIGHT"] > 0 and v & buttons.BOTTOM_RIGHT == 0:
+                    button_pressed["BOTTOM_RIGHT"] = 0
 
 
 if __name__ == "__main__":
+    ecg = ECG()
     try:
-        main()
+        ecg.main()
     except KeyboardInterrupt as e:
-        sensor.close()
+        ecg.close_sensor()
         raise e
diff --git a/preload/apps/ecg/settings.py b/preload/apps/ecg/settings.py
index ca49bbd109a0b7d372b7320ea5b89c4444fd396b..2336a5c798e9c6e36652f69011669f1f5ceff7c0 100644
--- a/preload/apps/ecg/settings.py
+++ b/preload/apps/ecg/settings.py
@@ -95,6 +95,7 @@ def ecg_settings():
     config.add_option(("Filter", itertools.cycle([("HP", {"HP"}), ("off", {})])))
     config.add_option(("Rate", itertools.cycle([("128Hz", 128), ("256Hz", 256)])))
     config.add_option(("Window", itertools.cycle([("1x", 1), ("2x", 2), ("3x", 3)])))
+    config.add_option(("BLE Disp", itertools.cycle([("Off", False), ("On", True)])))
     config.add_option(
         (
             "Log",
diff --git a/pycardium/modules/modbluetooth_card10.c b/pycardium/modules/modbluetooth_card10.c
index 61d244ca563e24533ea9d7951b29bb69a94bea62..995a0c1c216d4081ef74e60583dfa4901c330159 100644
--- a/pycardium/modules/modbluetooth_card10.c
+++ b/pycardium/modules/modbluetooth_card10.c
@@ -474,9 +474,14 @@ int mp_bluetooth_init(void)
 		MP_ROM_INT(EPIC_INT_BLE), (mp_obj_t *)&ble_event_obj
 	);
 	clear_events();
-	epic_ble_init();
-	active = true;
-	return 0;
+	int ret = epic_ble_init();
+
+	if (ret == 0) {
+		active = true;
+	} else {
+		active = false;
+	}
+	return ret;
 }
 
 // Disables the Bluetooth stack. Is a no-op when not enabled.
diff --git a/tools/card10-ble-ecg-streaming.py b/tools/card10-ble-ecg-streaming.py
new file mode 100755
index 0000000000000000000000000000000000000000..e4ec000370a179f341ac49394eda5745e1516ef9
--- /dev/null
+++ b/tools/card10-ble-ecg-streaming.py
@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+
+import bluepy
+import time
+import struct
+import argparse
+import numpy as np
+import threading
+
+from datetime import datetime
+import matplotlib
+
+matplotlib.use("GTK3Agg")
+
+import matplotlib.pyplot as plt
+import matplotlib.animation as animation
+
+from scipy import signal
+
+# Set this to true to invert the polarity of the data
+invert = True
+
+# Default sample rate of the ECG app
+sample_rate = 128
+
+# Larger FFT length gives more BPM resoultion, but takes longer to settle
+fft_len = 1024
+
+# Low/High section of the FFT which is shown. In BPM.
+fft_low_freq = 40
+fft_high_freq = 200
+
+# If you want to see 50/60 Hz line noise in the FFT
+# fft_high_freq = 60*60
+
+fft_low_bin = int(fft_low_freq / 60 / (sample_rate / fft_len))
+fft_high_bin = int(fft_high_freq / 60 / (sample_rate / fft_len))
+
+# 50 Hz notch filter
+b, a = signal.iirnotch(50, 50, sample_rate)
+
+ecg_data = []
+
+# Create figure for plotting
+fig, ax = plt.subplots(2, 2, sharey=False)
+fig.suptitle("card10 BLE ECG Streaming")
+fig.canvas.manager.set_window_title("card10 BLE ECG Streaming")
+
+# This function is called periodically from FuncAnimation
+def animate(i):
+    global ecg_data
+
+    # Limit displayed data
+    xs = np.array(range(len(ecg_data))[-fft_len:]) / sample_rate
+    ys = np.array(ecg_data[-fft_len:])
+
+    if len(ys) == 0:
+        return
+
+    if invert:
+        ys *= -1
+
+    ys_filt = signal.filtfilt(b, a, ys)
+
+    # Draw x and y lists
+    ax[0, 0].clear()
+    ax[0, 0].plot(xs, ys)
+
+    ax[1, 0].clear()
+    ax[1, 0].plot(xs, ys_filt)
+
+    if len(ys) == fft_len:
+        ax[0, 1].clear()
+        fft = abs(np.fft.fft(ys)[fft_low_bin:fft_high_bin])
+        ax[0, 1].plot(
+            np.linspace(fft_low_freq, fft_high_freq, fft_high_bin - fft_low_bin), fft
+        )
+
+        am = np.argmax(fft) + fft_low_bin
+
+        print("BPM:", am * sample_rate / fft_len * 60)
+
+        ax[1, 1].clear()
+        fft = abs(np.fft.fft(ys)[fft_low_bin:fft_high_bin])
+        ax[1, 1].plot(
+            np.linspace(fft_low_freq, fft_high_freq, fft_high_bin - fft_low_bin), fft
+        )
+
+        am = np.argmax(fft) + fft_low_bin
+
+        print("BPM(filt):", am * sample_rate / fft_len * 60)
+
+    # Format plot
+    # Needs to be here as the clear() call above also clears parts of the formating
+    ax[0, 0].set_title("ECG Data")
+    ax[0, 0].set_ylabel("Voltage")
+    ax[0, 0].set_xlabel("Time [s]")
+
+    ax[1, 0].set_title("ECG Data (50 Hz filtered)")
+    ax[1, 0].set_ylabel("Voltage")
+    ax[1, 0].set_xlabel("Time [s]")
+
+    ax[0, 1].set_title("FFT of ECG Data")
+    ax[0, 1].set_xlabel("Frequency [BPM]")
+
+    ax[1, 1].set_title("FFT of ECG Data (50 Hz filtered)")
+    ax[1, 1].set_xlabel("Frequency [BPM]")
+
+
+def plot_thread():
+    # Set up plot to call animate() function periodically
+    ani = animation.FuncAnimation(fig, animate, interval=500)
+    plt.show()
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="""\
+Transfer sensor data using Bluetooth Low Energy.
+"""
+    )
+
+    parser.add_argument(
+        "mac", help="BT MAC address of the card10. Format: CA:4D:10:XX:XX:XX"
+    )
+
+    args = parser.parse_args()
+
+    t0 = time.time()
+    p = bluepy.btle.Peripheral(args.mac)
+
+    # We need a larger MTU than the default MTU
+    p.setMTU(90)
+
+    c = p.getCharacteristics(uuid="42230301-2342-2342-2342-234223422342")[0]
+
+    # Enable streaming
+    c.getDescriptors()[0].write(b"\x01\x00")
+
+    print("Connection setup time:", int(time.time() - t0), "seconds")
+
+    class ECGDelegate(bluepy.btle.DefaultDelegate):
+        def __init__(self):
+            bluepy.btle.DefaultDelegate.__init__(self)
+            self.t0 = time.time()
+
+        def handleNotification(self, cHandle, data):
+            global ecg_data
+
+            if cHandle == c.valHandle:
+                index, *volts = struct.unpack(
+                    ">H" + ("h" * ((len(data) // 2) - 1)), data
+                )
+                t = time.time()
+
+                dt = t - self.t0
+                self.t0 = t
+
+                print(f"{dt:.3f}", cHandle, index, volts)
+                ecg_data += volts
+
+    p.setDelegate(ECGDelegate())
+
+    pt = threading.Thread(target=plot_thread)
+    pt.daemon = True
+    pt.start()
+
+    while pt.is_alive():
+        # All the magic happens in the delegate and the plotting thread.
+        # We just spin in here and tell bluepy that we want to receive
+        # notification.
+        p.waitForNotifications(1.0)
+
+
+if __name__ == "__main__":
+    main()