Merge pull request #77 from usetrmnl/open-source/withings-20250909-132859

Open source Withings plugin
This commit is contained in:
Ryan Kulp
2025-09-09 13:32:47 -04:00
committed by GitHub
8 changed files with 275 additions and 0 deletions
+1
View File
@@ -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:
+17
View File
@@ -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 %>
+53
View File
@@ -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>
+35
View File
@@ -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>
+35
View File
@@ -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>
+87
View File
@@ -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