Skip to content

Commit e3f3aa3

Browse files
authored
New app Ships Nearby (#464)
* initial commit * fix naming in manifest * working with marinesia * select newest * add url to get bounding box
1 parent 3c4d4e0 commit e3f3aa3

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

apps/shipsnearby/manifest.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
id: shipsnearby
3+
name: Ship Nearby
4+
summary: Display ships in a bounding box
5+
desc: Shows vessel names within a geographic bounding box using Marinesia.com.
6+
author: tavdog
7+
fileName: shipsnearby.star
8+
packageName: shipsnearby
9+
recommendedInterval: 30
10+
category: transit
11+
tags:
12+
- utility
13+
- local
14+
- tracking
15+
- maritime

apps/shipsnearby/shipsnearby.star

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
"""
2+
Applet: Ships Nearby
3+
Summary: Display ships near Indonesia
4+
Description: Shows vessels near Indonesia using Marinesia API.
5+
Author: tavdog
6+
"""
7+
8+
load("encoding/base64.star", "base64")
9+
load("encoding/json.star", "json")
10+
load("http.star", "http")
11+
load("render.star", "render")
12+
load("schema.star", "schema")
13+
14+
# Container ship icon (32x10 pixels)
15+
CONTAINER_ICON = base64.decode("iVBORw0KGgoAAAANSUhEUgAAACAAAAAKCAYAAADVTVykAAAAWElEQVR4nGNgoAO4c+fOfxCmh104LSfbESdsbP6DsE3KFgheEADGJ7bYQDAO+aHvAJiBMIvgFqNZSEg+ICAKBQ9+B6BroAXG7Wsbt//0wgPqCOLSAJ0sBgABdYfIAQVTYgAAAABJRU5ErkJggg==")
16+
17+
# Cargo/bulk carrier ship icon (32x10 pixels)
18+
CARGO_ICON = base64.decode("iVBORw0KGgoAAAANSUhEUgAAACAAAAAKCAYAAADVTVykAAAAkElEQVR4nGNgoAMwMrL5D8LY5JgYBhgw0tqCO3fuoPhcRUWFcVCFAAsuiby8CqxxRiqYNGkOaVGQB7X4EZcNRRZXHOlA4S8zQpg3aVIHI9YQ0LBJ+b/r3Bsom4GqAGYuzJ4bR+YwYg0BkCQDjQHMcqwOQHaEiAbC1ZSANzdEMCzG6wAYsEkJoDg0jszZgNcOAESuLXbaOFrzAAAAAElFTkSuQmCC")
19+
20+
# Tugboat icon (24x10 pixels)
21+
TUG_ICON = base64.decode("iVBORw0KGgoAAAANSUhEUgAAABgAAAAKCAYAAACuaZ5oAAAASklEQVR4nGNgoAAYGdn8B2FKzBgYC+7cufMfG6aqBXJyGnCDYWyqWkCxDzQ0jP6Ti3EamhJg8x+EtxgZUYwHxgJkQDODybUIl34AGGjZ9C3FCkUAAAAASUVORK5CYII=")
22+
23+
API_URL = "https://api.marinesia.com/api/v1/vessel/nearby"
24+
25+
DEFAULT_BBOX = "105,-7.5,107,-5"
26+
27+
DISPLAY_OPTIONS = [
28+
schema.Option(display = "Auto", value = "auto"),
29+
schema.Option(display = "Speed & Course", value = "speed_course"),
30+
schema.Option(display = "Nav Status", value = "nav_status"),
31+
schema.Option(display = "Vessel Type", value = "vessel_type"),
32+
schema.Option(display = "Flag", value = "flag"),
33+
schema.Option(display = "IMO", value = "imo"),
34+
schema.Option(display = "MMSI", value = "mmsi"),
35+
schema.Option(display = "Dimensions", value = "dimensions"),
36+
schema.Option(display = "Coordinates", value = "coordinates"),
37+
schema.Option(display = "Heading", value = "heading"),
38+
schema.Option(display = "Draft", value = "draught"),
39+
schema.Option(display = "Destination", value = "destination"),
40+
schema.Option(display = "ETA", value = "eta"),
41+
schema.Option(display = "None", value = "none"),
42+
]
43+
44+
def get_schema():
45+
return schema.Schema(
46+
version = "1",
47+
fields = [
48+
schema.Text(
49+
id = "api_key",
50+
name = "API Key",
51+
desc = "Your API key from marinesia.com",
52+
icon = "key",
53+
secret = True,
54+
),
55+
schema.Text(
56+
id = "bbox",
57+
name = "Bounding Box",
58+
desc = "Format: long_min,lat_min,long_max,lat_max Try use https://boundingbox.klokantech.com/",
59+
icon = "mapPin",
60+
default = DEFAULT_BBOX,
61+
),
62+
schema.Dropdown(
63+
id = "line2",
64+
name = "Line 2 Info",
65+
desc = "Information to display on the second line",
66+
icon = "alignLeft",
67+
default = "speed_course",
68+
options = DISPLAY_OPTIONS,
69+
),
70+
schema.Dropdown(
71+
id = "line3",
72+
name = "Line 3 Info",
73+
desc = "Information to display on the third line",
74+
icon = "alignLeft",
75+
default = "nav_status",
76+
options = DISPLAY_OPTIONS,
77+
),
78+
],
79+
)
80+
81+
def fetch_vessels(bbox, api_key):
82+
parts = bbox.strip().split(",")
83+
if len(parts) < 4:
84+
return None, "Invalid bbox: need 4 values (long_min,lat_min,long_max,lat_max)"
85+
params = {
86+
"key": api_key,
87+
"long_min": parts[0],
88+
"lat_min": parts[1],
89+
"long_max": parts[2],
90+
"lat_max": parts[3],
91+
}
92+
print("params: " + str(params))
93+
response = http.get(
94+
API_URL,
95+
params = params,
96+
ttl_seconds = 30,
97+
)
98+
print("status: " + str(response.status_code))
99+
body = response.body()
100+
print("body: " + body)
101+
if response.status_code == 404:
102+
return [], None
103+
if response.status_code != 200:
104+
return None, "API error: " + str(response.status_code)
105+
data = json.decode(response.body())
106+
if data.get("error", True):
107+
return None, data.get("message", "API error")
108+
vessels = data.get("data", [])
109+
print("vessels count: " + str(len(vessels)))
110+
return vessels, None
111+
112+
NAV_STATUS = {
113+
0: "Underway",
114+
1: "Anchor",
115+
2: "Not cmd",
116+
3: "Restrict",
117+
4: "Moored",
118+
5: "Aground",
119+
6: "Fishing",
120+
7: "Sailing",
121+
8: "Reserved",
122+
9: "Reserved",
123+
10: "Reserved",
124+
11: "AIS-SART",
125+
12: "Undef",
126+
13: "Undefined",
127+
14: "AIS-SART",
128+
15: "N/A",
129+
}
130+
131+
AUTO_PRIORITY = [
132+
"dest",
133+
"type",
134+
"flag",
135+
"imo",
136+
"mmsi",
137+
"coords",
138+
"hdt",
139+
"draft",
140+
"eta",
141+
]
142+
143+
def get_info_text(option, vessel, skip_field = ""):
144+
if option == "auto":
145+
for field in AUTO_PRIORITY:
146+
if field == skip_field:
147+
continue
148+
if field == "dest":
149+
dest = vessel.get("dest", "")
150+
if dest:
151+
return dest, field
152+
elif field == "type":
153+
vtype = vessel.get("type", "")
154+
if vtype:
155+
return vtype, field
156+
elif field == "flag":
157+
flag = vessel.get("flag", "")
158+
if flag:
159+
return flag, field
160+
elif field == "imo":
161+
imo = vessel.get("imo", 0)
162+
if imo:
163+
return "IMO: " + str(imo), field
164+
elif field == "mmsi":
165+
mmsi = vessel.get("mmsi", "")
166+
if mmsi:
167+
return "MMSI: " + str(mmsi), field
168+
elif field == "coords":
169+
lat = vessel.get("lat", 0)
170+
lng = vessel.get("lng", 0)
171+
if lat and lng:
172+
return str(int(lat * 1000) / 1000.0) + ", " + str(int(lng * 1000) / 1000.0), field
173+
elif field == "hdt":
174+
hdt = vessel.get("hdt", "")
175+
if hdt:
176+
return "Hdg: " + str(hdt), field
177+
elif field == "draft":
178+
draught = vessel.get("draught", 0)
179+
if draught:
180+
return "Draft: " + str(draught) + "m", field
181+
elif field == "eta":
182+
eta = vessel.get("eta", "")
183+
if eta:
184+
return "ETA: " + eta, field
185+
return "", ""
186+
if option == "speed_course":
187+
sog = vessel.get("sog", 0)
188+
cog = vessel.get("cog", 0)
189+
return str(sog) + " kn - " + str(cog) + "°", ""
190+
elif option == "nav_status":
191+
status = vessel.get("status", 15)
192+
return NAV_STATUS.get(status, "N/A"), ""
193+
elif option == "vessel_type":
194+
vtype = vessel.get("type", "")
195+
return vtype if vtype else "Unknown", "type"
196+
elif option == "flag":
197+
flag = vessel.get("flag", "")
198+
return flag if flag else "N/A", "flag"
199+
elif option == "imo":
200+
imo = vessel.get("imo", 0)
201+
return "IMO: " + str(imo) if imo else "N/A", "imo"
202+
elif option == "mmsi":
203+
mmsi = vessel.get("mmsi", "")
204+
return "MMSI: " + str(mmsi) if mmsi else "N/A", "mmsi"
205+
elif option == "dimensions":
206+
a = vessel.get("a", 0)
207+
b = vessel.get("b", 0)
208+
c = vessel.get("c", 0)
209+
d = vessel.get("d", 0)
210+
if a and b and c and d:
211+
return str(a) + "x" + str(b) + "x" + str(c) + "m", "dims"
212+
return "N/A", ""
213+
elif option == "coordinates":
214+
lat = vessel.get("lat", 0)
215+
lng = vessel.get("lng", 0)
216+
lat_str = str(int(lat * 1000) / 1000.0)
217+
lng_str = str(int(lng * 1000) / 1000.0)
218+
return lat_str + ", " + lng_str, "coords"
219+
elif option == "heading":
220+
hdt = vessel.get("hdt", "")
221+
return "Hdg: " + str(hdt) if hdt else "N/A", "hdt"
222+
elif option == "draught":
223+
draught = vessel.get("draught", 0)
224+
return "Draft: " + str(draught) + "m" if draught else "Draft: N/A", "draft"
225+
elif option == "destination":
226+
dest = vessel.get("dest", "")
227+
return dest if dest else "N/A", "dest"
228+
elif option == "eta":
229+
eta = vessel.get("eta", "")
230+
return "ETA: " + eta if eta else "N/A", "eta"
231+
elif option == "none":
232+
return "", ""
233+
return "", ""
234+
235+
def render_view(vessels, line2_opt, line3_opt):
236+
if len(vessels) == 0:
237+
return []
238+
239+
# Find vessel with a name, pick newest by ts
240+
v = None
241+
for vv in vessels:
242+
name = vv.get("name")
243+
if name:
244+
if v == None or vv.get("ts", "") > v.get("ts", ""):
245+
v = vv
246+
247+
if not v:
248+
# Use newest by mmsi
249+
for vv in vessels:
250+
mmsi = vv.get("mmsi")
251+
if mmsi:
252+
if v == None or vv.get("ts", "") > v.get("ts", ""):
253+
v = vv
254+
if not v:
255+
return []
256+
257+
name = v.get("name") or ""
258+
vtype = v.get("type") or ""
259+
260+
name_line = name + (" (" + vtype + ")" if vtype else "")
261+
262+
vtype_lower = vtype.lower() if vtype else ""
263+
if vtype_lower == "tug":
264+
icon = TUG_ICON
265+
elif vtype_lower == "container ship" or "container" in vtype_lower:
266+
icon = CONTAINER_ICON
267+
else:
268+
icon = CARGO_ICON
269+
270+
info_lines = [render.Text(name_line, font = "tom-thumb", color = "#fff")]
271+
272+
line2_result = get_info_text(line2_opt, v, "")
273+
if line2_result:
274+
info_lines.append(render.Text(line2_result[0], font = "tom-thumb", color = "#aaa"))
275+
276+
line3_skip = line2_result[1] if (line2_opt == "auto" and line3_opt == "auto") else ""
277+
line3_result = get_info_text(line3_opt, v, line3_skip)
278+
if line3_result:
279+
info_lines.append(render.Text(line3_result[0], font = "tom-thumb", color = "#aaa"))
280+
281+
return render.Root(
282+
child = render.Column(
283+
children = [
284+
render.Row(
285+
main_align = "center",
286+
expanded = True,
287+
children = [
288+
render.Padding(
289+
pad = (0, 1, 0, 1),
290+
child = render.Image(src = icon),
291+
),
292+
],
293+
),
294+
render.Padding(
295+
pad = (1, 0, 1, 0),
296+
child = render.Column(
297+
children = info_lines,
298+
),
299+
),
300+
],
301+
),
302+
)
303+
304+
def render_error(err):
305+
return render.Root(
306+
child = render.Padding(
307+
child = render.Text(err, font = "tom-thumb", color = "#f00"),
308+
pad = 1,
309+
),
310+
)
311+
312+
def main(config):
313+
api_key = config.get("api_key", "")
314+
if api_key == "":
315+
return render_error("API key required")
316+
bbox = config.get("bbox", DEFAULT_BBOX)
317+
if bbox == "":
318+
bbox = DEFAULT_BBOX
319+
line2_opt = config.get("line2", "speed_course")
320+
line3_opt = config.get("line3", "nav_status")
321+
vessels, err = fetch_vessels(bbox, api_key)
322+
if err:
323+
return render_error(err)
324+
return render_view(vessels, line2_opt, line3_opt)

apps/shipsnearby/shipsnearby.webp

272 Bytes
Loading

0 commit comments

Comments
 (0)