mirror of
https://github.com/usetrmnl/plugins.git
synced 2026-08-13 14:26:58 -07:00
Merge pull request #77 from usetrmnl/open-source/withings-20250909-132859
Open source Withings plugin
This commit is contained in:
@@ -24,6 +24,7 @@ community Recipes:
|
||||
- [Tempest Weather Station](/lib/tempest_weather_station)
|
||||
- [Todoist](/lib/todoist)
|
||||
- [Weather (generic)](/lib/weather)
|
||||
- [Withings](/lib/withings)
|
||||
- [Youtube Analytics](/lib/youtube_analytics)
|
||||
|
||||
native plugin structure:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<% libs = local_assigns[:libs] || [:highcharts, :chartkick] %>
|
||||
|
||||
<% if libs.include?(:highcharts) %>
|
||||
<script src="https://usetrmnl.com/js/highcharts/12.3.0/highcharts.js"></script>
|
||||
<% end %>
|
||||
|
||||
<% if libs.include?(:highcharts_more) %>
|
||||
<script src="https://usetrmnl.com/js/highcharts/12.3.0/highcharts-more.js"></script>
|
||||
<% end %>
|
||||
|
||||
<% if libs.include?(:highcharts_pattern_fill) %>
|
||||
<script src="https://usetrmnl.com/js/highcharts/12.3.0/pattern-fill.js"></script>
|
||||
<% end %>
|
||||
|
||||
<% if libs.include?(:chartkick) %>
|
||||
<script src="https://usetrmnl.com/js/chartkick/5.0.1/chartkick.min.js"></script>
|
||||
<% end %>
|
||||
@@ -0,0 +1,53 @@
|
||||
<%= render 'plugins/chart_js' %>
|
||||
|
||||
<div class="view view--full">
|
||||
<div class="layout layout--col gap--space-between">
|
||||
<div class="grid grid--cols-4">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= current_weight %></span>
|
||||
<span class="label">Current Weight</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %>
|
||||
<span class="value value--tnums"><%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %></span>
|
||||
<span class="label">This Period</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--medium" data-value-fit="true"><%= l(Time.at(metrics.last.keys.first).to_date, format: :short, locale:) %></span>
|
||||
<span class="label">Last Measurement</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if goals && goals['steps'] %>
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= number_with_delimiter(goals['steps']) %></span>
|
||||
<span class="label">Daily Step Goal</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% formatted_data = metrics.map { |period| [l(Time.at(period.keys.first).to_date, format: :short, locale:), period.values.first] } %>
|
||||
<%= line_chart formatted_data, adapter: :highcharts, prefix: "", thousands: ",", points: false, colors: ["black"], curve: true,
|
||||
library: {
|
||||
chart: { height: 320 },
|
||||
plotOptions: { series: { animation: false, lineWidth: 4 } },
|
||||
yAxis: { min: metrics.map { it.values.first }.min - 3, labels: { style: { fontSize: '16px', color: '#000000' } }, gridLineDashStyle: 'shortdot', gridLineWidth: 1, gridLineColor: '#000000', tickAmount: 8 },
|
||||
xAxis: { type: 'daytime', labels: { style: { fontSize: '16px', color: '#000000' } }, lineWidth: 0, gridLineDashStyle: 'dot', tickWidth: 1, tickLength: 0, gridLineWidth: 1, gridLineColor: '#000000', tickPixelInterval: 120 }}
|
||||
%>
|
||||
</div>
|
||||
|
||||
<%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
<%= render 'plugins/chart_js' %>
|
||||
|
||||
<div class="view view--half_horizontal">
|
||||
<div class="layout layout--row gap--space-between">
|
||||
<div class="grid h-full">
|
||||
<div class="col--span-1 h-full">
|
||||
<div class="col gap--space-between">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= current_weight %></span>
|
||||
<span class="label">Current Weight</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %>
|
||||
<span class="value value--tnums"><%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %></span>
|
||||
<span class="label">This Period</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col--span-4 col h-full gap--space-between">
|
||||
<% formatted_data = metrics.map { |period| [l(Time.at(period.keys.first).to_date, format: :short, locale:), period.values.first] } %>
|
||||
<%= line_chart formatted_data, adapter: :highcharts, prefix: "", thousands: ",", points: false, colors: ["black"], curve: true,
|
||||
library: {
|
||||
chart: { height: 176, width: 612 },
|
||||
plotOptions: { series: { animation: false, lineWidth: 4 } },
|
||||
yAxis: { min: metrics.map { it.values.first }.min - 2, labels: { style: { fontSize: '16px', color: '#000000' } }, gridLineDashStyle: 'shortdot', gridLineWidth: 1, gridLineColor: '#000000', tickAmount: 5 },
|
||||
xAxis: { type: 'daytime', labels: { enabled: false }, lineWidth: 0, gridLineDashStyle: 'dot', tickWidth: 1, tickLength: 0, gridLineWidth: 1, gridLineColor: '#000000', tickPixelInterval: 120 }
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<%= render 'plugins/chart_js' %>
|
||||
|
||||
<div class="view view--half_vertical">
|
||||
<div class="layout layout--col">
|
||||
<div class="grid grid--cols-2">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= current_weight %></span>
|
||||
<span class="label">Current Weight</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %>
|
||||
<span class="value value--tnums"><%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %></span>
|
||||
<span class="label">This Period</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% formatted_data = metrics.map { |period| [l(Time.at(period.keys.first).to_date, format: :short, locale:), period.values.first] } %>
|
||||
<%= line_chart formatted_data, adapter: :highcharts, prefix: "", thousands: ",", points: false, colors: ["black"], curve: true,
|
||||
library: {
|
||||
chart: { height: 280 },
|
||||
plotOptions: { series: { animation: false, lineWidth: 4 } },
|
||||
yAxis: { min: metrics.map { it.values.first }.min - 5, labels: { style: { fontSize: '16px', color: '#000000' } }, gridLineDashStyle: 'shortdot', gridLineWidth: 1, gridLineColor: '#000000', tickAmount: 5 },
|
||||
xAxis: { type: 'daytime', labels: { style: { fontSize: '16px', color: '#000000' } }, lineWidth: 0, gridLineDashStyle: 'dot', tickWidth: 1, tickLength: 0, gridLineWidth: 1, gridLineColor: '#000000', tickPixelInterval: 120 }}
|
||||
%>
|
||||
</div>
|
||||
|
||||
<%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<%= render 'plugins/chart_js' %>
|
||||
|
||||
<div class="view view--quadrant">
|
||||
<div class="layout layout--col">
|
||||
<div class="grid grid--cols-2">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= current_weight %></span>
|
||||
<span class="label">Current Weight</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %>
|
||||
<span class="value value--tnums"><%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %></span>
|
||||
<span class="label">This Period</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% formatted_data = metrics.map { |period| [l(Time.at(period.keys.first).to_date, format: :short, locale:), period.values.first] } %>
|
||||
<%= line_chart formatted_data, adapter: :highcharts, prefix: "", thousands: ",", points: false, colors: ["black"], curve: true,
|
||||
library: {
|
||||
chart: { height: 112 },
|
||||
plotOptions: { series: { animation: false, lineWidth: 4 } },
|
||||
yAxis: { min: metrics.map { it.values.first }.min - 2, labels: { style: { fontSize: '16px', color: '#000000' } }, gridLineDashStyle: 'shortdot', gridLineWidth: 1, gridLineColor: '#000000', tickAmount: 5 },
|
||||
xAxis: { type: 'daytime', labels: { style: { fontSize: '16px', color: '#000000' } }, lineWidth: 0, gridLineDashStyle: 'dot', tickWidth: 1, tickLength: 0, gridLineWidth: 1, gridLineColor: '#000000', tickPixelInterval: 120 }}
|
||||
%>
|
||||
</div>
|
||||
|
||||
<%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="title_bar">
|
||||
<img class="image" src="<%= plugin_asset_url %>/images/plugins/withings--render.png" />
|
||||
<span class="title">Withings</span>
|
||||
<span class="instance"><%= instance_name %></span>
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
module Plugins
|
||||
class Withings < Base
|
||||
|
||||
BASE_URL = 'https://wbsapi.withings.net'.freeze
|
||||
|
||||
def locals
|
||||
{ goals:, current_weight:, metrics: }
|
||||
end
|
||||
|
||||
def goals
|
||||
response = make_request('/v2/user', body: { action: 'getgoals' })
|
||||
response['body']['goals'] # ex: {"weight" => {"value" => 70000, "unit" => -3}, "steps" => 10000, "sleep" => 28800}
|
||||
end
|
||||
|
||||
def current_weight
|
||||
metrics.last.values.first.round(1)
|
||||
end
|
||||
|
||||
def metrics
|
||||
@metrics ||= begin
|
||||
body = { action: 'getmeas', meastype: 1, startdate: start_date }
|
||||
response = make_request('/measure', body:)
|
||||
|
||||
parse_and_format_metrics(response)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_and_format_metrics(data)
|
||||
metrics = parse_metrics(data)
|
||||
format_metrics(metrics)
|
||||
end
|
||||
|
||||
def parse_metrics(data)
|
||||
groups = data['body']['measuregrps'].filter { device_models.include?(it['model']) }
|
||||
|
||||
unit = groups.first['measures'].first['unit']
|
||||
divisor = "1#{'0' * unit.abs}".to_f
|
||||
|
||||
# type=1 is weight (https://developer.withings.com/api-reference#weight)
|
||||
# TODO: support more metrics, e.g. multi series line graph with weight vs fat, muscle
|
||||
groups.map { |g| { g['date'] => g['measures'].find { it['type'] == 1 }['value'] / divisor } }.sort_by { it.keys.first } # => [{ 1756494472 => 83.25 }, {1755519350 => 84.1 }]
|
||||
end
|
||||
|
||||
def format_metrics(metrics)
|
||||
return metrics if weight_units == 'kilograms'
|
||||
|
||||
metrics.map { |m| { m.keys.first => m.values.first * 2.20462 } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def device_models = ['Body Smart', 'Body Scan', 'Body+']
|
||||
|
||||
def start_date = lookback_period.days.ago.to_i
|
||||
|
||||
def weight_units = settings['weight_units']
|
||||
|
||||
def oauth_client = OauthService::Withings.new(keyname)
|
||||
|
||||
def keyname = plugin_settings.plugin.keyname
|
||||
|
||||
def access_token = settings['withings']['access_token']
|
||||
|
||||
def refresh_token = settings['withings']['refresh_token']
|
||||
|
||||
def make_request(endpoint, body:)
|
||||
response = post("#{BASE_URL}#{endpoint}", body: body.to_json, headers: headers)
|
||||
raise AccessTokenExpired if response['error']&.include?('The access token provided is invalid')
|
||||
|
||||
JSON.parse(response)
|
||||
rescue AccessTokenExpired
|
||||
token = oauth_client.send('refresh_access_token', refresh_token)
|
||||
credentials = plugin_settings.encrypted_settings
|
||||
credentials[keyname]['access_token'] = token['access_token']
|
||||
credentials[keyname]['refresh_token'] = token['refresh_token'] # this changes 1x per year
|
||||
plugin_settings.update(encrypted_settings: credentials)
|
||||
retry
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
"Authorization" => "Bearer #{access_token}",
|
||||
"Content-Type" => 'application/json'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user