plyer.compass のリプレイス版を「ちゃんと」書く

plyer.compass が使い物にならない件の続き。

一つ前の投稿の前半を繰り返しておく:

「コンパス」という機能名で欲しいのは文字通りコンパスであろう。つまり東西南北を知りたいわけだ。けど plyer.compass 単体では「何も出来ない」。

要は azimuth, roll, pitch を得たいわけなんだけれども、android API を使ってこれを得る方法は例えばこことかこことかこことか、まぁ色々みつかる。一応公式の説明はこれ。(stackexchange でのやりとりも参考になりそう。ちゃんと読めてないけど。)

これらサイトをみるに、方法は2つ:

  • (非推奨)Sensor.TYPE_ORIENTATION を使う
  • Sensor.TYPE_MAGNETIC_FIELD と Sensor.TYPE_ACCELEROMETER を組み合わせて計算する

そうなんだけれども、plyer.compass の実装はこのどちらでもない:

前回の Sensor.TYPE_ORIENTATION でも動くし、ワタシのデバイスでは特に問題はなさそうにもみえるんだけれども、確かにドキュメントではっきり非推奨と書かれているし、公式の説明があるので、欲しいものを「javaで」書くのは簡単なわけだが、これを Pyjnius を通して書きたい、ってわけだ。

デバッグ環境を整えてないので無駄に苦労したのはナイショである。なんであれ、出来上がってしまえばまったくもって難しくはなくて、これだけ:

my_compass.py
 1 '''
 2 Android Compass
 3 ---------------------
 4 '''
 5 
 6 import math
 7 from copy import deepcopy
 8 from plyer.facades import Compass
 9 from jnius import PythonJavaClass, java_method, autoclass, cast
10 from plyer.platforms.android import activity
11 
12 Context = autoclass('android.content.Context')
13 Sensor = autoclass('android.hardware.Sensor')
14 SensorManager = autoclass('android.hardware.SensorManager')
15 
16 
17 class _SensorListener(PythonJavaClass):
18     __javainterfaces__ = ['android/hardware/SensorEventListener']
19 
20     def __init__(self, sensor_manager, sensor_type):
21         super(_SensorListener, self).__init__()
22         self.SensorManager = sensor_manager
23         self.sensor = self.SensorManager.getDefaultSensor(sensor_type)
24 
25         self.values = None
26 
27     def enable(self):
28         self.SensorManager.registerListener(self, self.sensor,
29             SensorManager.SENSOR_DELAY_NORMAL)
30 
31     def disable(self):
32         self.SensorManager.unregisterListener(self, self.sensor)
33 
34     @java_method('(Landroid/hardware/SensorEvent;)V')
35     def onSensorChanged(self, event):
36         self.values = deepcopy(event.values)
37 
38     @java_method('(Landroid/hardware/Sensor;I)V')
39     def onAccuracyChanged(self, sensor, accuracy):
40         # Maybe, do something in future?
41         pass
42 
43 
44 class AndroidCompass(Compass):
45     def __init__(self):
46         super(AndroidCompass, self).__init__()
47         self.SensorManager = cast('android.hardware.SensorManager',
48             activity.getSystemService(Context.SENSOR_SERVICE))
49         self.bState = False
50 
51     def _enable(self):
52         if not self.bState:
53             self.listener_a = _SensorListener(
54                 self.SensorManager,
55                 Sensor.TYPE_ACCELEROMETER)
56             self.listener_a.enable()
57             #
58             self.listener_m = _SensorListener(
59                 self.SensorManager,
60                 Sensor.TYPE_MAGNETIC_FIELD)
61             self.listener_m.enable()
62             #
63             self.bState = True
64 
65     def _disable(self):
66         if self.bState:
67             self.bState = False
68             #
69             self.listener_m.disable()
70             del self.listener_m
71             #
72             self.listener_a.disable()
73             del self.listener_a
74 
75     def _get_orientation(self):
76         if self.bState and \
77                 self.listener_a.values and \
78                 self.listener_m.values:
79             R = [0., 0., 0., 0., 0., 0., 0., 0., 0.]
80             values = [0., 0., 0.]
81             self.SensorManager.getRotationMatrix(
82                 R, None, 
83                 self.listener_a.values,
84                 self.listener_m.values)
85             self.SensorManager.getOrientation(R, values)
86             # convert to degrees for compatibility with TYPE_ORIENTATION
87             return tuple(math.degrees(v) for v in values)
88         else:
89             return (None, None, None)
90 
91     def __del__(self):
92         if self.bState:
93             self._disable()
94         super(self.__class__, self).__del__()

元の実装とはまぁ結構違うことは違うが、まぁわかるでしょ。

さて。この「my_compass.py」。ワタシのサイトでは一度も説明してない「kivy」用である。そして、これもまた説明してない「kivyLauncher」を android 機に入れて使うと、「正規の Google Play アプリのように java (や NDK を使った C/C++) で書いてビルドして apk としてパッケージング」せずとも特定のフォルダに python スクリプトを置くだけで kivy アプリが動かせるので、つまり「今すぐにあなたの android 機で使える」。

kivy やら kivyLauncher やら、あるいは「android における python」全般についてはおいおい整理してお伝えしようかとは思うが、kivyLauncher の利用そのもの自体はとっても簡単である:

  1. kivyLauncher をあなたの android スマホ(やタブレット)に Google Play からインストール
  2. 特定の場所(多分 /sdcard/kivy、おそらく実体として /storage/emulated/0/kivy とか)に特定の形式のアプリケーションフォルダとして配置

    • 当たり前だが書くのは kivy アプリケーション
    • /sdcard/kivyの直下のフォルダに突っ込む
    • /sdcard/kivyの直下のフォルダには必ず main.py と android.txt 必要
    • ↑これを満たせば kivyLauncher がアプリケーションであることを認識する
    • main.py が文字通りアプリケーションのメイン
    • android.txt の形式は後掲

つーわけで、my_compass.py を使った最もシンプルなコンパス:

android.txt
1 title=Graphical Compass Demo
2 author=hhsprings
3 orientation=all
main.py
 1 # -*- coding: utf-8 -*-
 2 # コード全体としては
 3 #   http://stackoverflow.com/questions/18923321/making-a-clock-in-kivy
 4 # を参考にした。
 5 from kivy.app import App
 6 from kivy.uix.widget import Widget
 7 from kivy.graphics import Color, Line
 8 from kivy.uix.floatlayout import FloatLayout
 9 from math import cos, sin, pi, radians
10 from kivy.clock import Clock
11 from kivy.lang import Builder
12 
13 import datetime
14 
15 #------------------------------------------------------
16 #from plyer import compass  # これがダメだったわけですよ
17 from my_compass import AndroidCompass
18 compass = AndroidCompass()
19 #------------------------------------------------------
20 
21 # kv は kv ファイルとして外に書くことも出来るがここでは内部で。
22 kv = '''
23 #:import math math
24 
25 <MyClockWidget>:
26     face: face
27     ticks: ticks
28     FloatLayout:
29         id: face
30         size_hint: None, None
31         pos_hint: {"center_x": 0.5, "center_y": 0.5}
32         size: 0.9 * min(root.size), 0.9 * min(root.size)
33         canvas:
34             Color:
35                 rgb: 0.1, 0.1, 0.1
36             Ellipse:
37                 size: self.size     
38                 pos: self.pos
39     Ticks:
40         id: ticks
41         r: min(root.size) * 0.9 / 2
42 '''
43 Builder.load_string(kv)
44 
45 class MyClockWidget(FloatLayout):
46     pass
47 
48 class Ticks(Widget):
49     def __init__(self, **kwargs):
50         super(Ticks, self).__init__(**kwargs)
51         compass.enable()
52         Clock.schedule_interval(self.update_clock, 1/5.)
53 
54     def update_clock(self, df):
55         magnorth = compass.orientation[0]
56         if magnorth is None:
57             return
58 
59         # clockwise from north
60         magnorth = radians(360 - compass.orientation[0])
61 
62         self.canvas.clear()
63         with self.canvas:
64             Color(0.7, 0.2, 0.2)
65             Line(points=[
66                     self.center_x, self.center_y,
67                     self.center_x + 0.8 * self.r * sin(magnorth),
68                     self.center_y + 0.8 * self.r * cos(magnorth)
69                     ], width=3, cap="round")
70 
71 class MyClockApp(App):
72     def build(self):
73         clock = MyClockWidget()
74         return clock
75 
76 if __name__ == '__main__':
77     MyClockApp().run()

以上 3つのファイルを例えば /sdcard/kivy/compass_g(/storage/emulated/0/kivy/compass_g) に置くと、kivyLauncher に「Graphical Compass Demo」が現れるはず。起動するとこんなシンプルなコンパスが:

シンプル過ぎてキャプチャする価値もない…って?

ちなみに…。作ろうとしてる本当にワタシが欲しいコンパスは「ただのコンパス」ではなくて、日出日没の情報がくっついたもの。というかもう作ってあるんだけどね。そっちの画面はだいたいこんな:

赤線が「Magnetic North」(磁北)、青線が「True North」(真北)、黄色が日の出の方位と時刻、紫色が日没の方位と時刻ね。てなわけでほんとに練習兼ねた「自分さえ良ければいい」ってもんであって、こんなん誰が嬉しいんだ、とも思うけれど、少しは整理出来たらコードはお披露目します、そのうち。「何か作りたい」人は嬉しいでしょ、きっと。