From 0ac9d7155e80bbff9a6a588444a5d60a1e2dd0b3 Mon Sep 17 00:00:00 2001 From: Pavel Machek <8401486+pavelmachek@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:36:00 +0100 Subject: [PATCH] gyro: add gyroscope test/display application (#76) * iio: switch to maximum sampling frequency, apply mount matrix * mag: Apply mount matrix for magnetometer, too * iio: allow separate path for gyro sensor * gyro: fork from compass, get drawing back to work * iio: add todo. * gyro: gyro seems to work way better on pinephone * iio: move scale conversion where it belongs * gyro: rely of iio driver providing right scale of values * iio: Turn down debugging, but I still get framerate drops * gyro: add reset and calibration support, introduce vectors, display help image --- .../cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON | 24 + .../apps/cz.ucw.pavel.gyro/assets/main.py | 625 ++++++++++++++++++ .../apps/cz.ucw.pavel.gyro/res/gyro-help.png | Bin 0 -> 28684 bytes .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 7808 bytes .../lib/mpos/imu/drivers/iio.py | 159 ++++- 5 files changed, 798 insertions(+), 10 deletions(-) create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png create mode 100644 internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON b/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..bd365e8e --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.gyro/META-INF/MANIFEST.JSON @@ -0,0 +1,24 @@ +{ +"name": "Gyro", +"publisher": "Pavel Machek", +"short_description": "Gyro", +"long_description": "Simple gyro app.", +"icon_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.gyro/icons/cz.ucw.pavel.gyro_0.0.1_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/cz.ucw.pavel.gyro/mpks/cz.ucw.pavel.gyro_0.0.1.mpk", +"fullname": "cz.ucw.pavel.gyro", +"version": "0.0.1", +"category": "utilities", +"activities": [ + { + "entrypoint": "assets/main.py", + "classname": "Main", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} + diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py b/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py new file mode 100644 index 00000000..f6127684 --- /dev/null +++ b/internal_filesystem/apps/cz.ucw.pavel.gyro/assets/main.py @@ -0,0 +1,625 @@ +""" +Test/visualization of gyroscope / accelerometer + +""" + +import time +import os +import math + +try: + import lvgl as lv +except ImportError: + pass + +from mpos import Activity, MposKeyboard, SensorManager + +# ----------------------------- +# Utilities +# ----------------------------- + +def clamp(v, lo, hi): + if v < lo: + return lo + if v > hi: + return hi + return v + +def to_rad(deg): + return deg * math.pi / 180.0 + +def to_deg(rad): + return rad * 180.0 / math.pi + +class Vec3: + def __init__(self): + pass + + def init3(self, x, y, z): + self.x = float(x) + self.y = float(y) + self.z = float(z) + return self + + def init_v(self, v): + self.x = v[0] + self.y = v[1] + self.z = v[2] + return self + + def __add__(self, other): + return vec3( + self.x + other.x, + self.y + other.y, + self.z + other.z + ) + + def __sub__(self, other): + return vec3( + self.x - other.x, + self.y - other.y, + self.z - other.z + ) + + def __mul__(self, scalar): + return vec3( + self.x * scalar, + self.y * scalar, + self.z * scalar + ) + + def __truediv__(self, scalar): + return vec3( + self.x / scalar, + self.y / scalar, + self.z / scalar + ) + + __rmul__ = __mul__ + + def __repr__(self): + return f"X {self.x:.2f} Y {self.y:.2f} Z {self.z:.2f}" + +def vec3(x, y, z): return Vec3().init3(x, y, z) +def vec0(): return Vec3().init3(0, 0, 0) + +# ----------------------------- +# Calibration + heading +# ----------------------------- + +class Gyro: + def __init__(self): + super().__init__() + self.rot = vec0() + self.last = time.time() + self.last_reset = self.last + self.smooth = vec0() + self.calibration = vec0() + + def reset(self): + now = time.time() + self.calibration = self.rot / (now - self.last_reset) + print("Reset... ", self.calibration) + self.last_reset = now + self.rot = vec0() + + def update(self): + """ + Returns heading 0..360 + + iio is in rads/second + """ + t = time.time() + # pp: gyr[1] seems to be rotation "away" and "towards" the user, like pitch in plane ... or maybe roll? + # gyr[2] sseems to be rotation -- as useful for compass on table + v = self.gyr + coef = 1 + self.smooth = self.smooth * (1-coef) + v * coef + self.rot -= self.smooth * (t - self.last) + self.last = t + + def angle(self): + now = time.time() + return self.rot - (now - self.last_reset) * self.calibration + + def angvel(self): + return vec0()-self.smooth + +class UGyro(Gyro): + def __init__(self): + super().__init__() + + self.accel = SensorManager.get_default_sensor(SensorManager.TYPE_ACCELEROMETER) + self.magn = SensorManager.get_default_sensor(SensorManager.TYPE_MAGNETIC_FIELD) + self.gyro = SensorManager.get_default_sensor(SensorManager.TYPE_GYROSCOPE) + + self.gyr = None + + def update(self): + acc = SensorManager.read_sensor_once(self.accel) + sc = 1/9.81 + acc = vec3( -acc[0] * sc, acc[1] * sc, acc[2] * sc ) + self.acc = acc + + self.gyr = Vec3().init_v(SensorManager.read_sensor_once(self.gyro)) + super().update() + +# ----------------------------- +# Canvas (LVGL) +# ----------------------------- + +class Canvas: + """ + LVGL canvas + layer drawing Canvas. + + This matches ports where: + - lv.canvas has init_layer() / finish_layer() + - primitives are drawn via lv.draw_* into lv.layer_t + """ + + def __init__(self, scr, canvas): + self.scr = scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + self.canvas = canvas + + # Background: white (change if you want dark theme) + self.canvas.set_style_bg_color(lv.color_white(), lv.PART.MAIN) + + # Buffer: your working example uses 4 bytes/pixel + # Reality filter: this depends on LV_COLOR_DEPTH; but your example proves it works. + self.buf = bytearray(self.draw_w * self.draw_h * 4) + self.canvas.set_buffer(self.buf, self.draw_w, self.draw_h, lv.COLOR_FORMAT.NATIVE) + + # Layer used for draw engine + self.layer = lv.layer_t() + self.canvas.init_layer(self.layer) + + # Persistent draw descriptors (avoid allocations) + self._line_dsc = lv.draw_line_dsc_t() + lv.draw_line_dsc_t.init(self._line_dsc) + self._line_dsc.width = 1 + self._line_dsc.color = lv.color_black() + self._line_dsc.round_end = 1 + self._line_dsc.round_start = 1 + + self._label_dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(self._label_dsc) + self._label_dsc.color = lv.color_black() + self._label_dsc.font = lv.font_montserrat_24 + + self._rect_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._rect_dsc) + self._rect_dsc.bg_opa = lv.OPA.TRANSP + self._rect_dsc.border_opa = lv.OPA.COVER + self._rect_dsc.border_width = 1 + self._rect_dsc.border_color = lv.color_black() + + self._fill_dsc = lv.draw_rect_dsc_t() + lv.draw_rect_dsc_t.init(self._fill_dsc) + self._fill_dsc.bg_opa = lv.OPA.COVER + self._fill_dsc.bg_color = lv.color_black() + self._fill_dsc.border_width = 1 + + # Clear once + self.clear() + + # ---------------------------- + # Layer lifecycle + # ---------------------------- + + def _begin(self): + # Start drawing into the layer + self.canvas.init_layer(self.layer) + + def _end(self): + # Commit drawing + self.canvas.finish_layer(self.layer) + + # ---------------------------- + # Public API: drawing + # ---------------------------- + + def clear(self): + # Clear the canvas background + self.canvas.fill_bg(lv.color_white(), lv.OPA.COVER) + + def text(self, x, y, s, fg = lv.color_black()): + self._begin() + + dsc = lv.draw_label_dsc_t() + lv.draw_label_dsc_t.init(dsc) + dsc.text = str(s) + dsc.font = lv.font_montserrat_24 + dsc.color = lv.color_black() + + area = lv.area_t() + area.x1 = x + area.y1 = y + area.x2 = x + self.W + area.y2 = y + self.H + + lv.draw_label(self.layer, dsc, area) + + self._end() + + def line(self, x1, y1, x2, y2, fg = lv.color_black()): + self._begin() + + dsc = self._line_dsc + dsc.p1 = lv.point_precise_t() + dsc.p2 = lv.point_precise_t() + dsc.p1.x = int(x1) + dsc.p1.y = int(y1) + dsc.p2.x = int(x2) + dsc.p2.y = int(y2) + + lv.draw_line(self.layer, dsc) + + self._end() + + def circle(self, x, y, r, fg = lv.color_black()): + # Rounded rectangle trick (works everywhere) + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_circle(self, x, y, r, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = int(x - r) + a.y1 = int(y - r) + a.x2 = int(x + r) + a.y2 = int(y + r) + + dsc = self._rect_dsc + dsc.radius = lv.RADIUS_CIRCLE + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def fill_rect(self, x, y, sx, sy, fg = lv.color_black(), bg = lv.color_white()): + self._begin() + + a = lv.area_t() + a.x1 = x + a.y1 = y + a.x2 = x+sx + a.y2 = y+sy + + dsc = self._fill_dsc + dsc.border_color = fg + dsc.bg_color = bg + + lv.draw_rect(self.layer, dsc, a) + + self._end() + + def update(self): + # Nothing needed; drawing is committed per primitive. + # If you want, you can change the implementation so that: + # - draw ops happen between clear() and update() + # But then you must ensure the app calls update() once per frame. + pass + +# ---------------------------- +# App logic +# ---------------------------- + +class PagedCanvas(Activity): + def __init__(self): + super().__init__() + self.page = 0 + self.pages = 3 + + def onCreate(self): + self.scr = lv.obj() + scr = self.scr + + # Screen size + self.W = scr.get_width() + self.H = scr.get_height() + + # Bottom button bar + self.margin = 2 + self.bar_h = 39 + + # Canvas drawing area (everything above button bar) + self.draw_w = self.W + self.draw_h = self.H - (self.bar_h + self.margin * 2) + + # Canvas + self.canvas = lv.canvas(self.scr) + self.canvas.set_size(self.draw_w, self.draw_h) + self.canvas.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.canvas.set_style_border_width(0, 0) + + self.c = Canvas(self.scr, self.canvas) + + # Build buttons + self.build_buttons() + self.setContentView(self.c.scr) + + # ---------------------------- + # Button bar + # ---------------------------- + + def _make_btn(self, parent, x, y, w, h, label): + b = lv.button(parent) + b.set_pos(x, y) + b.set_size(w, h) + + l = lv.label(b) + l.set_text(label) + l.center() + + return b + + def _btn_cb(self, evt, tag): + self.page = tag + + def template_buttons(self, names): + margin = self.margin + y = self.H - self.bar_h - margin + + num = len(names) + if num == 0: + self.buttons = [] + return + + w = (self.W - margin * (num + 1)) // num + h = self.bar_h + x0 = margin + + self.buttons = [] + + for i, label in enumerate(names): + x = x0 + (w + margin) * i + btn = self._make_btn(self.scr, x, y, w, h, label) + + # capture index correctly + btn.add_event_cb( + lambda evt, idx=i: self._btn_cb(evt, idx), + lv.EVENT.CLICKED, + None + ) + + self.buttons.append(btn) + + def build_buttons(self): + self.template_buttons(["Pg0", "Pg1", "Pg2", "Pg3", "..."]) + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 1000, None) + + def onPause(self, screen): + if self.timer: + self.timer.delete() + self.timer = None + + def tick(self, t): + self.update() + self.draw() + + def update(self): + pass + + def draw_page_example(self): + ui = self.c + ui.clear() + + st = 28 + y = 2*st + ui.text(0, y, "Hello world, page is %d" % self.page) + y += st + + def draw(self): + self.draw_page_example() + + def handle_buttons(self): + ui = self.c + +# ---------------------------- +# App logic +# ---------------------------- + +class Main(PagedCanvas): + ASSET_PATH = "M:apps/cz.ucw.pavel.gyro/res/gyro-help.png" + + def __init__(self): + super().__init__() + + self.cal = UGyro() + self.Ypos = 40 + + img = lv.image(lv.layer_top()) + img.set_src(f"{self.ASSET_PATH}") + self.help_img = img + self.hide_img() + + def hide_img(self): + self.help_img.add_flag(lv.obj.FLAG.HIDDEN) + + def draw_img(self): + img = self.help_img + img.remove_flag(lv.obj.FLAG.HIDDEN) + img.set_pos(60, 18) + #img.set_size(640, 640) + img.set_rotation(0) + + def draw(self): + pass + + def onResume(self, screen): + self.timer = lv.timer_create(self.tick, 50, None) + + def update(self): + self.c.clear() + + y = 20 + st = 20 + + self.cal.update() + if self.cal.gyr is None: + self.c.text(0, y, f"No compass data") + y += st + return + + if self.page == 2: + self.draw_img() + return + self.hide_img() + + if self.page == 0: + self.draw_top(self.cal.acc) + elif self.page == 1: + self.draw_values() + elif self.page == 3: + self.c.text(0, y, f"Resetting calibration") + self.page = 0 + self.cal.reset() + + def build_buttons(self): + self.template_buttons(["Graph", "Values", "Help", "Reset"]) + + def draw_values(self): + x, y, z = self.cal.acc.x, self.cal.acc.y, self.cal.acc.z + total = math.sqrt(x*x+y*y+z*z) + s = "" + if x > .6: + s += " left" + if x < -.6: + s += " right" + if y > .6: + s += " up" + if y < -.6: + s += " down" + if z > .6: + s += " below" + if z < -.6: + s += " above" + + t = "" + lim = 25 + angvel = self.cal.angvel() + if angvel.z > lim: + # top part moves to the right + t += " yaw+" + if angvel.z < -lim: + t += " yaw-" + if angvel.x > lim: + # top part goes up + t += " pitch+" + if angvel.x < -lim: + t += " pitch-" + if angvel.y > lim: + # right part goes down + t += " roll+" + if angvel.y < -lim: + t += " roll-" + + self.c.text(0, 7, f""" +^ Up -> Right +|| Acc +{self.cal.acc} +Earth is{s}, {total*100:.0f}% +{self.cal.gyr} +Rotation is{t} +""") + + def _px_per_deg(self): + # JS used deg->px: (deg/90)*(width/2.1) + s = min(self.c.W, self.c.H) + return (s / 2.1) / 90.0 + + def _degrees_to_pixels(self, deg): + return deg * self._px_per_deg() + + # ---- TOP VIEW ---- + + def draw_top(self, acc): + heading=self.cal.angle().z + heading2=self.cal.angvel().z + vmin=0 + vmax=20 + v=self.cal.gyr + + cx = self.c.W // 2 + cy = self.c.H // 2 + + # Crosshair + self.c.line(0, cy, self.c.W, cy) + self.c.line(cx, 0, cx, self.c.H) + + # Circles (30/60/90 deg) + for rdeg in (30, 60, 90): + r = int(self._degrees_to_pixels(rdeg)) + self.c.circle(cx, cy, r) + + # Accel circle + if acc is not None: + self._draw_accel(acc) + + # Heading arrow(s) + self._draw_heading_arrow(heading, color=lv.color_make(255, 0, 0)) + self.c.text(265, 22, "%d°" % int(heading)) + if heading2 is not None: + self._draw_heading_arrow(heading2, color=lv.color_make(255, 255, 255), size = 100) + self.c.text(10, 22, "%d°" % int(heading2)) + + def _draw_heading_arrow(self, heading, color, size = 80): + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + rad = -to_rad(heading) + x2 = cx + math.sin(rad - 0.1) * size + y2 = cy - math.cos(rad - 0.1) * size + x3 = cx + math.sin(rad + 0.1) * size + y3 = cy - math.cos(rad + 0.1) * size + + poly = [ + int(cx), int(cy), + int(x2), int(y2), + int(x3), int(y3), + ] + + self.c.line(poly[0], poly[1], poly[2], poly[3]) + self.c.line(poly[2], poly[3], poly[4], poly[5]) + self.c.line(poly[4], poly[5], poly[0], poly[1]) + + def _draw_accel(self, acc): + ax, ay, az = acc.x, acc.y, acc.z + cx = self.c.W / 2.0 + cy = self.c.H / 2.0 + + x2 = cx + ax * self.c.W + y2 = cy + ay * self.c.W + + self.c.circle(int(x2), int(y2), int(self.c.W / 8)) diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/gyro-help.png new file mode 100644 index 0000000000000000000000000000000000000000..4b54b993402a0f2db0ef3e740fdab3f132e02411 GIT binary patch literal 28684 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

Cx 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 WnpJ;CMSl@!f4rxwpUXO@geCx9gGwd< literal 0 HcmV?d00001 diff --git a/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/cz.ucw.pavel.gyro/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb60f0cbd4a9b480e6bb47157e0b21828632b99 GIT binary patch literal 7808 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 str: - print("Read: ", name) + if False: + print("Read: ", name) f = open(name, "r") try: return f.readline().strip() finally: f.close() + def _parse_available_freqs(self, text): + """ + IIO typically uses either: + "12.5 25 50 100" + or + "0.5 1 2 4 8 16" + + Returns list of floats. + """ + out = [] + for tok in text.replace(",", " ").split(): + out.append(float(tok)) + return out + + def _format_freq_for_sysfs(self, f): + """ + Kernel sysfs usually accepts either integer or decimal. + We'll keep it minimal: + - if f is whole number -> "100" + - else -> "12.5" + """ + if int(f) == f: + return str(int(f)) + # avoid scientific notation + s = ("%.6f" % f).rstrip("0").rstrip(".") + return s + + def _try_set_via_sudo_tee(self, path, value_str): + """ + Executes: + sh -c 'echo VALUE | sudo tee PATH' + Returns True if command returns 0. + """ + cmd = "sh -c 'echo %s | sudo tee %s >/dev/null'" % (value_str, path) + rc = os.system(cmd) + return rc == 0 + + def ensure_sampling_frequency_max(self, dev_path): + """ + dev_path: "/sys/bus/iio/devices/iio:deviceX" + + Returns: + (changed: bool, max_freq: float or None, current: float or None) + """ + sf = dev_path + "/sampling_frequency" + sfa = dev_path + "/sampling_frequency_available" + + # read current + cur_s = self._read_text(sf) + cur = float(cur_s) + + avail_s = self._read_text(sfa) + avail = self._parse_available_freqs(avail_s) + + maxf = max(avail) + + # already max (tolerate float fuzz) + if abs(cur - maxf) < 1e-6: + print("Already at max frequency") + return (False, maxf, cur) + + max_str = self._format_freq_for_sysfs(maxf) + + # Fallback: sudo tee + ok = self._try_set_via_sudo_tee(sf, max_str) + if not ok: + print("Can't switch to max frequency") + return (False, maxf, cur) + + new_cur = float(self._read_text(sf)) + + return (True, maxf, new_cur) + + def ensure_sampling_frequency_max_for_device_with_file(self, filename): + """ + Convenience wrapper: + - finds iio device containing filename + - sets sampling_frequency to maximum + """ + dev = self.find_iio_device_with_file(filename) + if dev is None: + return (None, False, None, None) + + changed, maxf, cur = self.ensure_sampling_frequency_max(dev) + return (dev, changed, maxf, cur) + def _read_float(self, name: str) -> float: return float(self._read_text(name)) @@ -93,6 +185,7 @@ class IIODriver(IMUDriverBase): - in_temp_input (already scaled, usually millidegree C) - in_temp_raw + in_temp_scale """ + return 12.34 if not self.accel_path: return None @@ -102,6 +195,51 @@ class IIODriver(IMUDriverBase): return None return self._read_raw_scaled(raw_path, scale_path) + def _read_mount_matrix(self, p): + """ + Reads IIO mount matrix from *mount_matrix + + Format example: + "0, 1, 0; -1, 0, 0; 0, 0, 1" + + Returns: + 3x3 matrix as tuple of tuples (float) + """ + path = p + "/" + "in_accel_mount_matrix" + if not self._exists(path): + # Strange, librem 5 has different filename + path = self.accel_path + "/" + "mount_matrix" + if not self._exists(path): + return None + + text = self._read_text(path).strip() + + rows = [] + for row in text.split(";"): + rows.append(tuple(float(x.strip()) for x in row.split(","))) + + if len(rows) != 3 or any(len(r) != 3 for r in rows): + raise ValueError("Invalid mount matrix format") + + return tuple(rows) + + + def _apply_mount_matrix(self, ax, ay, az, p): + """ + Applies IIO mount matrix to acceleration vector. + + Returns rotated (ax, ay, az). + """ + M = self._read_mount_matrix(p) + if M is None: + return (ax, ay, az) + + x = M[0][0]*ax + M[0][1]*ay + M[0][2]*az + y = M[1][0]*ax + M[1][1]*ay + M[1][2]*az + z = M[2][0]*ax + M[2][1]*ay + M[2][2]*az + + return (x, y, z) + def _raw_acceleration_mps2(self): if not self.accel_path: return (0.0, 0.0, 0.0) @@ -111,18 +249,19 @@ class IIODriver(IMUDriverBase): ay = self._read_raw_scaled(self.accel_path + "/" + "in_accel_y_raw", scale_name) az = self._read_raw_scaled(self.accel_path + "/" + "in_accel_z_raw", scale_name) - return (ax, ay, az) + return self._apply_mount_matrix(ax, ay, az, self.accel_path) def _raw_gyroscope_dps(self): - if not self.accel_path: + if not self.gyro_path: return (0.0, 0.0, 0.0) - scale_name = self.accel_path + "/" + "in_anglvel_scale" + scale_name = self.gyro_path + "/" + "in_anglvel_scale" + mul = 57.2957795 - gx = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_x_raw", scale_name) - gy = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_y_raw", scale_name) - gz = self._read_raw_scaled(self.accel_path + "/" + "in_anglvel_z_raw", scale_name) + gx = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_x_raw", scale_name) + gy = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_y_raw", scale_name) + gz = mul * self._read_raw_scaled(self.gyro_path + "/" + "in_anglvel_z_raw", scale_name) - return (gx, gy, gz) + return self._apply_mount_matrix(gx, gy, gz, self.gyro_path) def read_acceleration(self): ax, ay, az = self._raw_acceleration_mps2() @@ -145,4 +284,4 @@ class IIODriver(IMUDriverBase): gy = self._read_raw_scaled(self.mag_path + "/" + "in_magn_y_raw", self.mag_path + "/" + "in_magn_y_scale") gz = self._read_raw_scaled(self.mag_path + "/" + "in_magn_z_raw", self.mag_path + "/" + "in_magn_z_scale") - return (gx, gy, gz) + return self._apply_mount_matrix(gx, gy, gz, self.mag_path)