diff --git a/README.md b/README.md index 6fb9a26..066a35c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/withings/views/_chart_js.html.erb b/lib/withings/views/_chart_js.html.erb new file mode 100644 index 0000000..6fb1c34 --- /dev/null +++ b/lib/withings/views/_chart_js.html.erb @@ -0,0 +1,17 @@ +<% libs = local_assigns[:libs] || [:highcharts, :chartkick] %> + +<% if libs.include?(:highcharts) %> + +<% end %> + +<% if libs.include?(:highcharts_more) %> + +<% end %> + +<% if libs.include?(:highcharts_pattern_fill) %> + +<% end %> + +<% if libs.include?(:chartkick) %> + +<% end %> diff --git a/lib/withings/views/full.html.erb b/lib/withings/views/full.html.erb new file mode 100644 index 0000000..846fef2 --- /dev/null +++ b/lib/withings/views/full.html.erb @@ -0,0 +1,53 @@ +<%= render 'plugins/chart_js' %> + +
+
+
+
+
+
+ <%= current_weight %> + Current Weight +
+
+ +
+
+
+ <% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %> + <%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %> + This Period +
+
+ +
+
+
+ <%= l(Time.at(metrics.last.keys.first).to_date, format: :short, locale:) %> + Last Measurement +
+
+ + <% if goals && goals['steps'] %> +
+
+
+ <%= number_with_delimiter(goals['steps']) %> + Daily Step Goal +
+
+ <% end %> +
+ + <% 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 }} + %> +
+ + <%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %> +
diff --git a/lib/withings/views/half_horizontal.html.erb b/lib/withings/views/half_horizontal.html.erb new file mode 100644 index 0000000..5fd94f6 --- /dev/null +++ b/lib/withings/views/half_horizontal.html.erb @@ -0,0 +1,42 @@ +<%= render 'plugins/chart_js' %> + +
+
+
+
+
+
+
+
+ <%= current_weight %> + Current Weight +
+
+ +
+
+
+ <% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %> + <%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %> + This Period +
+
+
+
+ +
+ <% 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 } + } + %> +
+
+
+ + <%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %> +
diff --git a/lib/withings/views/half_vertical.html.erb b/lib/withings/views/half_vertical.html.erb new file mode 100644 index 0000000..d0de6a7 --- /dev/null +++ b/lib/withings/views/half_vertical.html.erb @@ -0,0 +1,35 @@ +<%= render 'plugins/chart_js' %> + +
+
+
+
+
+
+ <%= current_weight %> + Current Weight +
+
+ +
+
+
+ <% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %> + <%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %> + This Period +
+
+
+ + <% 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 }} + %> +
+ + <%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %> +
diff --git a/lib/withings/views/quadrant.html.erb b/lib/withings/views/quadrant.html.erb new file mode 100644 index 0000000..464a27f --- /dev/null +++ b/lib/withings/views/quadrant.html.erb @@ -0,0 +1,35 @@ +<%= render 'plugins/chart_js' %> + +
+
+
+
+
+
+ <%= current_weight %> + Current Weight +
+
+ +
+
+
+ <% this_period_change = (metrics.last.values.first - metrics.first.values.first).round(1) %> + <%= this_period_change.positive? ? '+' : '-' %><%= this_period_change.abs %> + This Period +
+
+
+ + <% 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 }} + %> +
+ + <%= render partial: 'plugins/withings/shared/title_bar', locals: { instance_name: instance_name } %> +
diff --git a/lib/withings/views/shared/_title_bar.html.erb b/lib/withings/views/shared/_title_bar.html.erb new file mode 100644 index 0000000..6ac3bb2 --- /dev/null +++ b/lib/withings/views/shared/_title_bar.html.erb @@ -0,0 +1,5 @@ +
+ + Withings + <%= instance_name %> +
diff --git a/lib/withings/withings.rb b/lib/withings/withings.rb new file mode 100644 index 0000000..1d06836 --- /dev/null +++ b/lib/withings/withings.rb @@ -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