Update eight_sleep plugin from core repo

This commit is contained in:
Ryan Kulp
2025-11-10 17:32:03 -05:00
parent 7859a2b8fa
commit 2ff78e2cd3
8 changed files with 383 additions and 0 deletions
+1
View File
@@ -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)
+91
View File
@@ -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
+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 %>
+251
View File
@@ -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>
+6
View File
@@ -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>