Androidのメモとか

ポキオの日記です。今日も遅延してない。

Arduinoと3Dプリンタで4灯式信号機を作る

父は減速現示が好き。

ポキオ Arduino NeoPixel 4灯式信号機

4歳息子「信号機がほしい」

息子が日本信号の鉄道用信号機のガチャガチャを見てから、信号機がほしいと言って聞かないわけです。

まぁ、無いなら作ればいい。ということで、今回は4灯式信号機を作ってみます。

www.tomytec.co.jp

イメージはこんな感じ。進行、減速、注意、停止の現示が可能な信号機を作ります。

3Dプリンタでそれっぽい信号をつくる

なにはともあれ、信号っぽいガワを作っていきます。

ポキオ Arduino NeoPixel 4灯式信号機

Fusion 360で、信号機本体にひさしをくっつけて、それっぽくしていきます。今回は、めんどくさいので信号機の裏側や支柱などは作らないでおきます。

光源はNeoPixel

今回使用する光源はNeoPixelのLEDテープを使います。

ポキオ Arduino NeoPixel 4灯式信号機

普通のLEDを使ってもいいんですが、抵抗を挟んだりするのが面倒なのと、配線も複雑になるので、今回はこのNeoPixel系のLEDテープを使います。これだと、GND、電源、信号線の3つだけ繋げば動作させる事ができます。

全部合体させるよー

こんな感じです。

ポキオ Arduino NeoPixel 4灯式信号機

LEDテープはLED4つ分のところでハサミで切って使用しています。GND・電源・信号の線をArduinoのピンに接続していきますが、信号の線は5Vピンに近いA0ピンにさしています。

NeoPixelの特徴でもあるんですが、色をRGBで指定するため、赤は良いとしても、黄色と青(緑)色は現物を見ながら調整しています。

// 黄色
255, 128, 0

// 青色
64, 255, 0

一旦は上記のようなRGB値にしています。

光らせた様子はこちら

光を拡散させたかったので、透明のフィラメントでレンズを作り、LED部分を覆っています。

ポキオ Arduino NeoPixel 4灯式信号機

いい感じいい感じ。

ポキオ Arduino NeoPixel 4灯式信号機

信号機を綿棒の箱に輪ゴムでくっつけて、早速息子氏に遊ばせてみました。2.5秒ごとに信号が節操なく変わるようにプログラミングしてしまったので、若干ビジーかなと思ったんですが、息子的には代わる代わる信号が変わって面白そうな様子でした。

4歳息子「中継信号機と踏切がほしい」

ひとしきり遊んだあとに、息子氏が「中継信号機と踏切も」と言い出したので、おいおいそちらも着手しようと思います(笑)

コードはこちら

#include <Adafruit_NeoPixel.h>

Adafruit_NeoPixel pixels = Adafruit_NeoPixel(4, A0, NEO_GRB + NEO_KHZ800);

void setup() {
  pixels.begin();
}

void loop() {
  for(int i = 0; i < 4; i++){
    switchSignal(i);
    delay(2500);
  }
}

// 0:停止 1:注意 2:減速 3:進行
void switchSignal(int i){
  long light1[4] = {pixels.Color(0, 0, 0),pixels.Color(0, 0, 0),pixels.Color(255, 128, 0),pixels.Color(0, 0, 0)};
  long light2[4] = {pixels.Color(255, 0, 0),pixels.Color(0, 0, 0),pixels.Color(0, 0, 0),pixels.Color(0, 0, 0)};
  long light3[4] = {pixels.Color(0, 0, 0),pixels.Color(255, 128, 0),pixels.Color(0, 0, 0),pixels.Color(0, 0, 0)};
  long light4[4] = {pixels.Color(0, 0, 0),pixels.Color(0, 0, 0),pixels.Color(64, 255, 0),pixels.Color(64, 255, 0)};

  pixels.setPixelColor(0, light4[i]);
  pixels.setPixelColor(1, light3[i]);
  pixels.setPixelColor(2, light2[i]);
  pixels.setPixelColor(3, light1[i]);
  pixels.show();
}

Sonos Roamことはじめ

ろーーーむ!

ポキオ Sonos Roam

Sonos Roamとは

オーディオメーカーSonosから発売されたポータブルスピーカーというやつです。

www.sonos.com

以前参加したSonosのイベントを発端に、だんだんSonosにハマってきてしまったわけですね。はい。

relativelayout.hatenablog.com

今回のRoamは最近日本で発売されたばかりなんですが…。

www.bcnretail.com

ヨドバシさんで先行販売らしく…。

ポキオ Sonos Roam

ほう…、オンラインストアでも買えるのね…。

ポキオ Sonos Roam

で、気づいたら自宅にあった、というわけです。

ひとまずUnboxing

箱に布製の「とって」みたいなものが付いてるんですよね。

ポキオ Sonos Roam

波平さんみたいで可愛いですよね(そこじゃない)。

ポキオ Sonos Roam

箱の中はこんな感じ。毎度おなじみの不織布で包まれています。

ポキオ Sonos Roam

不織布の中には、この色白の子。

ポキオ Sonos Roam

裏側にはType-Cの充電ポートと、電源ボタンっぽいやつ。

ポキオ Sonos Roam

操作系はすべてスピーカーの上側(?)にまとまっています。ボタン類は他のSonos製品と同じような感じで、UXも同じような感じなので、マニュアルを読まずに使い始められるのがいいですね。

ポータブルスピーカーを家の中で?

Roamはポータブルスピーカーであって、Wi−FiだけでなくBTにも対応しているのが特異的な部分であることは承知をしていますが、ひとまず家の中で使ってみます。あくまでもSonosのスピーカーであることには変わりないので、Wi−Fi接続をして他のSonosスピーカーと連携することもできるのがすごいところです。

ポキオ Sonos Roam

こちらは弊家のリビングの間取りと、既存のSonosスピーカーたち。普段は3つのOneを連携させて音楽を聞いています。(訳あって、PlaybarはTVと接続し、独立して使っています)

ポキオ Sonos Roam

まずはWFH中の音楽鑑賞。手元にRoamを置いて、3つのOneとRoamを連携させて音楽を聞いてみました。

ポキオ Sonos Roam

もともとWFHしている場所が部屋の端によっているため、最適なリスニングポジションではなかったものの、手元にRoamを置いたことで、

  • Roamのおかげで音楽、とくにボーカルの解像度が上がったような感じ
  • Oneが3つあることによる包まれてる感じ

上の2つがうまい塩梅で両立しているような環境となりました。また、コンパクトなRoamのおかげで、机の上に置いても邪魔にならない感じが非常に良いです。

ポキオ Sonos Roam

次に同様にOne 3つと連携させたまま、Roamをキッチンに持っていってみます。というのも、間取りの関係で、リビングの3つのOneの音楽がキッチンでは聞きづらいと常々感じていたものの、キッチンのためだけにOneを増設するのも微妙かなと思っていたわけです。

ポキオ Sonos Roam

Roamはバッテリー内蔵で、先述の通りコンパクト。しかも縦置きもできるので、我が家の狭いキッチンでも置き場に困りません。そして、コンパクトなのにパワフルな音を出せるので、意外と音がうるさい料理中もしっかり音楽に没頭(料理も頑張ってますが)できました。リビングに行っても、キッチンに戻っても、シームレスに音楽が聞ける体験は、本当に素晴らしいですねぇ。そして、必要なときだけ持っていけるのは、ポータブルスピーカーならではのメリットですね。

というわけで

  • My New Gear : Sonos Roam
  • ポータブルスピーカーだけど、まだ家の中でしか使ってないよ
  • でも、家の中でも機動性を活かして、いろいろ使えるすごいやつ
  • 既存のSonosシステムと連携ができる
  • コンパクトだけどいい音

サマるとこんな感じでしょうか。屋外でのレビューはまた今度!

娘の夏休みの宿題のデイリー進捗を可視化してみた

見える化

ポキオ Arduino Todo カンバン Adafruit Neopixel

意外と多いタスク

小1の娘は初めての長い夏休みを堪能しつつ、初めての夏休みの宿題をこなしています。意外と学校の宿題も多く大変そうだなぁと思いつつ、親がいちいち「あれやった?」みたいなポーリングによる進捗確認は工数だけがかかる作業なのであまりしたくありません。

そこで、デイリーでこなすべき宿題の進捗を可視化できるガジェットを作ってみました。

Arduinoで作っていきます

イメージとしては光り物で、タスクが一覧できる物理的なカンバンを作って、タスクごとに光らせるんですが、タスクが終わったらボタンを押して光っている色を変える感じ。

ポキオ Arduino Todo カンバン Adafruit Neopixel

とりあえず家に転がってたArduino Mega互換ボードに、NeopixelのLEDとスイッチを接続。スイッチはプルアップとして使うので、スイッチはGPIOとGNDにつなげます。Neopixelは信号線を1つでも良かったのですが、無心で作ってたので、複数信号線を作ってしまいました…(笑)

ポキオ Arduino Todo カンバン Adafruit Neopixel

さらに、3DプリンタでLEDを覆うようなパーツを作って・・・。

ポキオ Arduino Todo カンバン Adafruit Neopixel

これらをすべてOSBボードに打ち付けて、できました。

動作の様子はこちら

起動直後はすべてのタスクが赤く光ります。

ポキオ Arduino Todo カンバン Adafruit Neopixel

タスクを終えて、ボタンを押すと赤→青になります。

ポキオ Arduino Todo カンバン Adafruit Neopixel

全部のタスクが終わると、白色点滅。

全クリ時に、おめでとう感を出していくスタイルです。

導入してみてどうだった?

普段はWFHな私ですが、仕事中も娘の進捗が見えて非常によかったです。また、娘も達成感を味わえるようで、進んでArduinoの電源を入れて宿題に取り組んでました。

とりあえず、大成功?

コードはこんな感じ

#define使えよ…(笑)

#include <Adafruit_NeoPixel.h>

int b1 = 0;
int b2 = 15;
int b3 = 17;
int b4 = 19;
int b5 = 21;

int l1 = 12;
int l2 = 10;
int l3 = 5;
int l4 = 8;
int l5 = 7;

Adafruit_NeoPixel pixels1 = Adafruit_NeoPixel(2, l1, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels2 = Adafruit_NeoPixel(2, l2, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels3 = Adafruit_NeoPixel(2, l3, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels4 = Adafruit_NeoPixel(2, l4, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel pixels5 = Adafruit_NeoPixel(2, l5, NEO_GRB + NEO_KHZ800);

bool stat1 = false;
bool stat2 = false;
bool stat3 = false;
bool stat4 = false;
bool stat5 = false;

void setup() {
  Serial.begin(9600);

  pinMode(b1, INPUT_PULLUP);
  pinMode(b2, INPUT_PULLUP);
  pinMode(b3, INPUT_PULLUP);
  pinMode(b4, INPUT_PULLUP);
  pinMode(b5, INPUT_PULLUP);

  pixels1.begin();
  pixels2.begin();
  pixels3.begin();
  pixels4.begin();
  pixels5.begin();

  pixels1.setBrightness(64);
  pixels2.setBrightness(64);
  pixels3.setBrightness(64);
  pixels4.setBrightness(64);
  pixels5.setBrightness(64);
}

void loop() {
  stat1 = stat1 || (digitalRead(b1) == LOW);
  stat2 = stat2 || (digitalRead(b2) == LOW);
  stat3 = stat3 || (digitalRead(b3) == LOW);
  stat4 = stat4 || (digitalRead(b4) == LOW);
  stat5 = stat5 || (digitalRead(b5) == LOW);

  if (stat1) {
    pixels1.setPixelColor(0, pixels1.Color(0, 0, 255));
    pixels1.setPixelColor(1, pixels1.Color(0, 0, 255));
  } else {
    pixels1.setPixelColor(0, pixels1.Color(255, 0, 0));
    pixels1.setPixelColor(1, pixels1.Color(255, 0, 0));
  }

  if (stat2) {
    pixels2.setPixelColor(0, pixels1.Color(0, 0, 255));
    pixels2.setPixelColor(1, pixels1.Color(0, 0, 255));
  } else {
    pixels2.setPixelColor(0, pixels1.Color(255, 0, 0));
    pixels2.setPixelColor(1, pixels1.Color(255, 0, 0));
  }

  if (stat3) {
    pixels3.setPixelColor(0, pixels1.Color(0, 0, 255));
    pixels3.setPixelColor(1, pixels1.Color(0, 0, 255));
  } else {
    pixels3.setPixelColor(0, pixels1.Color(255, 0, 0));
    pixels3.setPixelColor(1, pixels1.Color(255, 0, 0));
  }

  if (stat4) {
    pixels4.setPixelColor(0, pixels1.Color(0, 0, 255));
    pixels4.setPixelColor(1, pixels1.Color(0, 0, 255));
  } else {
    pixels4.setPixelColor(0, pixels1.Color(255, 0, 0));
    pixels4.setPixelColor(1, pixels1.Color(255, 0, 0));
  }

  if (stat5) {
    pixels5.setPixelColor(0, pixels1.Color(0, 0, 255));
    pixels5.setPixelColor(1, pixels1.Color(0, 0, 255));
  } else {
    pixels5.setPixelColor(0, pixels1.Color(255, 0, 0));
    pixels5.setPixelColor(1, pixels1.Color(255, 0, 0));
  }

  pixels1.show();
  pixels2.show();
  pixels3.show();
  pixels4.show();
  pixels5.show();

  if (stat1 && stat2 && stat3 && stat4 && stat5) {
    congrats();
  }
}

void congrats() {
  for (int i = 0; i < 100; i++) {
    setColor(255, 255, 255);
    delay(500);
    setColor(0, 0, 0);
    delay(500);
  }

  setColor(255, 255, 255);
  while (1) {}
}

void setColor(int r, int g, int b) {
  pixels1.setPixelColor(0, pixels1.Color(r, g, b));
  pixels1.setPixelColor(1, pixels1.Color(r, g, b));
  pixels2.setPixelColor(0, pixels1.Color(r, g, b));
  pixels2.setPixelColor(1, pixels1.Color(r, g, b));
  pixels3.setPixelColor(0, pixels1.Color(r, g, b));
  pixels3.setPixelColor(1, pixels1.Color(r, g, b));
  pixels4.setPixelColor(0, pixels1.Color(r, g, b));
  pixels4.setPixelColor(1, pixels1.Color(r, g, b));
  pixels5.setPixelColor(0, pixels1.Color(r, g, b));
  pixels5.setPixelColor(1, pixels1.Color(r, g, b));

  pixels1.show();
  pixels2.show();
  pixels3.show();
  pixels4.show();
  pixels5.show();
}

Android 12でBluetoothは位置情報権限の呪縛から解かれる

久々にAndroidの話。

ポキオ Android 12 BLUETOOTH_SCAN BLUETOOTH_CONNECT permission

tl:dr

  • いままではBluetoothの利用には位置情報の権限が必要でした
  • Android 12からBluetooth専用の権限が新設され、位置情報の権限は不要になります

developer.android.com

忌まわしき位置情報の権限

Androidの話なので、iOSユーザーの方はポカーンって感じかもしれないですが。

ポキオ Android 12 BLUETOOTH_SCAN BLUETOOTH_CONNECT permission

Androidはユーザーのプライバシーを守るために、Android 6からRuntime Permissionsを実装し、アプリがスマホの一部のリソースを使う前に、アプリがユーザーに対して権限の付与を尋ねるという機能が入りました。

たとえば、地図アプリや、アプリでどこかのスポットにチェックインしたりするときなど、アプリがスマホの位置情報をアクセスする際は、予めユーザーに対して位置情報へのアクセスを許可してもらう必要があり、許可がおりて初めて位置情報にアクセスできるようになります。

ただし、ここでポイントなのは、Bluetooth(BLE)やWi−Fiのスキャンをする際も位置情報アクセスの権限取得が何故か必要ということです。Android側のロジックとしては、BLEビーコンやWi−Fiのアクセスポイントのスキャン結果とそれぞれの電波強度から位置情報が推測できるため、そのような制約を設けていると思われるのですが、アプリを使っているユーザーからは、そんな内部の都合なんて理解できるはずがありません。

結果として、アプリとしては位置情報がほしいわけでもないのにBLEやWi− Fiを使う際は、ユーザーに対して位置情報へのアクセス権限を求めざるを得ず、ユーザーにとってみれば、なぜ位置情報へのアクセスを求められているのかがわからず、変に怪しまれるということが多々あったわけです。

ようやくAndroid 12で仕様が変わります

この状況が2021年秋にリリースされるAndroid 12で大きくかわります。

ポキオ Android 12 BLUETOOTH_SCAN BLUETOOTH_CONNECT permission

Android 12では、新たにBluetooth利用のための権限が2つ新設されます。

これにより、今までAndroidManifest.xmlで、

 <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

のように宣言していた位置情報アクセスの権限でしたが、BLEのスキャンと接続をするだけであれば、上記の権限は不要で、

<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
                 android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

というように、専用の権限利用の宣言だけをすれば良くなりました。ユーザーの見た目的にもBLE利用の際に求められる権限は、

Allow [an app] to find, connect to, and determine relative position of nearby devices.

のような、わかりやすい理由で権限を求められるようになります。

戦いはこれで終わりなのか?

というわけで、新しいOSでは、BLE利用と位置情報利用のための権限が分かれたことで、ユーザーフレンドリーな仕様に一歩近づいたわけです。

ポキオ Android 12 BLUETOOTH_SCAN BLUETOOTH_CONNECT permission

ただし、これはあくまでもAndroid 12のデバイスで動作するアプリの話であって、依然として大多数を占めるAndroid 11以下のデバイスでは関係ない話です。

なので、アプリデベロッパーとしてはぬか喜びすることなく、当面の間はAndroid 12で増えた権限のハンドリングをしつつ、Android 11以下のデバイスを考慮した設計が必要になりそうです。悲しいかな、デベロッパーの諸兄姉は目をつぶっていてもif文を無限にコーディングできるスキルを持っているはずなので、容易い話かと思いますが。

一方でユーザーも、急にアプリが位置情報の権限を要求してきても焦らずに、許可・不許可をする必要があります。もしかしたら、Bluetoothを使いたいだけなのに位置情報の権限を要求しているのかもしれません。(まぁ、そういうときは、アプリはその旨をユーザーに伝えるべきですが)位置情報に限らずそのような権限はあとからでも不許可に倒すことも可能なので、脳死で許可・不許可をせずに、冷静に考えて決めてほしいなと願っています。

でも、あんまり不許可にしないでね!もしかしたらバグるかもしれないから!(笑)

Androidアプリ開発 74のアンチパターン

Androidアプリ開発 74のアンチパターン

  • 作者:深見 浩和
  • 発売日: 2017/09/27
  • メディア: 単行本(ソフトカバー)

テレワーク用の椅子(5脚目)

ついに5脚目。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

テレワーク用の椅子の紆余曲折

いままで色々試してきました。

relativelayout.hatenablog.com

結局、最後に買った「ポンコタン ハイバックチェア」が一番しっくり来ていました。

なんですが、毎日座ってたせいで、ファブリックの縫い目がほつれ始めてきていました。

コストコ怪しいハイバックチェアが・・・

で、コストコでなにやらコスパの良さそうなハイバックチェアが売っていました。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

すぐにカートに入れちゃう癖やめたいところですが、なんと約3500円。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

きっと本国のメリケン人が乗っても壊れないんだろうなとか思うと、耐久性も期待できそうだったので早速購入。

Unboxingと組み立て

ポンコタンのハイバックチェアと同じように収納バッグに入ってて、非常にコンパクト。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

で、ひとまず開封。骨組みを組み立てるところまでは良かったが・・・。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

ファブリックの座面を骨組みに取り付けるのが本当に大変。未使用でファブリックが伸びてないのか、なかなか骨組みに入らず10分ほど格闘。最後はなんとか入りましたが。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

ちなみに、足はこんなタイプ。地面が砂地などでも潜り込まないような構造になっていますが、ポンコタンと異なり、この足は取り外しができないようです。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

で、完成。

座り心地は?

まず、ファーストインプレッションとして、

(ポンコタンのハイバックチェアと比べると…)

  • 背もたれは低めで、ハイバックというかミッドバック。
  • 左右方向のホールド感は低め。
  • 前後方向のホールド感は低めで、浅め。
  • 座面はちょっとだけ高め。
  • 大きめのヘッドレストがついてて快適。
  • 背もたれ上部に、左右方向に伸びる骨が入ってて安定感がある。

僕も割とお尻が大きい方ですが、それでも余裕があるので、ポンコタンより欧米寄りというのがわかります。とまぁ、色々差異はあるものの、なかなかの座り心地。耐久性の評価はこれからですが、すぐに壊れてもコストコなので返品しちゃえばいいしね。そう考えても3500円というのはコスパ高いですね。

ポキオ カスケード Cascade Mountain Tech Compact High Back Aluminum Chair

というわけで、カスケードマウンテンテックのハイバックチェアでした。次こそはHelinox買うん・・・だ・・・!

バチ抜けをNode-REDで地図に可視化する

つりはっく。

ポキオ Node-RED WorldMap バチ抜け

バチ抜けとは

釣り用語の一つです。

pokio-ringyo.hatenablog.com

pokio-ringyo.hatenablog.com

バチとはイソメやゴカイなどの環形動物のことを指し、バチ抜けとは春先の大潮の夜に海底に潜んでいるバチが一気に浮いてくる現象です。抜けたバチは種々の魚にとってはごちそうであって、バチを求めて魚が寄ってくるというわけです。バチ抜けは地域によって発生する時期がまちまちで、更に魚が寄ってくるかもわからない自然のイベントとなっているわけで、いつどのへんでバチ抜けが起こったのかを知りたいのは釣り人の性みたいなもの。

ポキオ輪業商会 横浜 メバリング シーバス プラッキング

今回は、Twitterで位置情報とともにつぶやかれている情報をもとに、バチ抜けなどの情報を地図上にマッピングしてみようと思います。

使うのはNode-RED

今回もNode-REDで実装していきます。フローはこんな感じ。

ポキオ Node-RED WorldMap バチ抜け

ポイントとなるノードはこちら。

Twitterノード

これでTwitter上の情報をほぼリアルタイムにスキャンして、指定したハッシュタグのツイートを引っ張ってきます。実際にツイートが見つかると、おそらくTwitter本家のTweet Objectの形式でデータが取得できます。

developer.twitter.com

ただ、仕様が複雑(笑)今回は、次のFunctionノードでテキトーに位置情報をパースします。

Functionノード

ここで、Twitterノードから取得したデータから位置情報をパースします。

if(msg.tweet && msg.tweet.place && msg.tweet.place.bounding_box && msg.tweet.place.bounding_box.coordinates){
    var longitude = 0;
    var latitude = 0;
    var array = msg.tweet.place.bounding_box.coordinates[0];
    var tweet = msg.payload.replace(/\r?\n/g,"");
    var link = 'https://twitter.com/' + msg.tweet.user.screen_name + '/status/' + msg.tweet.id_str;

    array.forEach(value => {
        longitude += value[0];
        latitude += value[1];
    });
    
    longitude /= array.length;
    latitude /= array.length;
    
    msg = {};
    msg.has_location = true;
    msg.payload = {
        "lat" : latitude,
        "lon" : longitude,
        "name" : tweet,
        "weblink" : link,
    };
}
return msg;

tweet.place.bounding_box.coordinates内の座標の平均値をとって、それを緯度経度として採用しています。後段の処理でCSVファイルに保存するため、Tweet本文は改行を削除してから保存しています。

CSVノード・Fileノード

前段の処理で、位置情報がパースできたTweetだけをCSVとして一旦保存しておきます。

WorldMapノード

今回の要。

flows.nodered.org

node-red-contrib-web-worldmapをインストールして、地図を表示できるようになります。フロー中にこのWorldMapノードがあると、特定のURLにアクセスすると地図が表示されるようになります。このノードに対して緯度経度などをくべると、地図上にピンが表示されるようになります。今回は、WorldMapにアクセスがあったときに発火するイベントを契機に、CSVを読み込み、そこに書かれた位置情報をすべてWorldMapにマッピングします。

これをつかって地図表示させてみました

こんな感じ。

ポキオ Node-RED WorldMap バチ抜け

まだまだTwitterの監視をし始めたばかりなのでピンはすくなめですが、ちゃんと可視化できています。

ちょっとずつピンが増えてくると楽しいですねぇ。

フローはこちら

[
    {
        "id": "add2a0f2.b28d2",
        "type": "twitter in",
        "z": "52e43921.021f08",
        "twitter": "",
        "tags": "バチ抜け,シーバス,#バチ抜け,#シーバス",
        "user": "false",
        "name": "バチ抜けツイート",
        "inputs": 0,
        "x": 130,
        "y": 300,
        "wires": [
            [
                "643d563.f2d9fa8",
                "1e92e179.29a17f"
            ]
        ]
    },
    {
        "id": "efe29703.79f498",
        "type": "comment",
        "z": "52e43921.021f08",
        "name": "ツイート監視",
        "info": "",
        "x": 110,
        "y": 260,
        "wires": []
    },
    {
        "id": "643d563.f2d9fa8",
        "type": "function",
        "z": "52e43921.021f08",
        "name": "位置情報パース",
        "func": "if(msg.tweet && msg.tweet.place && msg.tweet.place.bounding_box && msg.tweet.place.bounding_box.coordinates){\n    var longitude = 0;\n    var latitude = 0;\n    var array = msg.tweet.place.bounding_box.coordinates[0];\n    var tweet = msg.payload.replace(/\\r?\\n/g,\"\");\n    var link = 'https://twitter.com/' + msg.tweet.user.screen_name + '/status/' + msg.tweet.id_str;\n\n    array.forEach(value => {\n        longitude += value[0];\n        latitude += value[1];\n    });\n    \n    longitude /= array.length;\n    latitude /= array.length;\n    \n    msg = {};\n    msg.has_location = true;\n    msg.payload = {\n        \"lat\" : latitude,\n        \"lon\" : longitude,\n        \"name\" : tweet,\n        \"weblink\" : link,\n    };\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 340,
        "y": 300,
        "wires": [
            [
                "8649b0d9.a5f98"
            ]
        ]
    },
    {
        "id": "dddeedd9.5a74b",
        "type": "worldmap",
        "z": "52e43921.021f08",
        "name": "",
        "lat": "",
        "lon": "",
        "zoom": "",
        "layer": "",
        "cluster": "",
        "maxage": "",
        "usermenu": "show",
        "layers": "show",
        "panit": "false",
        "panlock": "false",
        "zoomlock": "false",
        "hiderightclick": "false",
        "coords": "none",
        "showgrid": "false",
        "allowFileDrop": "false",
        "path": "/worldmap",
        "x": 1060,
        "y": 460,
        "wires": []
    },
    {
        "id": "8649b0d9.a5f98",
        "type": "switch",
        "z": "52e43921.021f08",
        "name": "位置情報があるときだけ動作",
        "property": "has_location",
        "propertyType": "msg",
        "rules": [
            {
                "t": "true"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 1,
        "x": 580,
        "y": 300,
        "wires": [
            [
                "6d46063b.e4bce8"
            ]
        ]
    },
    {
        "id": "433fb432.0e49fc",
        "type": "function",
        "z": "52e43921.021f08",
        "name": "ズーム",
        "func": "msg.payload = {\n    command : {\n        lat : msg.payload.lat,\n        lon : msg.payload.lon,\n        zoom : 6,\n    }\n}\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 930,
        "y": 480,
        "wires": [
            [
                "dddeedd9.5a74b"
            ]
        ]
    },
    {
        "id": "75d4e561.cf315c",
        "type": "worldmap in",
        "z": "52e43921.021f08",
        "name": "",
        "path": "/worldmap",
        "events": "all",
        "x": 100,
        "y": 460,
        "wires": [
            [
                "493cf6af.c9d1d8"
            ]
        ]
    },
    {
        "id": "493cf6af.c9d1d8",
        "type": "switch",
        "z": "52e43921.021f08",
        "name": "",
        "property": "payload.action",
        "propertyType": "msg",
        "rules": [
            {
                "t": "eq",
                "v": "connected",
                "vt": "str"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 1,
        "x": 250,
        "y": 460,
        "wires": [
            [
                "6381668a.b712c8"
            ]
        ]
    },
    {
        "id": "6d46063b.e4bce8",
        "type": "csv",
        "z": "52e43921.021f08",
        "name": "CSVに変換",
        "sep": ",",
        "hdrin": "",
        "hdrout": "",
        "multi": "one",
        "ret": "\\n",
        "temp": "lat,lon,name,weblink",
        "skip": "0",
        "strings": false,
        "x": 810,
        "y": 300,
        "wires": [
            [
                "96084250.d8c06",
                "23921cd8.841d44"
            ]
        ]
    },
    {
        "id": "96084250.d8c06",
        "type": "file",
        "z": "52e43921.021f08",
        "name": "history.csvに保存",
        "filename": "history.csv",
        "appendNewline": true,
        "createDir": false,
        "overwriteFile": "false",
        "encoding": "none",
        "x": 1010,
        "y": 300,
        "wires": [
            []
        ]
    },
    {
        "id": "23921cd8.841d44",
        "type": "debug",
        "z": "52e43921.021f08",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "x": 970,
        "y": 340,
        "wires": []
    },
    {
        "id": "3b137fe3.26fa",
        "type": "csv",
        "z": "52e43921.021f08",
        "name": "",
        "sep": ",",
        "hdrin": "",
        "hdrout": "",
        "multi": "one",
        "ret": "\\n",
        "temp": "lat,lon,name,weblink",
        "skip": "0",
        "strings": true,
        "x": 630,
        "y": 460,
        "wires": [
            [
                "5786ae6a.d8e1"
            ]
        ]
    },
    {
        "id": "6381668a.b712c8",
        "type": "file in",
        "z": "52e43921.021f08",
        "name": "history.csvを読み込み",
        "filename": "history.csv",
        "format": "utf8",
        "chunk": false,
        "sendError": false,
        "encoding": "none",
        "x": 440,
        "y": 460,
        "wires": [
            [
                "3b137fe3.26fa"
            ]
        ]
    },
    {
        "id": "5786ae6a.d8e1",
        "type": "function",
        "z": "52e43921.021f08",
        "name": "weblink作成",
        "func": "const weblink = {\n    \"name\" : \"Twitter\",\n    \"url\" : msg.payload.weblink,\n    \"target\":\"_new\"\n}\n\nmsg.payload.weblink = weblink;\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 790,
        "y": 460,
        "wires": [
            [
                "dddeedd9.5a74b",
                "433fb432.0e49fc"
            ]
        ]
    },
    {
        "id": "cddaa0e7.caea7",
        "type": "comment",
        "z": "52e43921.021f08",
        "name": "Mapにアクセスがあったとき",
        "info": "",
        "x": 160,
        "y": 420,
        "wires": []
    },
    {
        "id": "1e92e179.29a17f",
        "type": "debug",
        "z": "52e43921.021f08",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "x": 310,
        "y": 340,
        "wires": []
    }
]

Node-REDをつかってGoogle Home Miniに時報を喋らせる

テレワークに便利。

ポキオ Google Home Mini 時報 Node-RED

tl;dr

  • Google Homeの「ルーティーン」を使ってもできる
  • 今回はNode-REDから時報を喋らせる
  • 定期的にタスクを実行して、喋らせたい文字列をGoogle Home MiniにCastするだけ

テレワークのタイムマネジメント

いうほどできてないんですが。ついうっかりミーティングの存在を忘れたりしがちなので、以前から1時間おきにGoogle Home Miniに時報を喋らせてました。

support.google.com

ルーティーンという機能をつかって、設定した時間に「OK, google. 今何時?」という問いを内部的に投げて、その答えをGoogle Home Miniから発話させることで、時報として活用していました。ただ、最近設定した時間の±1分くらいに発火する謎の病にかかってしまい、代替手段を探していました。で、今回はその方法としてNode-REDからGoogle Home Miniに対して時報を喋らせてみようと思います。

フローは至ってシンプル

最初にフローから。

ポキオ Google Home Mini 時報 Node-RED

こんな感じ。

ポキオ Google Home Mini 時報 Node-RED

まずはInjectionノードで定期実行をトリガー。「おいおい、真夜中に発火させてどうした?」みたいなこと思うかもしれませんが、Node-REDが動作しているRaspberry PiタイムゾーンがUKのままなので、運用でカバーしています・・・(笑)良い子は真似しないでね。

次のFunctionノードで発話させるメッセージを作成しています。こちらでもタイムゾーンをゴニョゴニョしてます。

const date = new Date(Date.now() + ((new Date().getTimezoneOffset() + (9 * 60)) * 60 * 1000));
msg.message = "現在の時刻は、だいたい" + date.getHours() + "時です。";
return msg;

そして、実際の発話はnode-red-contrib-castノードで実行しています。

flows.nodered.org

文字通り、Castを行うノードですが、対象のIPアドレスGoogle Home MiniのIPアドレスにすると、msg.messageのStringを発話してくれます。

実際のフローはこちら

[
    {
        "id": "3e94927e.7c6d7e",
        "type": "cast-to-client",
        "z": "36b47ac9.340b66",
        "name": "",
        "url": "",
        "contentType": "",
        "message": "",
        "language": "ja",
        "ip": "192.168.1.31",
        "port": "",
        "volume": "50",
        "x": 630,
        "y": 120,
        "wires": [
            []
        ]
    },
    {
        "id": "fdae59a3.98caa8",
        "type": "inject",
        "z": "36b47ac9.340b66",
        "name": "22:00-00:00",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "repeat": "",
        "crontab": "0 22-23 * * 1,2,3,4,5",
        "once": false,
        "onceDelay": 0.1,
        "x": 140,
        "y": 60,
        "wires": [
            [
                "88c5f7d4.a36758",
                "b569a844.e50c58"
            ]
        ]
    },
    {
        "id": "88c5f7d4.a36758",
        "type": "debug",
        "z": "36b47ac9.340b66",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "x": 370,
        "y": 60,
        "wires": []
    },
    {
        "id": "b569a844.e50c58",
        "type": "function",
        "z": "36b47ac9.340b66",
        "name": "メッセージ作成",
        "func": "const date = new Date(Date.now() + ((new Date().getTimezoneOffset() + (9 * 60)) * 60 * 1000));\nmsg.message = \"現在の時刻は、だいたい\" + date.getHours() + \"時です。\";\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 380,
        "y": 120,
        "wires": [
            [
                "3e94927e.7c6d7e"
            ]
        ]
    },
    {
        "id": "a93920ed.ddbc3",
        "type": "inject",
        "z": "36b47ac9.340b66",
        "name": "00:00-10:00",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "repeat": "",
        "crontab": "0 0-9 * * 1,2,3,4,5",
        "once": false,
        "onceDelay": 0.1,
        "x": 140,
        "y": 120,
        "wires": [
            [
                "88c5f7d4.a36758",
                "b569a844.e50c58"
            ]
        ]
    }
]
「Androidのメモとか」は、Amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、Amazonアソシエイト・プログラムの参加者です。

このブログは個人的なメモ書きであったり、考えを書く場所であります。執筆者の所属する団体や企業のコメントや意向とは無関係であります。また、このブログは必ずしも正しいことが書かれているとは限らず、誤字脱字や意図せず誤った情報を載せる場合がありえます。それが原因で読者が不利益を被ったとしても、執筆者はいかなる責任も負いません。ありがとうございます。