by Phil Pownall
This Moon Clock shows the Moon Phase using a globe, and the Moon Azimuth and Elevation on an attached display. The device consists of a (Moon) globe printed in two different color hemispheres (I chose Blue and transparent White). The Moon globe rotates during the lunar month, and the internal LED lights up when the Moon is visible above the horizon. The device uses ESPHome to program an ESP32, and the IPGeolocation Astronomy API.
- An ESP32 device running ESPHome
- A 608ZZ bearing
- A SG90 or MG90 180-degree servo motor
- A Yellow or Blue LED
- An SSD1306 128x64 display
- A 3D-printed mount consisting of a base, a cog, a platform and a globe
- An ESP32 Expansion board to supply power and additional pins
- A 9V power adapter for the Expansion board
The mount was designed in TinkerCAD, providing a base for the SG90 servo STL files for the design are available in this repository: [TinkerCAD Moon Clock v2] (https://www.tinkercad.com/things/8dPIg13zxnB-moon-clock-v2)
The platform consists of a 1X cog and a Moon Globe. The platform and the !X Cog have a hole in the centre for the LED wires. The platform sits on top of the bearing which is in the top of the base. The 2X cog sits on top of the servo, and interlocks with the 1X cog.
To set up the tracker,
- Set the servo to its centre position (180 degrees) using the number slider
- Position the base with the Moon Globe with the Full Moon (white side facing you), and
- Tighten the 2X cog screw into the servo.
- enter your location (latitude and Longitude) and the IPGeolocation Astronomy API key into your secrets file
- compile and install the MoonClock.yaml into your ESP32 device
- plug the ESP32 adapter in to start the Moon Phase Tracker
The ESPHome yaml code consists of the following key sections:
- the get_attributes script which uses http_request to fetch the Moon parameters from the IPGeo Astronomy API
- the servo number template which updates the servo position with the Moon Phase Angle
- the display code
- a time automation to update the Moon position
The script uses http_request to fetch the current Moon position and phase (for your location) from the IPGeolocation Astronomy API (in a json format). The json::parse_json* function is used to parse the json, and set the display variables and the Moon Phase servo position.
script:
- id: get_attributes
then:
- logger.log:
format: "moon-clock: Get Request initiated"
level: INFO
- http_request.get:
capture_response: true
# max_response_buffer_size: 1024
headers:
Content-Type: application/json
url: !lambda |-
return "https://api.ipgeolocation.io/astronomy?apiKey=${ipg_api_key}&lat=${my_latitude}&long=${my_longitude}";
on_response:
- if:
condition:
lambda: |-
return response->status_code == 200;
then:
- logger.log:
format: "moon-clock: Get Request succeeded..."
level: INFO
- lambda: |-
json::parse_json(body, [](JsonObject root) {
id(moon_altitude).publish_state(root["moon_altitude"]);
id(moon_azimuth).publish_state(root["moon_azimuth"]);
// use call.set_value rather than publish state for number template
auto call = id(moon_angle).make_call();
call.set_value(root["moon_angle"]);
call.perform();
id(moonrise).publish_state(root["moonrise"]);
id(moonset).publish_state(root["moonset"]);
id(moon_phase).publish_state(root["moon_phase"]);
return true;
});
else:
- logger.log:
format: "moon-clock: Get Request - error!!!"
level: ERROR
- if:
condition:
lambda: 'return id(moon_altitude).state > 0;'
then:
- logger.log:
format: "moon-clock: The moon is up"
level: INFO
- light.turn_on: yellow_led
else:
- logger.log:
format: "moon-clock: The moon is below the horizon"
level: INFO
- light.turn_off: yellow_led
- logger.log:
format: "moon-clock: Get Request finished"
level: INFO
The number template drives the servo to the correct Phase Angle of the Moon. The Phase Angle varies from 0 degrees (New Moon) through the waxing phases to 180 degrees (Full Moon), and through the waning phases to 360 degrees (New Moon again). The 2X Cog generates the full 360-degree rotation from a 180-degree SG90 servo. The formula translates the Moon Phase Angle from 0-360 to -1 to +1 for the servo.
number:
- platform: template
name: Moon Angle
id: moon_angle
min_value: 0
initial_value: 180
max_value: 360
step: 0.1
optimistic: True
set_action:
then:
- servo.write:
id: servo_phase
level: !lambda 'return ((x / 360.0)* 2) - 1.0;'
The display code shows the Moon position, rise and set times, and Moon Phase Angle. Font Glyphs are used to display an icon with each item, and small Moon images are used to show the major phases. The Interval automation cycles the dsiplay page every 5 seconds
display:
- platform: ssd1306_i2c
model: "SSD1306 128x64"
reset_pin: GPIO25 # an unused pin
address: 0x3C
id: my_display
flip_x: True
flip_y: True
pages:
- id: page1
lambda: |-
it.strftime(64, 10, id(font3), TextAlign::TOP_CENTER, "%H:%M", id(sntp_time).now());
- id: page2
lambda: |-
it.print(64, 0, id(font2), TextAlign::TOP_CENTER, "MOONRISE");
it.print(75, 10, id(icons), "");
it.printf(5, 20, id(font1), "%s", id(moonrise).state.c_str());
- id: page3
lambda: |-
it.print(64, 0, id(font2), TextAlign::TOP_CENTER, "ALTITUDE");
it.print(75, 14, id(icons), "");
it.printf(25, 21, id(font1), "%.0f°", id(moon_altitude).state);
- id: page4
lambda: |-
it.print(64, 0, id(font2), TextAlign::TOP_CENTER, "AZIMUTH");
it.print(75, 14, id(icons), "");
it.printf(20, 20, id(font1), "%.0f°", id(moon_azimuth).state);
- id: page5
lambda: |-
it.print(64, 0, id(font2), TextAlign::TOP_CENTER, "MOONSET");
it.print(75, 10, id(icons), "");
it.printf(6, 21, id(font1), "%s", id(moonset).state.c_str());
- id: page6
lambda: |-
// Print Moon Phase in top centre
it.printf(64, 0, id(font2), TextAlign::TOP_CENTER, "%.12s", id(moon_phase).state.c_str());
it.printf(10, 25, id(font1), "%.0f°", id(moon_angle).state);
if (id(moon_phase).state == "NEW_MOON") {
it.image(70, 16, id(new_moon), COLOR_OFF, COLOR_ON);
} else if (id(moon_phase).state == "WAXING_CRESCENT") {
it.image(70, 16, id(waxing_crescent), COLOR_OFF, COLOR_ON);
} else if (id(moon_phase).state == "FIRST_QUARTER") {
it.image(70, 16, id(first_quarter), COLOR_OFF, COLOR_ON);
} else if (id(moon_phase).state == "WAXING_GIBBOUS") {
it.image(70, 16, id(waxing_gibbous), COLOR_OFF, COLOR_ON);
} else if ((id(moon_phase).state == "FULL_MOON")) {
it.image(70, 16, id(full_moon), COLOR_OFF, COLOR_ON);
} else if (id(moon_phase).state == "WANING_GIBBOUS") {
it.image(70, 16, id(waning_gibbous), COLOR_OFF, COLOR_ON);
} else if (id(moon_phase).state == "LAST_QUART") {
it.image(70, 16, id(last_quarter), COLOR_OFF, COLOR_ON);
} else if (id(moon_phase).state == "WANING_CRESCENT") {
it.image(70, 16, id(waning_crescent), COLOR_OFF, COLOR_ON);
} else {
it.printf(0, 40, id(font2), "%s", id(moon_phase).state.c_str());
}
interval:
# update the display page every 5 seconds
- interval: 5s
then:
- display.page.show_next: my_display
- component.update: my_display
time:
- platform: sntp
id: sntp_time
timezone: ${my_timezone}
on_time:
# get Moon data every 10 minutes
- seconds: 0
minutes: /10
then:
- logger.log:
format: "moon-clock: every 10 minutes..."
level: INFO
- script.execute: get_attributes
- display.page.show: page1
Printing the white Moon hemisphere as a Lithophane... and scaling up to a 6" globe.