mirror of
https://github.com/usetrmnl/plugins.git
synced 2026-08-13 14:26:58 -07:00
Update eight_sleep plugin from core repo
This commit is contained in:
@@ -13,6 +13,7 @@ community Recipes:
|
||||
- [Calendar](/lib/calendar)
|
||||
- [Chatgpt](/lib/chatgpt)
|
||||
- [Days Left Until](/lib/days_left_until)
|
||||
- [Eight Sleep](/lib/eight_sleep)
|
||||
- [Email Meter](/lib/email_meter)
|
||||
- [Github Commit Graph](/lib/github_commit_graph)
|
||||
- [Google Analytics](/lib/google_analytics)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
module Plugins
|
||||
class EightSleep < Base
|
||||
|
||||
AUTH_URL = 'https://auth-api.8slp.net/v1/tokens'.freeze
|
||||
BASE_URL = 'https://app-api.8slp.net/v1'.freeze
|
||||
|
||||
def locals
|
||||
{ metrics: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metrics
|
||||
create_access_token
|
||||
|
||||
resp = fetch(metrics_url, headers:)
|
||||
JSON.parse(resp.body)['periods'][0]
|
||||
end
|
||||
|
||||
def metrics_url
|
||||
fields = 'sfs,sqs,srs,sleep,light,rem,rem_percent,deep,deep_percent,hr,hrv,br,bedtime,wakeup,ttfa,ttgu,snore,snore_heavy,snore_percent,snore_heavy_percent'
|
||||
"#{BASE_URL}/users/#{user_id}/metrics/aggregate?to=#{today}&tz=#{tz}&metrics=#{fields}&periods=week"
|
||||
end
|
||||
|
||||
# spoofs mobile app login
|
||||
def create_access_token
|
||||
resp = post(AUTH_URL, body: auth_body, headers: auth_headers)
|
||||
update_credentials(resp)
|
||||
end
|
||||
|
||||
def update_credentials(resp, refreshing: false)
|
||||
data = JSON.parse(resp.body) # => { 'access_token', 'refresh_token', 'expires_in', 'userId', etc }
|
||||
|
||||
# refreshing tokens does not respond with userId
|
||||
data = data.merge({ 'userId' => user_id }) if refreshing
|
||||
|
||||
credentials = plugin_setting.encrypted_settings
|
||||
credentials['eight_sleep'] = data
|
||||
plugin_setting.update(encrypted_settings: credentials)
|
||||
end
|
||||
|
||||
# anything cool we can do with this endpoint data?
|
||||
def me
|
||||
fetch('https://client-api.8slp.net/v1/users/me', headers:)
|
||||
end
|
||||
|
||||
# https://github.com/lukas-clarke/eight_sleep/blob/101bbf88cdce8aeb7b449a29a1761cfd6004b65a/custom_components/eight_sleep/pyEight/constants.py#L30
|
||||
def auth_body
|
||||
{
|
||||
client_id: '0894c7f33bb94800a03f1f4df13a4f38',
|
||||
client_secret: 'f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76',
|
||||
grant_type: 'password',
|
||||
username:,
|
||||
password:
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def auth_headers
|
||||
{
|
||||
'content-type': "application/json",
|
||||
'user-agent': "Android App",
|
||||
'accept-encoding': "gzip",
|
||||
accept: "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'content-type': 'application/json',
|
||||
connection: 'keep-alive',
|
||||
'user-agent': 'Home Assistant/2024.10.3-14058 (Android 14; SM-S911U1)', # based on: https://github.com/home-assistant/frontend/issues/19374#issuecomment-2506470285
|
||||
'accept-encoding': 'gzip',
|
||||
accept: 'application/json',
|
||||
host: 'app-api.8slp.net',
|
||||
authorization: "Bearer #{access_token}"
|
||||
}
|
||||
end
|
||||
|
||||
def access_token = plugin_setting.encrypted_settings.dig('eight_sleep', 'access_token')
|
||||
|
||||
def user_id = plugin_setting.encrypted_settings.dig('eight_sleep', 'userId')
|
||||
|
||||
def password = settings['password']
|
||||
|
||||
def username = settings['email']
|
||||
|
||||
def tz = ActiveSupport::TimeZone::MAPPING[user.tz] || 'America/New_York'
|
||||
|
||||
def today = user.datetime_now.to_date
|
||||
end
|
||||
end
|
||||
@@ -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,251 @@
|
||||
<div class="view view--full">
|
||||
<div class="layout layout--col gap--large">
|
||||
<!-- Daily gauges in grid -->
|
||||
<div class="grid">
|
||||
<% metrics['samples'].sort_by { |s| s['date'] }.each_with_index do |day, idx| %>
|
||||
<div class="h--32">
|
||||
<div id="day_<%= idx %>" class="h--24"></div>
|
||||
<span class="description text--center"><%= day['date'].to_date.strftime('%A') %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Weekly summary section -->
|
||||
<div class="grid">
|
||||
<div class="col--span-7 gap--large">
|
||||
<div class="flex flex--col gap--medium w--full flex--center">
|
||||
<div class="grid grid--cols-2">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= metrics['avg'].find { |m| m['name'] == 'sqs' }['value'].to_i %>%</span>
|
||||
<span class="label">Quality</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= metrics['avg'].find { |m| m['name'] == 'snore_percent' }['value'].to_f.round(1) %>%</span>
|
||||
<span class="label">Snoring</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid--cols-2">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= metrics['avg'].find { |m| m['name'] == 'deep_percent' }['value'].to_i %>%</span>
|
||||
<span class="label">Deep Sleep</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= metrics['avg'].find { |m| m['name'] == 'rem_percent' }['value'].to_i %>%</span>
|
||||
<span class="label">REM Sleep</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid--cols-2">
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= (metrics['avg'].find { |m| m['name'] == 'ttfa' }['value'].to_f / 60).round %>m</span>
|
||||
<span class="label">Time to Sleep</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="meta"></div>
|
||||
<div class="content">
|
||||
<span class="value value--tnums"><%= (metrics['avg'].find { |m| m['name'] == 'sleep' }['value'].to_f / 60 / 60).round(1) %>h</span>
|
||||
<span class="label">Sleep Duration</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col--span-5 col--center">
|
||||
<div id="day_all" class="w-[340px] h--52"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title_bar">
|
||||
<%= image_tag plugin_image_path("eight_sleep--render.svg"), class: "image", alt: "" %>
|
||||
<span class="title">Eight Sleep</span>
|
||||
<span class="instance"><%= instance_name %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %>
|
||||
|
||||
<script type="text/javascript">
|
||||
var dailyScores = <%== metrics['samples'].sort_by { |k, _| Date.parse(k['date'])}.map {|k, v| k['avg'].find { |score| score['name'] == 'sfs' }['value'].to_i } %>
|
||||
var weeklyScore = <%= metrics['avg'].find { |m| m['name'] == 'sfs'}['value'].to_f.round %>
|
||||
|
||||
function createGauge(score, day, opts) {
|
||||
opts ||= {
|
||||
title: null, // ex: 'Week'
|
||||
height: '80%',
|
||||
labels: {
|
||||
distance: 15
|
||||
},
|
||||
series: {
|
||||
fontSize: '3em'
|
||||
},
|
||||
yAxis: {
|
||||
title: textRating(score)
|
||||
}
|
||||
};
|
||||
Highcharts.chart(`day_${day}`, {
|
||||
|
||||
chart: {
|
||||
type: 'gauge',
|
||||
height: opts.height,
|
||||
animation: false
|
||||
},
|
||||
|
||||
title: {
|
||||
text: opts.title
|
||||
},
|
||||
|
||||
pane: {
|
||||
// size: '75%',
|
||||
startAngle: -150,
|
||||
endAngle: 150,
|
||||
background: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 0
|
||||
}
|
||||
},
|
||||
|
||||
plotOptions: {
|
||||
gauge: {
|
||||
animation: false,
|
||||
pivot: {
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
dial: {
|
||||
backgroundColor: 'transparent',
|
||||
baseWidth: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// The value axis
|
||||
yAxis: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
|
||||
minorTickInterval: 0,
|
||||
tickColor: '#ffffff',
|
||||
tickLength: 40,
|
||||
tickPixelInterval: 40,
|
||||
tickWidth: 0, // spacing between sections of loop
|
||||
lineWidth: 0,
|
||||
title: {
|
||||
text: opts.yAxis.title,
|
||||
style: {
|
||||
color: '#000000',
|
||||
fontFamily: 'NicoPups',
|
||||
fontSize: '16px'
|
||||
}
|
||||
},
|
||||
labels: opts.labels,
|
||||
|
||||
// tips to make them overlap nicely without rounded transitions
|
||||
// https://jsfiddle.net/gh/get/library/pure/highcharts/highcharts/tree/master/samples/highcharts/xaxis/plotbands-gauge-borderradius
|
||||
plotBands: [{
|
||||
from: 1,
|
||||
to: score,
|
||||
color: {
|
||||
pattern: {
|
||||
image: 'https://usetrmnl.com/images/grayscale/gray-2.png',
|
||||
width: 12,
|
||||
height: 12
|
||||
}
|
||||
},
|
||||
innerRadius: '82%',
|
||||
borderRadius: '50%'
|
||||
}, {
|
||||
from: (score + 1),
|
||||
to: 100,
|
||||
color: {
|
||||
pattern: {
|
||||
image: 'https://usetrmnl.com/images/grayscale/gray-5.png',
|
||||
width: 12,
|
||||
height: 12
|
||||
}
|
||||
},
|
||||
innerRadius: '82%',
|
||||
borderRadius: '50%',
|
||||
}]
|
||||
},
|
||||
|
||||
series: [{
|
||||
name: 'Score',
|
||||
data: [score],
|
||||
dataLabels: {
|
||||
borderWidth: 0,
|
||||
style: {
|
||||
fontSize: opts.series.fontSize,
|
||||
fontWeight: opts.series.fontWeight || '400',
|
||||
fontFamily: opts.series.fontFamily || 'inherit'
|
||||
}
|
||||
}
|
||||
}],
|
||||
|
||||
credits: {
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function textRating(score) {
|
||||
if (score <= 50) {
|
||||
return 'Low';
|
||||
} else if (score <= 65) {
|
||||
return 'Pay Attention';
|
||||
} else if (score < 80) {
|
||||
return 'Fair';
|
||||
} else {
|
||||
return 'Good';
|
||||
}
|
||||
}
|
||||
|
||||
// headings - last week averages per day
|
||||
dailyScores.forEach((score, idx) => {
|
||||
let opts = {
|
||||
title: null,
|
||||
labels: { enabled: false },
|
||||
series: {
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
fontFamily: 'NicoClean'
|
||||
},
|
||||
yAxis: {
|
||||
title: null
|
||||
}
|
||||
}
|
||||
createGauge(score, idx, opts);
|
||||
})
|
||||
|
||||
// main content - weekly summary
|
||||
createGauge(weeklyScore, 'all', {
|
||||
title: null,
|
||||
height: '80%',
|
||||
labels: {
|
||||
distance: 15
|
||||
},
|
||||
series: {
|
||||
fontSize: '3em',
|
||||
fontWeight: '550'
|
||||
},
|
||||
yAxis: {
|
||||
title: textRating(weeklyScore)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="view view--half_horizontal">
|
||||
<div class="layout layout--row">
|
||||
</div>
|
||||
|
||||
<%= render "plugins/#{@plugin.keyname}/shared/title_bar", instance_name: instance_name %>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="view view--half_vertical">
|
||||
<div class="layout layout--col gap--xlarge">
|
||||
</div>
|
||||
|
||||
<%= render "plugins/#{@plugin.keyname}/shared/title_bar", instance_name: instance_name %>
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="view view--quadrant">
|
||||
<div class="layout layout--col gap--xlarge">
|
||||
</div>
|
||||
|
||||
<%= render "plugins/#{@plugin.keyname}/shared/title_bar", instance_name: instance_name %>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="title_bar">
|
||||
<%= image_tag plugin_image_path("eight_sleep--render.svg"), class: "image", alt: "Eight Sleep plugin icon" %>
|
||||
<h1 class="title">Eight Sleep</h1>
|
||||
<span class="instance"><%= instance_name %></span>
|
||||
</div>
|
||||
Reference in New Issue
Block a user