Hohoho... Santa is here!
Every year the Christmas Eve Santa Claus sets out on a journey travels around the globe and sends out presents to all the children around the world. But would it be cool to know where the Santa Claus is at that day?
MKR1000 Santa Tracker to the rescue!
You may already know there are two places where we can get Santa location information, one is from NORAD and one from Google. Although NORAD was the original organization who started the Santa tracking tradition, but Google provides a developer friendly (undocumented) API for the Santa tracking. With this API you will be able to track the real time Santa information including the location, arrival and departure time, presents sent at the location, the same as on the Google's Santa tracking web site. So in this project I chose to use Google Santa data to implement my Santa tracker.
The idea of this project is simple: Use LEDs to show where the Santa have been and the current location of Santa on a world map. Here is what I get at the end of the project:
System ArchitectureOverall the design take use of one raspberry PI and one MKR1000 to process and visualize the Santa data fetched from Google Santa Tracker API.
As you can see the data fetched from Google Santa Tracker API is first put though a Raspberry PI. The reason is that the API response JSON is around 20M which is too big to fit into MKR1000's memory (18M available after my sketch is loaded) for processing. So I'm using a Raspberry Pi 3 to first consume the data, and generate a much smaller data format which is tailer made for my application. The later data is then exposed through a REST API server hosted on Raspberry Pi. The MKR1000 board will call the REST API every 10 seconds to get the current Santa location.
Circuit DesignThe MKR1000 is connected to a custom PCB which has 30 WS2812B RGB NeoPixel LEDs. Each LED represents one geo location. The idea is blinking the closest LED to where the Santa's current location, and turn on all the LEDs on Santa's past locations.
The PCB is optimized for being produced by Othermill as a double sided PCB, but should be also easily produced through online PCB services like OSH Park.
I spread 30 NeoPixels on the PCB, forming a world map. You may not be able to see it right now, but after coating with the negative mask and diffuser it would be easier to recognize.
The main consideration here to make this home-milling-friendly is the location of vias. Since the vias are drilled on PCB plate both sides are not connected. So you will need to solder them together on both side through a wire. And because of that, the vias can not be put under a SMT component like they usually do in commercial PCBs.
There are some design considerations behind the PCB file:
- The power is delivered through a thick trunk wire and sinked into another thick ground wire. This is because 30 NeoPixels will draw quite a lot of current.
- The NeoPixels are connected as close as possible to the trunk power wire. This is to reduce voltage drop across multiple NeoPixels. Different voltage will result in slight difference in brightness and color.
- A 1000 uF capacitor is connected close to the power source. This is recommended best practice to work with NeoPixels.
- The vias are put outside of other SMT components because they will be connected by soldering both side manually, the solder joint will not fit under another SMT components. This wouldn't be a problem on PCBs produced by commercial services, but should take into consideration when make PCM in home.
- Make sure you leave enough space between wires. It will be easily shorted after soldering because the home made PCB don't have the insulation coat. This can be done in Otherplan application by setting the trace clearance to a larger value than the default 0.006in (e.g. 0.06 in should be good enough). My first fully soldered board was found not working because of short circuit, and it's to hard to fix than just make another one.
- If you are going to work with plenty of SMT components, a hot air rework station will save you tons of time on soldering. Although it's doable, it really doesn't worth the effort to hand-solder the components one by one. And hot air will also make the components align to the the exact position automatically.
Because the Othermill can mill directly from a SVG, I just use the world map found from wikimedia.org.
I also created another eagle file containing the drill holes. Those holes are used for mounting the mask to the circuit board.
Since I want to get highest possible milling precision, the best way to do that is using the alignment bracket. But the outline will definitely overlap with the bracket. The solution I found was first only cut the map without cutting the outline. Then remove the bracket without telling the software. Then start the milling just to drill holes and cut the outline. The software will use the same tool path to cut the outline at exactly position you want.
Assembly was pretty straightforward. I used 4 M3 Nylon long screws, 4 standoffs and 4 nuts.
The response from Google Santa Tracker API is a huge JSON file. This may be a problem for MKR1000, but not a problem at all for Raspberry Pi 3. So I set up a HTTP server to preprocess the JSON file and produce a smaller data for the MKR1000.
The Raspberry Pi server also maps the location directly to the index of the corresponding LED to further reduce the calculation on the MKR1000. To do this, I first manually assigned a coordinate to each of the LED, and then calculate the distance between each of the location in Santa's path to the LED coordinates, find the closest LED to represent that location.
The server is written in Python, and use the Flask web framework to expose the REST endpoints to the MKR1000.
from flask import Flask
import requests
import json
import math
import sys
app = Flask(__name__)
# Google's Santa API. Only updates on Dec 24.
# santa_api_url = 'https://santa-api.appspot.com/info?client=web&language=en&fingerprint=&routeOffset=0&streamOffset=0'
# My Fake Santa API.
santa_api_url = 'http://localhost:1224/info'
# LEDs metadata.
leds = [
{'name': 'North Pole', 'location': {'lat': 90.0, 'lng': 30.0}},
{'name': 'Alaska (US)', 'location': {'lat': 64.536117, 'lng': -151.258768}},
{'name': 'Alberta (Canada)', 'location': {'lat': 48.9202307, 'lng': -93.69738}},
{'name': 'Ontario (Canada)', 'location': {'lat': 50.956252, 'lng': -87.369255}},
{'name': 'Utah (US)', 'location': {'lat': 40.7765868, 'lng': -111.9905244}},
{'name': 'Tennessee (US)', 'location': {'lat': 36.1865589, 'lng': -86.9253274}},
{'name': 'Mexico City (Mexico)', 'location': {'lat': 19.39068, 'lng': -99.2836957}},
{'name': 'Bogota (Columbia)', 'location': {'lat': 4.6482837, 'lng': -74.2478905}},
{'name': 'Brasilia (Brazil)', 'location': {'lat': -15.721751, 'lng': -48.0082759}},
{'name': 'Santiago (Chile)', 'location': {'lat': -33.4727092, 'lng': -70.7699135}},
{'name': 'Greenland', 'location': {'lat': 70.8836652, 'lng': -59.6665893}},
{'name': 'UK', 'location': {'lat': 64.6748061, 'lng': -7.9869018}},
{'name': 'Spain', 'location': {'lat': 40.4379332, 'lng': -3.749576}},
{'name': 'Mali', 'location': {'lat': 17.5237416, 'lng': -8.4791157}},
{'name': 'Finland', 'location': {'lat': 64.6479136, 'lng': 17.1440256}},
{'name': 'Greece', 'location': {'lat': 38.2540419, 'lng': 21.56707}},
{'name': 'Libya', 'location': {'lat': 21.520733, 'lng': 23.237173}},
{'name': 'Central African Republic', 'location': {'lat': 6.2540984, 'lng': -0.2809593}},
{'name': 'Botswana', 'location': {'lat': -22.327399, 'lng': 22.4437318}},
{'name': 'Saudi Arabia', 'location': {'lat': 24.0593214, 'lng': 40.6158589}},
{'name': 'Turkmenistan', 'location': {'lat': 38.9423384, 'lng': 57.3349508}},
{'name': 'Xinjiang (China)', 'location': {'lat': 42.0304225, 'lng': 77.3185349}},
{'name': 'India', 'location': {'lat': 20.8925986, 'lng': 73.7613366}},
{'name': 'Henan (China)', 'location': {'lat': 33.8541479, 'lng': 111.2634555}},
{'name': 'Cambodia', 'location': {'lat': 12.2978202, 'lng': 103.8594626}},
{'name': 'Japan', 'location': {'lat': 34.452585, 'lng': 125.382845}},
{'name': 'Australia', 'location': {'lat': -25.0340388, 'lng': 115.2378468}},
{'name': 'New Zealand', 'location': {'lat': -43.0225411, 'lng': 163.4767905}},
{'name': 'South Pole', 'location': {'lat': -90.0, 'lng': 30.0}},
]
@app.route('/santa')
def santa():
santa_info = requests.get(santa_api_url).json()
santa_time = santa_info['now']
response = []
for dest_json in santa_info['destinations']:
if santa_time < dest_json['arrival']:
break
dist, led, led_index = closest_led(dest_json['location'])
response.append({
'i': led_index,
'd': int(dist),
'n': dest_json['city'],
'p': dest_json['presentsDelivered']
})
return app.response_class(json.dumps(response).replace(' ',''), content_type='application/json')
def distance(loc1, loc2, unit='M'):
lat1 = loc1['lat']
lng1 = loc1['lng']
lat2 = loc2['lat']
lng2 = loc2['lng']
radlat1 = math.pi * lat1 / 180
radlat2 = math.pi * lat2 / 180
theta = lng1-lng2
radtheta = math.pi * theta / 180
dist = (math.sin(radlat1) * math.sin(radlat2) +
math.cos(radlat1) * math.cos(radlat2) * math.cos(radtheta));
dist = math.acos(dist)
dist = dist * 180 / math.pi
dist = dist * 60 * 1.1515
if unit == 'K':
return dist * 1.609344
if unit == 'N':
return dist * 0.8684
return dist
def closest_led(loc):
min_dist = sys.float_info.max
min_led = None
min_index = 0
for index, led in enumerate(leds):
led_loc = led['location']
dist = distance(loc, led_loc)
if dist < min_dist:
min_dist = dist
min_led = led
min_index = index
return min_dist, min_led, min_index
if __name__ == '__main__':
app.run(host='0.0.0.0', port=2412)
Since the Google Santa Tracker API only updates on one day (Dec 24) in a year, in order to test the whole system, I also wrote a fake Santa tracker API server simulates the real one. With this fake API server, I can also control the speed of travel and reset as needed. This server is also a Flask Python server, run on a different port on the Raspberry Pi 3.
from flask import Flask, request
import json
import time
app = Flask(__name__)
fake_start_time = 0 # initialized to first arrival time from json
real_start_time = 0 # set to start time
speed_factor = 100 # fake clock speed
all_destinations = None
current_info = {
'status': 'OK',
'language': 'en',
'now': None, # Will be set to fake time
'timeOffset': 120000,
'fingerprint': '3b8835bc354c6d5018344b289b833402f7079844',
'refresh': 51449,
'switchOff': False,
'clientSpecific': {
'DisableEarth': False,
'DisableTracker': False,
'DisableWikipedia': False,
'DisablePhotos': False,
'HighResolutionPhotos': False,
'EarthAltitudeMultiplier': 1
},
'routeOffset': 0,
'destinations': None # Will only have destinations up to two towns ahead
}
@app.route('/info')
def info():
if real_start_time != 0:
advance_fake_time()
return app.response_class(json.dumps(current_info), content_type='application/json')
@app.route('/start')
def start():
global real_start_time, speed_factor
real_start_time = real_now()
speed_factor = int(request.args.get('speed', '100'))
print(u'fake clock stated at speed {0}'.format(speed_factor))
return 'ok'
@app.route('/reset')
def reset():
global real_start_time
real_start_time = 0
current_info['destinations'] = all_destinations[:3]
return 'ok'
def index_of_current_destination(ts):
for i, dest in enumerate(all_destinations):
if dest['departure'] > ts:
return i
return 0
def current_destinations():
index = index_of_current_destination(fake_now()) + 3
return all_destinations[:index]
def advance_fake_time():
current_info['now'] = fake_now()
current_info['destinations'] = current_destinations()
def real_now():
return int(time.time() * 100)
def fake_now():
return (real_now() - real_start_time) * speed_factor + fake_start_time
def arrival(d):
return d['arrival']
def load_json():
with open('santa2016.json') as data_file:
data = json.load(data_file)
global all_destinations, fake_start_time
all_destinations = sorted(data['destinations'], key=arrival)
fake_start_time = all_destinations[1]['arrival']
reset()
print(u'{0} destinations loaded, fake_start_time={1}'.format(len(all_destinations), fake_start_time))
if __name__ == '__main__':
load_json()
app.run(host='0.0.0.0', port=1224)
MKR1000 FirmwareNow the MKR1000 is ready to fetch the data from Raspberry Pi server, and turn LEDs on and off.
#include <SPI.h>
#include <WiFi101.h>
#include <Adafruit_NeoPixel.h>
#include "JsonStreamingParser.h"
#include "JsonListener.h"
#define LED_PIN 6
#define LED_NUM 30
#define BRIGHTNESS 50
Adafruit_NeoPixel strip = Adafruit_NeoPixel(LED_NUM, LED_PIN, NEO_GRB + NEO_KHZ800);
char ssid[] = "YOUR_SSID"; // your network SSID (name)
char pass[] = "YOUR_PWRD"; // your network password
int keyIndex = 0; // your network key Index number (needed only for WEP)
int status = WL_IDLE_STATUS;
IPAddress server(192, 168, 1, 120); // numeric IP for RPI server
//char server[] = "rpi3.local"; // name address for RPI server
char endpoint[] = "/santa";
int port = 2412;
// Initialize the Ethernet client library
// with the IP address and port of the server
// that you want to connect to (port 80 is default for HTTP):
WiFiClient client;
class Led {
public:
String name;
int distance;
int presents;
boolean on;
};
Led leds[30];
class LedSwitcher: public JsonListener {
public:
void whitespace(char c) {}
void startDocument() {}
void key(String key) {
Serial.println(key);
currentKey = key;
}
void value(String value) {
Serial.println(value);
if (currentKey == "i") {
ledIndex = value.toInt();
} else if (currentKey == "p") {
presents = value.toInt();
} else if (currentKey == "d") {
distance = value.toInt();
} else {
name = value;
}
}
void endArray() {}
void endObject() {
Serial.println("End of Object");
Serial.print(ledIndex);
Serial.print(":");
Serial.print(name.c_str());
Serial.print(",");
Serial.print(presents);
Serial.print(",");
Serial.print(distance);
leds[ledIndex].on = true;
leds[ledIndex].name = name;
leds[ledIndex].presents = presents;
leds[ledIndex].distance = distance;
}
void endDocument() {}
void startArray() {}
void startObject() {}
int lastLed() {
return ledIndex;
}
private:
String currentKey;
int ledIndex;
int presents;
int distance;
String name;
};
LedSwitcher ledSwitcher;
void connectToWifi() {
// check for the presence of the shield:
if (WiFi.status() == WL_NO_SHIELD) {
Serial.println("WiFi shield not present");
// don't continue:
while (true);
}
// attempt to connect to Wifi network:
while (status != WL_CONNECTED) {
Serial.print("Attempting to connect to SSID: ");
Serial.println(ssid);
// Connect to WPA/WPA2 network. Change this line if using open or WEP network:
status = WiFi.begin(ssid, pass);
// wait 10 seconds for connection:
delay(10000);
}
Serial.println("Connected to wifi");
printWifiStatus();
}
boolean connectToSantaServer() {
Serial.println("Starting connection to server...");
return client.connect(server, port);
}
void ensureConnected() {
if (!client.connected()) {
while (!connectToSantaServer()) {
Serial.println("Failed to connect to server. Retry in 5 seconds");
delay(5000);
}
Serial.println("connected to server");
}
}
void fetchSantaInfo() {
ensureConnected();
// Make a HTTP request:
client.print("GET ");
client.print(endpoint);
client.println(" HTTP/1.1");
client.print("Host: ");
client.println(server);
client.println("Connection: close");
client.println();
int bytes = 0;
boolean isBody = false;
JsonStreamingParser parser;
parser.setListener(&ledSwitcher);
Serial.println();
Serial.println("Received response:");
Serial.println();
while (client.connected()) {
while (client.available()) {
char c = client.read();
++bytes;
//Serial.write(c);
if (isBody || c == '[') {
isBody = true;
parser.parse(c);
}
}
}
Serial.println();
Serial.println();
Serial.println("Disconnecting from server.");
client.stop();
Serial.print("Received: ");
Serial.print(bytes);
Serial.println(" Bytes.");
}
void flashLastLed() {
strip.setPixelColor(ledSwitcher.lastLed(), strip.Color(0, 0, 0));
strip.show();
delay(500);
strip.setPixelColor(ledSwitcher.lastLed(), strip.Color(255, 0, 0));
strip.show();
delay(500);
}
void setup() {
//Initialize serial and wait for port to open:
Serial.begin(9600);
strip.setBrightness(BRIGHTNESS);
strip.begin();
strip.show(); // Initialize all pixels to 'off'.
connectToWifi();
fetchSantaInfo();
for (int i = 0; i < 30; ++i) {
if (leds[i].on) {
strip.setPixelColor(i, strip.Color(255, 0, 0));
} else {
strip.setPixelColor(i, strip.Color(0, 0, 0));
}
}
strip.show();
}
void loop() {
fetchSantaInfo();
delay(1000);
for (int i = 0; i < 10; ++i) {
flashLastLed();
}
}
void printWifiStatus() {
// print the SSID of the network you're attached to:
Serial.print("SSID: ");
Serial.println(WiFi.SSID());
// print your WiFi shield's IP address:
IPAddress ip = WiFi.localIP();
Serial.print("IP Address: ");
Serial.println(ip);
// print the received signal strength:
long rssi = WiFi.RSSI();
Serial.print("signal strength (RSSI):");
Serial.print(rssi);
Serial.println(" dBm");
}
As most of the work has been done on Raspberry Pi, the code here is straightforward. It connects to WiFi, the every 10 seconds it fetches the Santa data from Raspberry Pi, and update the NeoPixels accordingly.
Now let's power it on:
ConclusionMKR1000 is very powerful board for IoT applications, but it has its own limits. With the help of more powerful SBC (single-board computer) like a Raspberry Pi 3 we will be able to interface with any services with more complex APIs.
Hope you like this project as a little holiday surprise.
Comments