init
1
.frontmatter/database/mediaDb.json
Normal file
@ -0,0 +1 @@
|
||||
{"dev":{"blog.neet.works":{"static":{},"resources":{"_gen":{"images":{"posts":{"watercooling-my-homelab":{}}}}}}}}
|
1
.frontmatter/database/pinnedItemsDb.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
1
.frontmatter/database/taxonomyDb.json
Normal file
@ -0,0 +1 @@
|
||||
{"taxonomy":{"tags":["alphacool","aquacomputer","arduino","homelab","intel","nvidia","supermicro","watercooling"],"categories":["diy","homelab","servers","watercooling"]}}
|
0
.hugo_build.lock
Normal file
5
archetypes/default.md
Normal file
@ -0,0 +1,5 @@
|
||||
+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
54
assets/css/custom.css
Normal file
@ -0,0 +1,54 @@
|
||||
.max-w-prose {
|
||||
max-width:120ch
|
||||
}
|
||||
|
||||
.panzoom-popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
cursor: zoom-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panzoom-popup img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: grab;
|
||||
vertical-align: middle;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.panzoom-popup .panzoom-overlay {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panzoom-container {
|
||||
cursor: zoom-in;
|
||||
display: inline-block; /* Shrink-wrap the content */
|
||||
max-width: fit-content; /* Ensure the container doesn't grow larger than its content */
|
||||
max-height: fit-content; /* Restrict height similarly */
|
||||
margin: 0 auto; /* Optional: Center the container if necessary */
|
||||
overflow: hidden; /* Ensure no content overflows */
|
||||
}
|
||||
|
||||
.panzoom-container img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
width: auto; /* Allow it to size naturally */
|
||||
max-width: 100%; /* Prevent overflow */
|
||||
}
|
||||
|
32
assets/css/schemes/patchouli.css
Normal file
@ -0,0 +1,32 @@
|
||||
:root { --color-neutral: 255, 255, 255;
|
||||
--color-neutral-50: 255,255,255;
|
||||
--color-neutral-100: 255,255,255;
|
||||
--color-neutral-200: 255,255,255;
|
||||
--color-neutral-300: 223,203,232;
|
||||
--color-neutral-400: 190,149,209;
|
||||
--color-neutral-500: 157,96,186;
|
||||
--color-neutral-600: 138,73,168;
|
||||
--color-neutral-700: 114,61,140;
|
||||
--color-neutral-800: 91,48,111;
|
||||
--color-neutral-900: 68,36,83;
|
||||
--color-primary-50: 255,255,255;
|
||||
--color-primary-100: 242,238,249;
|
||||
--color-primary-200: 199,181,229;
|
||||
--color-primary-300: 157,125,209;
|
||||
--color-primary-400: 114,68,189;
|
||||
--color-primary-500: 80,47,134;
|
||||
--color-primary-600: 62,36,104;
|
||||
--color-primary-700: 44,26,74;
|
||||
--color-primary-800: 26,15,43;
|
||||
--color-primary-900: 8,5,13;
|
||||
--color-secondary-50: 255,255,255;
|
||||
--color-secondary-100: 255,255,255;
|
||||
--color-secondary-200: 255,255,255;
|
||||
--color-secondary-300: 255,255,255;
|
||||
--color-secondary-400: 255,255,255;
|
||||
--color-secondary-500: 255,215,233;
|
||||
--color-secondary-600: 255,174,211;
|
||||
--color-secondary-700: 255,133,188;
|
||||
--color-secondary-800: 255,93,166;
|
||||
--color-secondary-900: 255,52,143;
|
||||
}
|
133
assets/js/panzoom-util.js
Normal file
@ -0,0 +1,133 @@
|
||||
// AI GENNED AS FUCK NGL
|
||||
|
||||
// Define the handler variables outside of any event listener
|
||||
let activeContainer = null;
|
||||
let panzoomInstance = null;
|
||||
let isProcessingClick = false;
|
||||
let isInitialized = false; // Add a flag to prevent multiple initializations
|
||||
|
||||
// Handle keyboard events
|
||||
function handleKeyPress(e) {
|
||||
if (e.key === "Escape" && activeContainer) {
|
||||
closePopup();
|
||||
}
|
||||
}
|
||||
|
||||
// Function to close the popup and cleanup
|
||||
function closePopup() {
|
||||
if (activeContainer) {
|
||||
// Remove the keyboard event listener
|
||||
document.removeEventListener("keydown", handleKeyPress);
|
||||
|
||||
// Clean up Panzoom instance
|
||||
if (panzoomInstance && typeof panzoomInstance.destroy === 'function') {
|
||||
panzoomInstance.destroy();
|
||||
}
|
||||
|
||||
activeContainer.remove();
|
||||
activeContainer = null;
|
||||
panzoomInstance = null;
|
||||
isProcessingClick = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the panzoom functionality
|
||||
function initializePanzoom() {
|
||||
if (isInitialized) return; // Prevent multiple initializations
|
||||
|
||||
// Create a single delegated click handler at the document level
|
||||
document.body.addEventListener('click', function(event) {
|
||||
// First, check if we clicked a zoomable image
|
||||
const clickedImage = event.target.closest('.zoomable');
|
||||
if (!clickedImage) return; // Exit if we didn't click a zoomable image
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Stop event from bubbling
|
||||
|
||||
// If there's already a container or we're processing a click, don't create another
|
||||
if (activeContainer || isProcessingClick) {
|
||||
console.log('Already processing or container exists'); // Debug log
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingClick = true;
|
||||
|
||||
// Create the popup container
|
||||
activeContainer = document.createElement("div");
|
||||
activeContainer.className = "panzoom-popup";
|
||||
|
||||
// Use the clicked image's source and alt
|
||||
const imgSrc = clickedImage.dataset.src || clickedImage.src;
|
||||
const imgAlt = clickedImage.alt || '';
|
||||
|
||||
activeContainer.innerHTML = `
|
||||
<div class="panzoom-overlay">
|
||||
<img src="${imgSrc}" alt="${imgAlt}" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(activeContainer);
|
||||
|
||||
// Initialize Panzoom on the image
|
||||
const popupImage = activeContainer.querySelector("img");
|
||||
|
||||
// Wait for the image to load before initializing Panzoom
|
||||
popupImage.onload = () => {
|
||||
// Get the image and viewport dimensions
|
||||
const imageWidth = popupImage.naturalWidth;
|
||||
const imageHeight = popupImage.naturalHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Calculate the scaling factor to fit the image within the viewport
|
||||
const scaleX = viewportWidth / imageWidth;
|
||||
const scaleY = viewportHeight / imageHeight;
|
||||
|
||||
// Use the smaller scaling factor to ensure the image fits within the viewport
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
// Calculate the initial x and y coordinates to center the image after scaling
|
||||
const startX = (viewportWidth - imageWidth * scale) / 2;
|
||||
const startY = (viewportHeight - imageHeight * scale) / 2;
|
||||
|
||||
console.log(startX, startY, imageWidth, imageHeight, viewportWidth, viewportHeight)
|
||||
|
||||
// But only modifying y is necessary because centering works VIA CESSPOOL HORIZONTALLY BUT NOT VERTICALLY
|
||||
panzoomInstance = Panzoom(popupImage, {
|
||||
startY: startY,
|
||||
startScale: 1,
|
||||
maxScale: 5
|
||||
});
|
||||
|
||||
// Enable wheel zoom
|
||||
activeContainer.addEventListener("wheel", panzoomInstance.zoomWithWheel);
|
||||
isProcessingClick = false;
|
||||
};
|
||||
|
||||
// Handle errors
|
||||
popupImage.onerror = () => {
|
||||
console.error('Failed to load image:', imgSrc);
|
||||
closePopup();
|
||||
isProcessingClick = false;
|
||||
};
|
||||
|
||||
// Close popup when clicking outside the image
|
||||
activeContainer.addEventListener("click", function (e) {
|
||||
if (e.target === activeContainer || e.target.classList.contains("panzoom-overlay")) {
|
||||
closePopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Add keyboard support for closing
|
||||
document.addEventListener("keydown", handleKeyPress);
|
||||
});
|
||||
|
||||
isInitialized = true; // Mark as initialized
|
||||
}
|
||||
|
||||
// Initialize only once when the DOM is loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePanzoom);
|
||||
} else {
|
||||
initializePanzoom();
|
||||
}
|
6
assets/js/panzoom.min.js
vendored
Normal file
23
assets/js/shortcodes/gallery.js
Normal file
@ -0,0 +1,23 @@
|
||||
function _getDefaultPackeryOptions() {
|
||||
return {
|
||||
percentPosition: true,
|
||||
gutter: 5,
|
||||
resize: true,
|
||||
itemSelector: '.panzoom-container'
|
||||
};
|
||||
}
|
||||
|
||||
(function init() {
|
||||
|
||||
$(window).on("load", function () {
|
||||
let packeries = [];
|
||||
let nodeGalleries = document.querySelectorAll('.gallery');
|
||||
|
||||
nodeGalleries.forEach(nodeGallery => {
|
||||
// TODO : implement a reader of Packery configuration _getPackeryOptions; for example by reading data-attribute
|
||||
let packery = new Packery(nodeGallery, _getDefaultPackeryOptions());
|
||||
packeries.push(packery);
|
||||
});
|
||||
console.groupEnd();
|
||||
});
|
||||
})();
|
74
config/_default/hugo.toml
Normal file
@ -0,0 +1,74 @@
|
||||
# -- Site Configuration --
|
||||
# Refer to the theme docs for more details about each of these parameters.
|
||||
# https://blowfish.page/docs/getting-started/
|
||||
|
||||
theme = "blowfish" # UNCOMMENT THIS LINE
|
||||
baseURL = "https://blog.neet.works/"
|
||||
defaultContentLanguage = "en"
|
||||
|
||||
# pluralizeListTitles = "true" # hugo function useful for non-english languages, find out more in https://gohugo.io/getting-started/configuration/#pluralizelisttitles
|
||||
|
||||
enableRobotsTXT = true
|
||||
summaryLength = 0
|
||||
|
||||
buildDrafts = false
|
||||
buildFuture = false
|
||||
|
||||
enableEmoji = true
|
||||
|
||||
# googleAnalytics = "G-XXXXXXXXX"
|
||||
|
||||
[pagination]
|
||||
pagerSize = 100
|
||||
|
||||
[imaging]
|
||||
anchor = 'Center'
|
||||
[imaging.exif]
|
||||
disableDate = true
|
||||
disableLatLong = true
|
||||
excludeFields = ''
|
||||
includeFields = 'Orientation'
|
||||
|
||||
[taxonomies]
|
||||
tag = "tags"
|
||||
category = "categories"
|
||||
author = "authors"
|
||||
series = "series"
|
||||
|
||||
[sitemap]
|
||||
changefreq = 'daily'
|
||||
filename = 'sitemap.xml'
|
||||
priority = 0.5
|
||||
|
||||
[outputs]
|
||||
home = ["HTML", "RSS", "JSON"]
|
||||
|
||||
[related]
|
||||
threshold = 0
|
||||
toLower = false
|
||||
|
||||
[[related.indices]]
|
||||
name = "tags"
|
||||
weight = 100
|
||||
|
||||
[[related.indices]]
|
||||
name = "categories"
|
||||
weight = 100
|
||||
|
||||
[[related.indices]]
|
||||
name = "series"
|
||||
weight = 50
|
||||
|
||||
[[related.indices]]
|
||||
name = "authors"
|
||||
weight = 20
|
||||
|
||||
[[related.indices]]
|
||||
name = "date"
|
||||
weight = 10
|
||||
|
||||
[[related.indices]]
|
||||
applyFilter = false
|
||||
name = 'fragmentrefs'
|
||||
type = 'fragments'
|
||||
weight = 10
|
73
config/_default/languages.en.toml
Normal file
@ -0,0 +1,73 @@
|
||||
disabled = false
|
||||
languageCode = "en"
|
||||
languageName = "English"
|
||||
weight = 1
|
||||
title = "N.E.E.T. Works"
|
||||
|
||||
[params]
|
||||
displayName = "EN"
|
||||
isoCode = "en"
|
||||
rtl = false
|
||||
dateFormat = "January 2 2006"
|
||||
logo = "img/logo.png"
|
||||
secondaryLogo = "img/secondary-logo.png"
|
||||
description = "random tech stuff from a sane and normal individual"
|
||||
copyright = "*© { year } rawhide kobayashi | **Original content** licensed under **[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)** unless otherwise noted.*"
|
||||
|
||||
[params.author]
|
||||
name = "rawhide kobayashi"
|
||||
email = "rawhide@neet.works"
|
||||
image = "img/blowfish_logo.png"
|
||||
imageQuality = 96
|
||||
headline = "beep-boop maggot!"
|
||||
bio = "ᗜ⩊ᗜ"
|
||||
links = [
|
||||
{ email = "mailto:rawhide@neet.works" },
|
||||
#{ link = "https://link-to-some-website.com/" },
|
||||
#{ amazon = "https://www.amazon.com/hz/wishlist/ls/wishlist-id" },
|
||||
#{ apple = "https://www.apple.com" },
|
||||
#{ blogger = "https://username.blogspot.com/" },
|
||||
#{ bluesky = "https://bsky.app/profile/username" },
|
||||
#{ codepen = "https://codepen.io/username" },
|
||||
#{ dev = "https://dev.to/username" },
|
||||
#{ discord = "https://discord.gg/invitecode" },
|
||||
#{ dribbble = "https://dribbble.com/username" },
|
||||
#{ facebook = "https://facebook.com/username" },
|
||||
#{ flickr = "https://www.flickr.com/photos/username/" },
|
||||
#{ foursquare = "https://foursquare.com/username" },
|
||||
{ github = "https://github.com/rawhide-kobayashi" },
|
||||
#{ gitlab = "https://gitlab.com/username" },
|
||||
#{ google = "https://www.google.com/" },
|
||||
#{ hashnode = "https://username.hashnode.dev" },
|
||||
#{ instagram = "https://instagram.com/username" },
|
||||
#{ itch-io = "https://username.itch.io" },
|
||||
#{ keybase = "https://keybase.io/username" },
|
||||
#{ kickstarter = "https://www.kickstarter.com/profile/username" },
|
||||
#{ lastfm = "https://lastfm.com/user/username" },
|
||||
#{ linkedin = "https://linkedin.com/in/username" },
|
||||
#{ mastodon = "https://mastodon.instance/@username" },
|
||||
#{ medium = "https://medium.com/username" },
|
||||
#{ microsoft = "https://www.microsoft.com/" },
|
||||
#{ orcid = "https://orcid.org/userid" },
|
||||
#{ patreon = "https://www.patreon.com/username" },
|
||||
#{ pinterest = "https://pinterest.com/username" },
|
||||
#{ reddit = "https://reddit.com/user/username" },
|
||||
#{ researchgate = "https://www.researchgate.net/profile/username" },
|
||||
#{ slack = "https://workspace.url/team/userid" },
|
||||
#{ snapchat = "https://snapchat.com/add/username" },
|
||||
#{ soundcloud = "https://soundcloud.com/username" },
|
||||
#{ spotify = "https://open.spotify.com/user/userid" },
|
||||
#{ stack-overflow = "https://stackoverflow.com/users/userid/username" },
|
||||
#{ steam = "https://steamcommunity.com/profiles/userid" },
|
||||
#{ telegram = "https://t.me/username" },
|
||||
#{ threads = "https://www.threads.net/@username" },
|
||||
#{ tiktok = "https://tiktok.com/@username" },
|
||||
#{ tumblr = "https://username.tumblr.com" },
|
||||
#{ twitch = "https://twitch.tv/username" },
|
||||
#{ twitter = "https://twitter.com/username" },
|
||||
#{ x-twitter = "https://twitter.com/username" },
|
||||
#{ whatsapp = "https://wa.me/phone-number" },
|
||||
#{ youtube = "https://youtube.com/username" },
|
||||
#{ ko-fi = "https://ko-fi.com/username" },
|
||||
#{ codeberg = "https://codeberg.org/username"},
|
||||
]
|
13
config/_default/markup.toml
Normal file
@ -0,0 +1,13 @@
|
||||
# -- Markup --
|
||||
# These settings are required for the theme to function.
|
||||
|
||||
[goldmark]
|
||||
[goldmark.renderer]
|
||||
unsafe = true
|
||||
|
||||
[highlight]
|
||||
noClasses = false
|
||||
|
||||
[tableOfContents]
|
||||
startLevel = 2
|
||||
endLevel = 4
|
69
config/_default/menus.en.toml
Normal file
@ -0,0 +1,69 @@
|
||||
# -- Main Menu --
|
||||
# The main menu is displayed in the header at the top of the page.
|
||||
# Acceptable parameters are name, pageRef, page, url, title, weight.
|
||||
#
|
||||
# The simplest menu configuration is to provide:
|
||||
# name = The name to be displayed for this menu link
|
||||
# pageRef = The identifier of the page or section to link to
|
||||
#
|
||||
# By default the menu is ordered alphabetically. This can be
|
||||
# overridden by providing a weight value. The menu will then be
|
||||
# ordered by weight from lowest to highest.
|
||||
|
||||
[[main]]
|
||||
name = "Blog"
|
||||
pageRef = "posts"
|
||||
weight = 10
|
||||
|
||||
#[[main]]
|
||||
# name = "Parent"
|
||||
# weight = 20
|
||||
|
||||
#[[main]]
|
||||
# name = "example sub-menu 1"
|
||||
# parent = "Parent"
|
||||
# pageRef = "posts"
|
||||
# weight = 20
|
||||
|
||||
#[[main]]
|
||||
# name = "example sub-menu 2"
|
||||
# parent = "Parent"
|
||||
# pageRef = "posts"
|
||||
# weight = 20
|
||||
|
||||
#[[subnavigation]]
|
||||
# name = "An interesting topic"
|
||||
# pageRef = "tags/interesting-topic"
|
||||
# weight = 10
|
||||
|
||||
#[[subnavigation]]
|
||||
# name = "My Awesome Category"
|
||||
# pre = "github"
|
||||
# pageRef = "categories/awesome"
|
||||
# weight = 20
|
||||
|
||||
[[main]]
|
||||
name = "Categories"
|
||||
pageRef = "categories"
|
||||
weight = 20
|
||||
|
||||
[[main]]
|
||||
name = "Tags"
|
||||
pageRef = "tags"
|
||||
weight = 30
|
||||
|
||||
|
||||
# -- Footer Menu --
|
||||
# The footer menu is displayed at the bottom of the page, just before
|
||||
# the copyright notice. Configure as per the main menu above.
|
||||
|
||||
|
||||
# [[footer]]
|
||||
# name = "Tags"
|
||||
# pageRef = "tags"
|
||||
# weight = 10
|
||||
|
||||
# [[footer]]
|
||||
# name = "Categories"
|
||||
# pageRef = "categories"
|
||||
# weight = 20
|
3
config/_default/module.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[hugoVersion]
|
||||
extended = false
|
||||
min = "0.87.0"
|
165
config/_default/params.toml
Normal file
@ -0,0 +1,165 @@
|
||||
# -- Theme Options --
|
||||
# These options control how the theme functions and allow you to
|
||||
# customise the display of your website.
|
||||
#
|
||||
# Refer to the theme docs for more details about each of these parameters.
|
||||
# https://blowfish.page/docs/configuration/#theme-parameters
|
||||
|
||||
colorScheme = "patchouli"
|
||||
defaultAppearance = "light" # valid options: light or dark
|
||||
autoSwitchAppearance = true
|
||||
|
||||
enableSearch = true
|
||||
enableCodeCopy = true
|
||||
|
||||
replyByEmail = true
|
||||
|
||||
# mainSections = ["section1", "section2"]
|
||||
# robots = ""
|
||||
|
||||
disableImageZoom = true
|
||||
|
||||
disableImageOptimization = false
|
||||
disableTextInHeader = false
|
||||
# backgroundImageWidth = 1200
|
||||
|
||||
# defaultBackgroundImage = "IMAGE.jpg" # used as default for background images
|
||||
# defaultFeaturedImage = "IMAGE.jpg" # used as default for featured images in all articles
|
||||
|
||||
# highlightCurrentMenuArea = true
|
||||
# smartTOC = true
|
||||
# smartTOCHideUnfocusedChildren = true
|
||||
|
||||
giteaDefaultServer = "https://git.neet.works"
|
||||
# forgejoDefaultServer = "https://v8.next.forgejo.org"
|
||||
|
||||
[header]
|
||||
layout = "basic" # valid options: basic, fixed, fixed-fill, fixed-gradient, fixed-fill-blur
|
||||
|
||||
[footer]
|
||||
showMenu = true
|
||||
showCopyright = true
|
||||
showThemeAttribution = true
|
||||
showAppearanceSwitcher = false
|
||||
showScrollToTop = true
|
||||
|
||||
[homepage]
|
||||
layout = "background" # valid options: page, profile, hero, card, background, custom
|
||||
#homepageImage = "IMAGE.jpg" # used in: hero, and card
|
||||
showRecent = true
|
||||
showRecentItems = 9
|
||||
showMoreLink = true
|
||||
showMoreLinkDest = "/posts/"
|
||||
cardView = false
|
||||
cardViewScreenWidth = false
|
||||
layoutBackgroundBlur = false # only used when layout equals background
|
||||
|
||||
[article]
|
||||
showDate = true
|
||||
showViews = false
|
||||
showLikes = false
|
||||
showDateOnlyInArticle = false
|
||||
showDateUpdated = true
|
||||
showAuthor = true
|
||||
# showAuthorBottom = false
|
||||
showHero = true
|
||||
heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
|
||||
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
|
||||
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
|
||||
showBreadcrumbs = false
|
||||
showDraftLabel = true
|
||||
showEdit = false
|
||||
# editURL = "https://github.com/username/repo/"
|
||||
editAppendPath = true
|
||||
seriesOpened = false
|
||||
showHeadingAnchors = true
|
||||
showPagination = true
|
||||
invertPagination = false
|
||||
showReadingTime = false
|
||||
showTableOfContents = true
|
||||
showRelatedContent = true
|
||||
relatedContentLimit = 3
|
||||
showTaxonomies = false
|
||||
showAuthorsBadges = false
|
||||
showWordCount = true
|
||||
# sharingLinks = [ "linkedin", "twitter", "bluesky", "mastodon", "reddit", "pinterest", "facebook", "email", "whatsapp", "telegram"]
|
||||
showZenMode = false
|
||||
|
||||
[list]
|
||||
showHero = false
|
||||
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
|
||||
layoutBackgroundBlur = true # only used when heroStyle equals background or thumbAndBackground
|
||||
layoutBackgroundHeaderSpace = true # only used when heroStyle equals background
|
||||
showBreadcrumbs = false
|
||||
showSummary = false
|
||||
showViews = false
|
||||
showLikes = false
|
||||
showTableOfContents = false
|
||||
showCards = false
|
||||
orderByWeight = false
|
||||
groupByYear = true
|
||||
cardView = false
|
||||
cardViewScreenWidth = false
|
||||
constrainItemsWidth = false
|
||||
|
||||
[sitemap]
|
||||
excludedKinds = ["taxonomy", "term"]
|
||||
|
||||
[taxonomy]
|
||||
showTermCount = true
|
||||
showHero = false
|
||||
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
|
||||
showBreadcrumbs = false
|
||||
showViews = false
|
||||
showLikes = false
|
||||
showTableOfContents = false
|
||||
cardView = false
|
||||
|
||||
[term]
|
||||
showHero = false
|
||||
# heroStyle = "background" # valid options: basic, big, background, thumbAndBackground
|
||||
showBreadcrumbs = false
|
||||
showViews = false
|
||||
showLikes = false
|
||||
showTableOfContents = true
|
||||
groupByYear = false
|
||||
cardView = false
|
||||
cardViewScreenWidth = false
|
||||
|
||||
[firebase]
|
||||
# apiKey = "XXXXXX"
|
||||
# authDomain = "XXXXXX"
|
||||
# projectId = "XXXXXX"
|
||||
# storageBucket = "XXXXXX"
|
||||
# messagingSenderId = "XXXXXX"
|
||||
# appId = "XXXXXX"
|
||||
# measurementId = "XXXXXX"
|
||||
|
||||
[fathomAnalytics]
|
||||
# site = "ABC12345"
|
||||
# domain = "llama.yoursite.com"
|
||||
|
||||
[umamiAnalytics]
|
||||
# websiteid = "ABC12345"
|
||||
# domain = "llama.yoursite.com"
|
||||
# dataDomains = "yoursite.com,yoursite2.com"
|
||||
# scriptName = ""
|
||||
# enableTrackEvent = true
|
||||
|
||||
[selineAnalytics]
|
||||
# token = "XXXXXX"
|
||||
# enableTrackEvent = true
|
||||
|
||||
[buymeacoffee]
|
||||
# identifier = ""
|
||||
# globalWidget = true
|
||||
# globalWidgetMessage = "Hello"
|
||||
# globalWidgetColor = "#FFDD00"
|
||||
# globalWidgetPosition = "Right"
|
||||
|
||||
[verification]
|
||||
# google = ""
|
||||
# bing = ""
|
||||
# pinterest = ""
|
||||
# yandex = ""
|
||||
# fediverse = ""
|
BIN
content/posts/watercooling-my-homelab/Block_Mod_Detail_A.jpg
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
content/posts/watercooling-my-homelab/Block_Mod_Detail_B.jpg
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
content/posts/watercooling-my-homelab/Coldplate.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
content/posts/watercooling-my-homelab/Different_Plugs.jpg
Normal file
After Width: | Height: | Size: 8.3 MiB |
BIN
content/posts/watercooling-my-homelab/Dual_Blocks_Zoom.jpg
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
content/posts/watercooling-my-homelab/GPUs_Installed.jpg
Normal file
After Width: | Height: | Size: 9.3 MiB |
BIN
content/posts/watercooling-my-homelab/MO-RA!.jpg
Normal file
After Width: | Height: | Size: 3.0 MiB |
BIN
content/posts/watercooling-my-homelab/Mounting_Detail.jpg
Normal file
After Width: | Height: | Size: 2.0 MiB |
BIN
content/posts/watercooling-my-homelab/Tall_Capacitor.jpg
Normal file
After Width: | Height: | Size: 6.5 MiB |
BIN
content/posts/watercooling-my-homelab/Test_Fit.jpg
Normal file
After Width: | Height: | Size: 2.6 MiB |
BIN
content/posts/watercooling-my-homelab/Triple_Card_Jank.jpg
Normal file
After Width: | Height: | Size: 7.2 MiB |
BIN
content/posts/watercooling-my-homelab/complete_assembly.jpg
Normal file
After Width: | Height: | Size: 2.3 MiB |
BIN
content/posts/watercooling-my-homelab/featured.jpg
Normal file
After Width: | Height: | Size: 2.8 MiB |
BIN
content/posts/watercooling-my-homelab/goopy_installed.jpg
Normal file
After Width: | Height: | Size: 9.0 MiB |
351
content/posts/watercooling-my-homelab/index.md
Normal file
@ -0,0 +1,351 @@
|
||||
---
|
||||
title: Watercooling My Homelab
|
||||
description: Watercooling for my homelab with a custom, leak-resistant controller and monitoring!
|
||||
date: 2024-11-27T02:14:28.867Z
|
||||
preview: /Supermicro_846_Internal.jpg
|
||||
draft: true
|
||||
tags:
|
||||
- alphacool
|
||||
- arduino
|
||||
- intel
|
||||
- nvidia
|
||||
- supermicro
|
||||
- aquacomputer
|
||||
categories:
|
||||
- homelab
|
||||
- servers
|
||||
- watercooling
|
||||
- diy
|
||||
---
|
||||
|
||||
## Overview
|
||||
Watercooling - or, more accurately, custom loop watercooling[^badnomenclature] over AIOs - has increasingly transitioned to an aesthetic choice rather than a practical one in the consumer gaming space, with more energy efficient chips overclocked out the wazoo from the factory and relatively minimal gains to be made. Whereas, in the enterprise space, with ever-increasing power density, it's becoming the only option for many types of deployments. A lot of people are intimidated by custom watercooling, especially when it comes to their costly (in terms of cash, or time, or both) homelab setups. In this post I'm going to showcase my solution for a leak-resistant watercooling system with monitoring that I trust to protect my beloved rack from the horrors of water damage as well as thermal throttling.
|
||||
|
||||
## The Hardware
|
||||
Most of the build uses pretty standard off-the-shelf parts for PC watercooling, but there are a few bits and pieces that most builders won't have seen before, and a couple custom solutions that provided a better experience than what standard PC parts can offer. The control system is 100% custom, based on an Arduino Uno clone that feeds vital statistics to the server over serial, and features Dallas 1-wire digital temperature probes and an interesting pressure gauge that I will expand on further down.
|
||||
|
||||
### Off-the-shelf
|
||||
#### General Details
|
||||
{{< panzoom-figure
|
||||
src="MO-RA!.jpg"
|
||||
alt="A MO-RA V3 360 PRO PC Watercooling Radiator from [Watercool](https://watercool.de)"
|
||||
caption="A MO-RA V3 360 PRO PC Watercooling Radiator from [Watercool](https://watercool.de)"
|
||||
>}}
|
||||
|
||||
The centerpiece of the build, which the control unit and pump mount to, is the "MOther of all RAdiators", version 3, from Watercool. This is the 360mm version with support for up to 18 fans. It's constructed more in the spirit of a vehicle radiator than a traditional PC radiator, with a less restrictive fin stack and large, round tubes rather than thin rectangular ones. It provides several mounting points for accessories which I was able to utilize to secure it to my server rack in a satisfactorily sturdy fashion. An in-depth teardown on the construction method and material quality of the MO-RA can be found on [igor'sLAB](https://www.igorslab.de/en/the-big-radiator-material-test-how-much-copper-and-technology-is-in-the-watercool-mo-ra3-360-pro-part-4/). For fans, I have a collection of old Corsair Air Series SP120s, from the days before we had RGB and PWM control on everything. They've all been retired from regular use, because of noise-related aging issues. In fact, one of them failed to turn at all once I had everything wired up, and another had its bearing disintegrate (and I really mean disintegrate, the fan became almost entirely un-born and would consistently ram into its own frame) about 8 weeks after putting the thing into service. That being said, they did survive (and continue to survive, in the remaining 16 cases) 24/7 use for anywhere from 4-10 years, at bottom of the barrel pricing, so that's not too bad.
|
||||
|
||||
{{< panzoom-figure
|
||||
src="Mounting_Detail.jpg"
|
||||
alt="Simple, cheap aluminum bars and angles mount to the studs on the radiator and into the stud holes on the server rack."
|
||||
caption="Simple, cheap aluminum bars and angles mount to the studs on the radiator and into the stud holes on the server rack."
|
||||
>}}
|
||||
|
||||
I got a secondhand Corsair XD5 pump/res combo from eBay for about sixty bucks, which is pretty good for a genuine D5-based pump/res combo. It has PWM support which I did wire up, but the flow rate ended up being so low at 100% that I just run it at 100% all the time. The flow rate is measured through an [aquacomputer flow sensor](https://shop.aquacomputer.de/Monitoring-and-Controlling/Sensors/Flow-sensor-high-flow-LT-G1-4::3951.html), although based on the rather vague calibration guide in the manual, I'm not exactly confident in the exact-ness of my readings with it. In either case, the temperature deltas between the blocks and the water have been more than adequate at whatever the true flow rate is.
|
||||
|
||||
{{< panzoom-figure
|
||||
src="Triple_Card_Jank.jpg"
|
||||
alt="My initial setup with 3x 2080 Tis air cooled, using m.2 NVMe to PCIe risers in an ASUS prebuilt. Two are connected by NVLINK, which I found to provide a slight performance benefit on the order of ~1-5% in multi-GPU training and inference, but not really worth the cost most people are charging for them these days..."
|
||||
caption="My initial setup with 3x 2080 Tis air cooled, using m.2 NVMe to PCIe risers in an ASUS prebuilt. Two are connected by NVLINK, which I found to provide a slight performance benefit on the order of ~1-5% in multi-GPU training and inference, but not really worth the cost most people are charging for them these days..."
|
||||
>}}
|
||||
|
||||
#### CPUs
|
||||
The parts that I'm cooling are dual Xeon Gold 6154s, which are Skylake-SP architecture. This specific SKU has 18 cores with sustained all-core speeds of 3.7GHz SSE / 3.3GHz AVX2 / 2.7GHz AVX512, and a TDP of 200 watts. I've observed them running as high as 220 watts in sustained loads under watercooled conditions, though.
|
||||
|
||||
{{< panzoom-figure
|
||||
src="Coldplate.jpg"
|
||||
alt="Alphacool Eisblock XPX Pro coldplate. Image credit & copyright - [igor'sLAB](https://www.igorslab.de/en/ryzen-threadripper-2990-wx-with-500-w-alphacool-iceblock-xpx-aurora-pro-plexi-digital-rgb-in-test/)"
|
||||
caption="Alphacool Eisblock XPX Pro coldplate Image credit & copyright - [igor'sLAB](https://www.igorslab.de/en/ryzen-threadripper-2990-wx-with-500-w-alphacool-iceblock-xpx-aurora-pro-plexi-digital-rgb-in-test/)"
|
||||
>}}
|
||||
|
||||
The CPU waterblocks are Alphacool Eisblock XPX Pro Aurora Light models, which are significantly cheaper than the XPX Aurora Pro not-light version. They appear to be entirely identical, functionally... I'm not sure if there any actual performance benefits offered by the not-light version. It's a relatively obscure block family without many good reviews, which makes sense, given this block is designed for full coverage on Xeons/Threadrippers. It would comically outsize consumer processors and unnecessarily restrict your flow, although it does come with brackets for consumer sockets should you really want to use one with them. The coldplate appears to be skived, which is uncommon in this price bracket for a discrete block, and the fins are incredibly short and dense. A single instance of this block would, alone, restrict your loop's flow to a ridiculous degree, though people worry far too much about flow rate. I've seen people mounting three D5s to the MO-RA to cool a single CPU and GPU with much less restrictive fins... In my case, at maximum load, the maximum core temperature delta relative to the water temperature at radiator outflow is 25\*C, with a ~1-2\*C average delta between the two serially-connected sockets at a (supposed) flow rate of ~130L/h.
|
||||
|
||||
{{< panzoom-figure
|
||||
src="Dual_Blocks_Zoom.jpg"
|
||||
alt="Interior view of the Supermicro CSE-846 chassis showcasing the installed waterblocks and other components."
|
||||
caption="Interior view of the Supermicro CSE-846 chassis showcasing the installed waterblocks and other components."
|
||||
>}}
|
||||
|
||||
This case did have a shroud for forced airflow over passive heatsinks, but the setup proved insufficient for the 6154s. Under sustained all-core loads they would reach thermal saturation and start throttling within minutes, even on full jumbo-jet takeoff screech mode... Which is not cool. Particularly if I ever upgrade to the off-roadmap SKUs with TDPs of up to 240w.
|
||||
|
||||
#### GPUs
|
||||
I had already been thinking about watercooling the GPUs, two 2080 Tis modded with 22GB of VRAM, before I upgraded my server and discovered that its cooling was insufficient. The OEM coolers were... Fine. Nothing special. Loud. Very toasty in a 2 slot configuration that fit my cheapo Quadro NVLINK bridge. I couldn't maintain 1800MHz without alternatively power/thermal throttling depending on the circumstances. The actual temperature is not a conern, despite what some/many/most peopl seem to believe. Thermal cycles - not *long periods of prolonged exposure to 'high'[^thermalfears] temperature*, in and of itself, under reasonable circumstances - are the number one killer of modern GPUs with the enormous dies that they now have. Mismatches in thermal expansion between the die and the substrate will eventually cause the solder joints between them to break, regardless of how well you treat it, so long as you're letting it get hot, then cold, then hot, then cold... The number one way to make sure a modern GPU lives a long life is to reduce its experience of thermal cycles (used mining GPUs are actually better buys than used gaming GPUs, fight me), or reduce the extremity of the cycles. The only thing that temperature actually affects, within manufacturer limits, are boost clocks, and leakage current. A cooler chip will use less power to run at the same clock speed compared to a hotter chip due to reduced leakage current, making them measurably more energy efficient per unit of work. You can see a not-insignificant, measurable drop in total board power draw at the same clock speed by dropping the average core temperature from 80\*C to <40\*C[^citationneeded], as was my experience here. You can also destroy those efficiency gains by further overclocking. It's up to you!
|
||||
{{< panzoom-figure
|
||||
src="GPUs_Installed.jpg"
|
||||
alt="The blocks installed in an ASUS prebuilt gaming tower."
|
||||
caption="The blocks installed in an ASUS prebuilt gaming tower."
|
||||
>}}
|
||||
|
||||
Thus, it follows, provided you don't somehow break things while installing the block, watercooling is the second-best method to ensure the longevity of your GPU behind never using it or always keeping it under full load. It also generally allows the memory to clock a bit higher as it can be kept significantly cooler by the less-heat-saturated surface area of the block compared to a traditional air cooler. Although I can't benchmark the temperature on these cards in particular as they do not expose VRAM temperature sensors, I can confirm that putting them under water allowed the memory to clock marginally higher than under the stock air cooler. They can run at 1800MHz core clock at <15*C delta die temp above the water temperature, which now, thanks to losing the onboard fan and some unquantifiable reduction of leakage current thanks to a significantly reduced temperature, runs this clock speed at ~230w reported board power draw, workload dependent. The board only allows a 280w power limit, although it would likely be possible to flash an alternate vBIOS with a higher limit... But it wouldn't be worth it, efficiency wise, things get bleak when you approach 2GHz on these cards. Some quick searching gave me people reaching 2100MHz - or +16% relative core clock, but at >400 watts... Much like a 3090. 🤔!
|
||||
|
||||
{{< panzoom-gallery caption="The GPU blocks required a *moderate amount of light massaging* to properly fit on these OEM model cards. The power plugs are in a different position and a singular capacitor on these models is slightly taller than on the actual Founder's Edition reference card, but they're otherwise identical. Enough.">}}
|
||||
{{< panzoom-figure
|
||||
src="Block_Mod_Detail_A.jpg"
|
||||
alt="Trimmed area for the capacitor."
|
||||
gallery_class="grid-w25"
|
||||
>}}{{< panzoom-figure
|
||||
src="Block_Mod_Detail_B.jpg"
|
||||
alt="An area of the block cut out to make room for the power plugs."
|
||||
gallery_class="grid-w25"
|
||||
>}}
|
||||
{{< panzoom-figure
|
||||
src="Tall_Capacitor.jpg"
|
||||
alt="Showcasing the capacitor fitting into the trimmed area."
|
||||
gallery_class="grid-w50"
|
||||
>}}
|
||||
{{< panzoom-figure
|
||||
src="Different_Plugs.jpg"
|
||||
alt="Showcasing the plugs fitting into the cutout area."
|
||||
gallery_class="grid-w50"
|
||||
>}}
|
||||
{{< /panzoom-gallery >}}
|
||||
|
||||
The GPU blocks are Phanteks 2080 Ti Founder's Edition blocks. Nothing special, they're just the cheapest matching ones I could find in 2024 that looked like they'd fit these almost-reference-but-not-quite OEM cards without extensive modification. I bought the GPUs from a supplier dedicated to the cause of specifically selling 22GB modded 2080 Tis, [for quite a reasonable price.](https://2080ti22g.com/ "#not an ad, but it could be 🪝☝️😜") It's by far the best value for $/GB VRAM in NVIDIA GPUs,[^pascalbad] although for your usecase, you will have to judge the speed-value proposition compared to used 3090 (Ti)s. Performance improvement in ML tasks between the 2080 Ti and 3090 (Ti) ranges from as little as ~20% to as much as ~100% depending on how memory bandwidth constrained your workload is. With secondhand 3090 (Ti)s still going for minimum $700 on the used market in the US, I found the alternative 2080 Ti option to be more alluring. The idea of having a modded GPU in itself was also appealing and definitely part of why I made that decision. Pulling up a hardware monitor and seeing a 2080 Ti with 22GB of VRAM just feels a little bit naughty, and I like that. It should be noted that I did initially buy three of them, and one of them failed just after the 30 day warranty period listed on their website. However, despite that, they were kind enough to offer a full refund if I covered return shipping, and were very communicative and responded in <24 hours every time I sent them any kind of message/inquiry.
|
||||
|
||||
### Putting the I in DIY
|
||||
{{< panzoom-figure
|
||||
src="Test_Fit.jpg"
|
||||
alt="Plopping all the major components in a box to see what happens in my brain."
|
||||
caption="Plopping all the major components in a box to see what happens in my brain."
|
||||
>}}
|
||||
|
||||
In no particular order, here is a list of the major components involved in the control system.
|
||||
- Generic metal box, that used to contain backup batteries for a PBX system.
|
||||
- Arduino Uno clone, unknown brand
|
||||
- 60mm Corsair fan
|
||||
- RS232 TTL shifter
|
||||
- Aesthetic retro power switch
|
||||
- 12v DC vacuum pump
|
||||
- U.S. Solid 12V NC Solenoid
|
||||
- 12v relay modules
|
||||
- HX711 ADC
|
||||
- MD-PS002 Absolute Pressure Sensors
|
||||
- L298-like PWM motor driver
|
||||
- Apple White iMac PSU
|
||||
- Adafruit Arduino Uno Proto Shield
|
||||
- DS18B20 temperature probes
|
||||
|
||||
Unfortunately, I didn't take excruciatingly detailed pictures of literally every single step of the assembly/prototyping process, but it's not that complicated or interesting in terms of electrical engineering. For the most part, it's just plugging pre-made components together. The most interesting production notes include the pressure sensor and the power supply.
|
||||
|
||||
#### Putting New Life into an iMac PSU
|
||||
Some time ago, my aunt gave me her first-gen Intel White iMac, which is visually very similar to the G5, and it was one of the earliest things that I installed Linux on. I used it as a seedbox for a bit, but eventually took it apart and saved some of the more interesting stuff. The hard drive is still running in my router today!
|
||||
|
||||
{{< panzoom-figure
|
||||
src="schematic_minify.svg"
|
||||
alt="My schematic for the control unit. It's the first time I've used KiCad, and the first time I've ever made a schematic like this at all. I hope it's relatively legible."
|
||||
caption="My schematic for the control unit. It's the first time I've used KiCad, and the first time I've ever made a schematic like this at all. I hope it's relatively legible."
|
||||
>}}
|
||||
|
||||
All the credit goes to the user 'ersterhernd', from [this thread](https://www.tonymacx86.com/threads/imac-isight-model-power-supply-unleashed.150793/) on the [tonymacx86.com](https://tonymacx86.com) forum for figuring out the pinout of this PSU, which is almost entirely identical to the one that was in my unit, apart from the power rating on mine being 200w. There are two banks of pins, half of which are always on, half of which are toggleable. Each bank has 12v, 5v, and 3.3v. I didn't end up using 3.3v for anything other than the power switch. I have no idea what the energy efficiency of this unit is, obviously it doesn't have an 80+ certification... But I'm assuming that Apple would make it at least halfway decent, right? Certainly more than a random 12v power brick with additional converters, I'd hope.
|
||||
|
||||
As you can see in the schematic above, the always-on 3.3v pin is connected to SYS_POWERUP through a relay board. The relay input is pulled low by a single pole switch, which turns the relay on, which connects ground to SYS_POWERUP, engaging the other rail of the power supply. This is kind of a convoluted solution to not having a double-pole switch... But I didn't have a double-pole switch, so that's what I did.
|
||||
|
||||
While I did wire up the pump PWM, tachometer, and fan tachometer, I didn't really end up using them. There's no reason for the pump to ever run below 100%, especially given the restrictive loop, and the tachometer readings I found to be very inconsistent for all but the flow meter. I still haven't figured out for sure what the problem is. My best guess is that the PWM signal for the fans is interfering with the readings for those two pins, somehow... But I'm not sure. In the end, I took fan tachometer monitoring out of the script entirely, as the flow meter will inform of pump failure, and air vs water temperature deltas will inform of total fan failure. I haven't integrated monitoring into the server-side of the script yet, beyond simple stat readouts. I plan to integrate it with Zabbix, once I have that installed... Soon™️.
|
||||
|
||||
#### Measuring vacuum
|
||||
|
||||
For some reason, I had a really hard time finding a vacuum pressur sensor. There are plenty of physical, analogue vacuum gauges available, but an actual, electronic sensor... At least for reasonable prices, located in the US, I could only find ones that measured positive pressure. Maybe I had the wrong search terms. Eventually I found an unpackaged sensor with obscure, not entirely legible datasheets that claimed to have an acceptable pressure range for my application. The [MD-PS002](https://electronperdido.com/wp-content/uploads/2021/12/MD_PS002-Datasheet.zh-CN.en_.pdf) is what I settled on, available on Amazon in the US in a 2-pack for $8. It's a tiny little thing, and it took two attempts to successfully create a sensor package that didn't leak.
|
||||
|
||||
{{< panzoom-gallery caption="Sensor package details, installed and all gooped up.">}}
|
||||
{{< panzoom-figure
|
||||
src="plug_detail_top.jpg"
|
||||
alt="Top view of the sensor JB Welded into the drilled out plug."
|
||||
gallery_class="grid-w50"
|
||||
>}}{{< panzoom-figure
|
||||
src="plug_detail_bottom.jpg"
|
||||
alt="Bottom view of the sensor JB Welded into the drilled out plug."
|
||||
gallery_class="grid-w50"
|
||||
>}}
|
||||
{{< panzoom-figure
|
||||
src="goopy_installed.jpg"
|
||||
alt="Sensor package with additional JB Weld installed into the vacuum tank."
|
||||
gallery_class="grid-w100"
|
||||
>}}
|
||||
{{< /panzoom-gallery >}}
|
||||
|
||||
I drilled a hole in a G1/4" plug, just slightly bigger than the metal ring on the sensor, coated that ring with J-B Weld, and inserted it, letting it cure before grinding away the exterior of the top of the plug and building up more J-B weld to add some strain relief for the wires as well as edge-to-edge sealing. The vacuum loss rate, after running the system for a few months to allow the loop to thoroughly de-gas, is now less than 50mbar per day at -500 to -600mbar. I was slightly worried about the lifetime of the pump, given it's a cheap thing from Amazon, but given it only has to run for about a second every other day, I imagine that won't be an issue.
|
||||
|
||||
This sensor is a wheatstone bridge, which works the same way as load cells for digital scales. The resistance changes are very, very low, thus the signal must be amplified before being fed into an ADC. You could use an op-amp, and feed that signal into an analog input on the Arduino, but I felt more comfortable using the HX711, a two-channel ADC with integrated amplifier designed to be used with wheatstone bridge load cells. Here's a code snippet showing how I converted the raw analog measurement to mbar.
|
||||
|
||||
```c++
|
||||
float pressure_raw_to_mbar(int32_t pressure_raw) {
|
||||
return (pressure_raw - 390000) * (1700.0 / (5600000 - 390000)) - 700;
|
||||
}
|
||||
```
|
||||
|
||||
I calibrated it manually, comparing it to an analogue gauge. It's calibrated to a zero point at atmospheric pressure in my locale, and from -700mbar to +1000mbar. I figured out that, when setting the HX711 to a gain of 64 with the Adafruit HX711 library, a change of 100mbar is a change in the ADC measurement by 30k, highly consistent across the entire pressure range that I tested. I can't be 100% sure how accurate the analogue gauge is, but 100% accuracy doesn't really matter for this application. All I really need to know is the fact that an adequate vacuum is present, and a general idea of the leak rate, which is a requirement that this setup meets.
|
||||
|
||||
#### Other stuff
|
||||
|
||||
Everything else was mostly uneventful. I got a medium-power PWM motor driver with L298 logic, claiming a continuous current of 7 amps per channel, which nicely fit my requirements. 120mm PC fans are typically 0.2-0.3 amps, mine in particular are 0.25. So, for 18 fans, it should be approximately 4.5 amps at 100% speed. It's a bit oversized, and I'm only using one channel, but it leaves me the option in the future to use larger, generic radiator fans that have more demanding power requirements. PC fans are infamously overpriced, after all. Eventually enough of them are going to fail that I'll have to do something.
|
||||
|
||||
{{< panzoom-gallery caption="Required additions to the solenoid, pump motor, and the complete assembly without cover.">}}
|
||||
{{< panzoom-figure
|
||||
src="pump_greeble.jpg"
|
||||
alt="Top view of the sensor JB Welded into the drilled out plug."
|
||||
gallery_class="grid-w45"
|
||||
>}}
|
||||
{{< panzoom-figure
|
||||
src="complete_assembly.jpg"
|
||||
alt="Bottom view of the sensor JB Welded into the drilled out plug."
|
||||
gallery_class="grid-w55"
|
||||
>}}
|
||||
{{< panzoom-figure
|
||||
src="solenoid_diode.jpg"
|
||||
alt="Sensor package with additional JB Weld installed into the vacuum tank."
|
||||
gallery_class="grid-w45"
|
||||
>}}
|
||||
{{< /panzoom-gallery >}}
|
||||
|
||||
In my initial tests, I found that operating the pump and solenoid would cause the Arduino to reset, seemingly at random, or cause other undefined behavior. Since they were not electrically isolated on a second power supply, that makes sense. They were backfeeding energy and causing a notable amount of general interference during operation, to the point that the LEDs on the inactive relay modules would dimly illuminate when the motor was in operation, and very visibly illuminate whenever the motor or the solenoid deactivated. I had to add flyback diodes, and, for peace of mind, I added ceramic filtering capacitors to the pump as well. Those additions completely eliminated the issues. Electrical engineering is real. Below is a video demonstrating the issue.
|
||||
|
||||
{{< youtubeLite id="E-Ngy0T2RyM" >}}
|
||||
|
||||
I soldered up a sort of bus bar for the fan connectors, used an Adafruit proto-shield to interface several connectors with the Arduino, and did a similar plug-drilling setup for the water temperature sensors with a generic Dallas temperature probe. That pretty much covers everything noteworthy about the hardware.
|
||||
|
||||
## The Software
|
||||
|
||||
As I mentioned earlier, my software is incomplete. The server-side is currently just a brute-force JSON-over-serial reader writte in Python. I will update this section in the future when I have the JSON-serial-Zabbix bridge setup. It will mostly be for intellectual interest to see how the temperatures change throughout the year and whether or not the leak rate changes meaningfully over time. I plan to setup alerts and emergency shutdowns for out-of-bounds leak rates, or pump failure, of course, but with proper soft-tubing setups spontaneous failures are exceedingly rare, and the negative pressure should prevent/notify of any kind of impending failure before anything actually leaks. D5 pump failures are exceedingly rare when run in clean systems at a fixed speed with infrequent starts/stops, but they do happen.
|
||||
|
||||
The Arduino does not take commands from the server. It manages the fans and pressure autonomously, for ease of programming / debugging, and so that it can operate independently of a connection to an active server. It doesn't need to know how many devices are in use, or the temperatures of any components, because there are ultimately only two actions it can take. Change the pump speed, or change the fan speed. Fan speed should never be associated with component temperature. It should be associated with water temperature.
|
||||
|
||||
Occasionally, the temperature probes as well as the HX711 return spurious readings that cause poor behavior, such as crashing the Arduino. In particular, the temperature probes will sometimes return -127, which caused my PID algorithm to crash the Arduino for reasons I could not divine. In addition, the HX711 occasionally returns wildly wrong results that need to be filtered out.
|
||||
|
||||
For the temperature probes, I simply ignore the one problematic result that I've observed.
|
||||
|
||||
```c++
|
||||
new_water_temp = sensors.getTempC(water_therm);
|
||||
new_air_temp = sensors.getTempC(air_therm);
|
||||
|
||||
if (new_water_temp != -127) {
|
||||
water_temp = new_water_temp;
|
||||
}
|
||||
|
||||
if (new_air_temp != -127) {
|
||||
air_temp = new_air_temp;
|
||||
}
|
||||
|
||||
sensors.requestTemperatures();
|
||||
```
|
||||
|
||||
For the pressure detection issues, I wait one second, and if the pressure is still below the threshold, I then begin pumping. This check happens approximately ten times per second, as the default behavior of the HX711 board that I have is to run in 10hz mode. I'm not sure if the issue springs from some kind of interference with the tachometer interrupts messing up the signaling timing, or if I'm misunderstanding the correct way to sample the HX711 over time.
|
||||
|
||||
```c++
|
||||
if (cur_loop_timestamp - last_pressure_check >= 100) {
|
||||
loop_pressure = pressure_raw_to_mbar(hx711.readChannelRaw(CHAN_A_GAIN_64));
|
||||
|
||||
if (sucking == false) {
|
||||
if (loop_pressure > low_pressure_threshold) {
|
||||
if (checking_low_pressure == false) {
|
||||
checking_low_pressure = true;
|
||||
low_pressure_confirmation_timestamp = cur_loop_timestamp;
|
||||
}
|
||||
if (cur_loop_timestamp - low_pressure_confirmation_timestamp >= 1000) {
|
||||
digitalWrite(pump_relay, HIGH);
|
||||
digitalWrite(solenoid_relay, HIGH);
|
||||
sucking = true;
|
||||
checking_low_pressure = false;
|
||||
}
|
||||
}
|
||||
|
||||
else if (cur_loop_timestamp - low_pressure_confirmation_timestamp >= 1000) {
|
||||
checking_low_pressure = false;
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
if (loop_pressure < high_pressure_threshold) {
|
||||
digitalWrite(pump_relay, LOW);
|
||||
digitalWrite(solenoid_relay, LOW);
|
||||
sucking = false;
|
||||
}
|
||||
}
|
||||
last_pressure_check = cur_loop_timestamp;
|
||||
}
|
||||
```
|
||||
|
||||
In case of any other issues, I also enabled the watchdog timer for 2 seconds. So, if, for some reason, it does freeze/crash, it should self reset after 2 seconds. It seems to be working, although I guess time will tell in the long-term. I haven't experienced any operation issues since I added it over a month ago. The other concern is undefined behavior when the timer overflows. The Uno only has a 32-bit timer, so it will overflow around 50 days of uptime. This function pre-emptively resets it.
|
||||
|
||||
```c++
|
||||
//we will use this function to periodically self-reset to avoid timer overflows
|
||||
void(* resetFunc) (void) = 0;
|
||||
|
||||
...
|
||||
|
||||
//reset the system when approaching timer overflow
|
||||
if (cur_loop_timestamp >= 4000000000) {
|
||||
resetFunc();
|
||||
}
|
||||
```
|
||||
|
||||
Once per second, the temperature sensors are sampled, the PID loop for the fans runs with the new temperature data points, and the fan speed, temperature data, vacuum pressure, and pump/flow measurements are sent over serial with the help of the ArduinoJSON library. I settled on a target water delta of 4\*C relative to ambient, with a chosen min/max temperature range where the fans turn off or pin themselves to 100% completely. I'm not sure if either of those temperatures will ever be reached, realistically, but it's better to be safe than sorry, right?
|
||||
|
||||
```c++
|
||||
int fan_PID(float* air_temp, float* water_temp, uint32_t* cur_loop_timestamp) {
|
||||
static const float kp = 120.0;
|
||||
static const float ki = 0.16;
|
||||
static const float kd = 4.0;
|
||||
|
||||
static float integral = 0;
|
||||
static float derivative = 0;
|
||||
static float last_error = 0;
|
||||
static float error;
|
||||
static float delta;
|
||||
static float last_time = *cur_loop_timestamp;
|
||||
static const float min_water_temp = 5.0;
|
||||
static const float max_water_temp = 40.0;
|
||||
static const uint8_t min_fan_speed = 90;
|
||||
static const uint8_t max_fan_speed = 255;
|
||||
|
||||
static const uint8_t temp_target_offset = 4;
|
||||
static const uint8_t fan_offset = 10;
|
||||
|
||||
static int16_t fan_speed;
|
||||
|
||||
if (*water_temp >= max_water_temp) {
|
||||
return max_fan_speed;
|
||||
}
|
||||
|
||||
else if (*water_temp <= min_water_temp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
else {
|
||||
error = *water_temp - min(*air_temp + temp_target_offset, max_water_temp);
|
||||
delta = *cur_loop_timestamp - last_time;
|
||||
|
||||
//mitigate unlimited integral windup
|
||||
if (fan_speed == max_fan_speed) {
|
||||
integral += error * delta;
|
||||
}
|
||||
|
||||
if (*air_temp + temp_target_offset > *water_temp - 1) {
|
||||
integral = 0;
|
||||
}
|
||||
|
||||
derivative = (error - last_error) / delta;
|
||||
fan_speed = round(constrain(min_fan_speed + fan_offset + (kp * error + ki * integral + kd * derivative), min_fan_speed, max_fan_speed));
|
||||
last_error = error;
|
||||
return fan_speed;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Theoretically the loop should handle water temperatures in excess of 60\*C without the components actually overheating, but lower is better. With all the currently connected components running at 100% use, and 100% fan speed, the delta between air and water is <10\*C. In the summer, the water temperature might broach 40\*C, but I don't know. I haven't monitored the actual ambient temperature in the garage before now. My garage is partially underground, and my rack is situated in the back, where it is deepest, so the temperatures tend to stay more mild than the outside. So far this winter, the air temperature measured by the Arduino has tended to stay 10-20\*C higher than the ambient air temperature outside, so it would need to be *very* cold to actually have the loop be at-risk of freezing. In that extreme case, in my testing, turning off the fans allows heat to accumulate to a sufficient degree (even at idle) that there is no need to be concerned with antifreeze additives.
|
||||
|
||||
## Other Thoughts?
|
||||
|
||||
This project had a lot of firsts for me. It was the first time I've done any kind of embedded-adjacent development beyond "ooooo look at the blinky light, oooooooo it turns off when you press the button, wwaaaow", and the first time I'd designed something with so many individual parts. I've never worked with air pumps, solenoids, or pressure sensing before, nor had to debug issues like the lack of flyback diodes.
|
||||
|
||||
I learned that I hate drilling through sheet steel, especially without a drill press. I really, really hate drilling through steel. I should have gotten an aluminum or plastic project box instead of using that stupid battery box. If I were to ever take it apart again, I'd add a passthrough for the SPI header, and/or an external reset button. I'd like to think that I'm going to stop poking into boxes that have live electricity inside of them, but I'm not sure that one is going to stick. I should have gotten a physical display of some type that could show the sensors and debug info on the device itself without being connected to another device to readout the data.
|
||||
|
||||
I'd like to get a second pump, for redundancy's sake and to increase the flow rate. But it's going to be such a pain to install that I feel like I'm never going to bother to do it, unless the current pump fails. I was also slightly concerned about the evaporation rate of the liquid via the vacuum tank, and that I'd need to add some kind of fluid level detection system, but there's been no noticeable loss thus far. Now that I know the pump turns on so infrequently, I can't imagine that it's going to need to be topped up anytime soon.
|
||||
|
||||
Godbwye.
|
||||
|
||||
[^pascalbad]: In modern, post-Turing cards, that is. Please stop buying mesozoic-era Kepler/Maxwell Quadros and Teslas just because they have VRAM. There's a reason they're going for like, $20, and if you paid more for anything from that era, I'm sorry. Electrical costs are a thing, and your life is worth more than waiting for any meaningful, current-year work to happen on those decrepit e-waste cards. You can make an argument for Volta, but only if you're doing some deranged pure FP64 stuff. Consumer Turing and newer are faster at everything else! And if you're buying them for HW-accel encode... The quality is awful compared to any Intel ARC card. Buy one of those instead.
|
||||
|
||||
[^thermalfears]: I don't understand why people don't trust the manufacturer specifications when it comes to silicon temperature limits, beyond unfounded conspiracy nonsense around planned destruction/obselence. In terms of Intel server SKUs, you find that the throttling temp is *higher* than on consumer SKUs, despite the higher reliability demanded by the enterprise market... I'm assuming that this is due to reduced hotspot variance thanks to generally lower voltage spread from lower boosting clock speeds. On enterprise SKUs which are focused on single threaded performance, the throttling temp is typically lower than those without the ability to boost as high. If you have evidence to the contrary, let me know.
|
||||
|
||||
[^badnomenclature]: I don't understand why people call custom loops 'open loops'. They're not open. They're closed. People correctly use the phrase 'closed loop' when referring to AIOs. This phrasing has been pervasive for at least ten years and it bugs me a lot. AIOs are sealed units where the liquid has no interaction with the external environment. Custom loops are sealed units where the liquid has no interaction with the external environment. They're both closed in operation. Outside of the PC watercooling space, 'open loop' would imply that your cooling method intakes fresh coolant and outputs waste that is not directly recovered. LN2 overclocking, in the PC world, is a form of open loop liquid cooling. If you were putting water into your loop via your sink, and dumping the output into the drain, that would be open loop water cooling. Eternally recycling the same liquid in a sealed loop is not open. It's closed. It's a closed loop.
|
||||
|
||||
[^citationneeded]: Quantifying the exact power drop due to reduced leakage current is not possible as I do not have an isolated measurement of fan power use. The fan on a blower-type card such as this can exceed 20w power draw. Approximately 50w reduced power use at the same performance level can be attributed to a combination of removing the built-in fan plus a reduction of leakage current.
|
BIN
content/posts/watercooling-my-homelab/plug_detail_bottom.jpg
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
content/posts/watercooling-my-homelab/plug_detail_top.jpg
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
content/posts/watercooling-my-homelab/pump_greeble.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
After Width: | Height: | Size: 242 KiB |
BIN
content/posts/watercooling-my-homelab/solenoid_diode.jpg
Normal file
After Width: | Height: | Size: 1.2 MiB |
67
frontmatter.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"$schema": "https://frontmatter.codes/frontmatter.schema.json",
|
||||
"frontMatter.taxonomy.contentTypes": [
|
||||
{
|
||||
"name": "default",
|
||||
"pageBundle": false,
|
||||
"previewPath": null,
|
||||
"fields": [
|
||||
{
|
||||
"title": "Title",
|
||||
"name": "title",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Description",
|
||||
"name": "description",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Publishing date",
|
||||
"name": "date",
|
||||
"type": "datetime",
|
||||
"default": "{{now}}",
|
||||
"isPublishDate": true
|
||||
},
|
||||
{
|
||||
"title": "Content preview",
|
||||
"name": "preview",
|
||||
"type": "image"
|
||||
},
|
||||
{
|
||||
"title": "Is in draft",
|
||||
"name": "draft",
|
||||
"type": "draft"
|
||||
},
|
||||
{
|
||||
"title": "Tags",
|
||||
"name": "tags",
|
||||
"type": "tags"
|
||||
},
|
||||
{
|
||||
"title": "Categories",
|
||||
"name": "categories",
|
||||
"type": "categories"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"frontMatter.framework.id": "hugo",
|
||||
"frontMatter.content.publicFolder": "static",
|
||||
"frontMatter.preview.host": "http://localhost:1313",
|
||||
"frontMatter.content.pageFolders": [
|
||||
{
|
||||
"title": "content",
|
||||
"path": "[[workspace]]/content"
|
||||
},
|
||||
{
|
||||
"title": "pages",
|
||||
"path": "[[workspace]]/content/pages"
|
||||
},
|
||||
{
|
||||
"title": "posts",
|
||||
"path": "[[workspace]]/content/posts"
|
||||
}
|
||||
],
|
||||
"frontMatter.git.enabled": true
|
||||
}
|
3
hugo.toml
Normal file
@ -0,0 +1,3 @@
|
||||
baseURL = 'https://example.org/'
|
||||
languageCode = 'en-us'
|
||||
title = 'My New Hugo Site'
|
161
layouts/partials/head.html
Normal file
@ -0,0 +1,161 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
{{ with .Site.Language.Params.htmlCode | default .Site.LanguageCode }}
|
||||
<meta http-equiv="content-language" content="{{ . }}" />
|
||||
{{ end }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
{{/* Title */}}
|
||||
{{ if .IsHome -}}
|
||||
<title>{{ .Site.Title | emojify }}</title>
|
||||
<meta name="title" content="{{ .Site.Title | emojify }}" />
|
||||
{{- else -}}
|
||||
<title>{{ .Title | emojify }} · {{ .Site.Title | emojify }}</title>
|
||||
<meta name="title" content="{{ .Title | emojify }} · {{ .Site.Title | emojify }}" />
|
||||
{{- end }}
|
||||
{{/* Metadata */}}
|
||||
{{ with (.Params.Summary | default .Params.Description) | default .Site.Params.description -}}
|
||||
<meta name="description" content="{{ . }}" />
|
||||
{{- end }}
|
||||
{{ with .Params.Tags | default .Site.Params.keywords -}}
|
||||
<meta name="keywords" content="{{ range . }}{{ . }}, {{ end -}}" />
|
||||
{{- end }}
|
||||
{{ with .Site.Params.robots }}
|
||||
<meta name="robots" content="{{ . }}" />
|
||||
{{ end }}
|
||||
{{ with .Params.robots }}
|
||||
<meta name="robots" content="{{ . }}" />
|
||||
{{ end }}
|
||||
<link rel="canonical" href="{{ .Permalink }}" />
|
||||
{{ range .AlternativeOutputFormats -}}
|
||||
{{ printf `
|
||||
<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .RelPermalink ($.Site.Title | emojify) |
|
||||
safeHTML }}
|
||||
{{ end -}}
|
||||
{{/* Asset bundles */}}
|
||||
{{ $assets := newScratch }}
|
||||
{{ $cssScheme := resources.Get (printf "css/schemes/%s.css" (.Site.Params.colorScheme | default "blowfish")) }}
|
||||
{{ if not $cssScheme }}
|
||||
{{ $cssScheme = resources.Get "css/schemes/blowfish.css" }}
|
||||
{{ end }}
|
||||
{{ $assets.Add "css" (slice $cssScheme) }}
|
||||
{{ $cssMain := resources.Get "css/compiled/main.css" }}
|
||||
{{ $assets.Add "css" (slice $cssMain) }}
|
||||
{{ $cssCustom := resources.Get "css/custom.css" }}
|
||||
{{ if $cssCustom }}
|
||||
{{ $assets.Add "css" (slice $cssCustom) }}
|
||||
{{ end }}
|
||||
{{ $bundleCSS := $assets.Get "css" | resources.Concat "css/main.bundle.css" | resources.Minify | resources.Fingerprint
|
||||
"sha512" }}
|
||||
<link type="text/css" rel="stylesheet" href="{{ $bundleCSS.RelPermalink }}"
|
||||
integrity="{{ $bundleCSS.Data.Integrity }}" />
|
||||
{{ $jsAppearance := resources.Get "js/appearance.js" }}
|
||||
{{ $jsAppearance = $jsAppearance | resources.ExecuteAsTemplate "js/appearance.js" . | resources.Minify | resources.Fingerprint "sha512" }}
|
||||
<script type="text/javascript" src="{{ $jsAppearance.RelPermalink }}"
|
||||
integrity="{{ $jsAppearance.Data.Integrity }}"></script>
|
||||
{{ if .Site.Params.enableSearch | default false }}
|
||||
{{ $jsFuse := resources.Get "lib/fuse/fuse.min.js" }}
|
||||
{{ $jsSearch := resources.Get "js/search.js" }}
|
||||
{{ $assets.Add "js" (slice $jsFuse $jsSearch) }}
|
||||
{{ end }}
|
||||
{{ if .Site.Params.enableCodeCopy | default false }}
|
||||
{{ $jsCode := resources.Get "js/code.js" }}
|
||||
{{ $assets.Add "js" (slice $jsCode) }}
|
||||
{{ end }}
|
||||
{{ if .Site.Params.rtl | default false }}
|
||||
{{ $jsRTL := resources.Get "js/rtl.js" }}
|
||||
{{ $assets.Add "js" (slice $jsRTL) }}
|
||||
{{ end }}
|
||||
{{ $jsMobileMenu := resources.Get "js/mobilemenu.js" }}
|
||||
{{ $assets.Add "js" (slice $jsMobileMenu) }}
|
||||
{{ if $assets.Get "js" }}
|
||||
{{ $bundleJS := $assets.Get "js" | resources.Concat "js/main.bundle.js" | resources.Minify | resources.Fingerprint
|
||||
"sha512" }}
|
||||
<script defer type="text/javascript" id="script-bundle" src="{{ $bundleJS.RelPermalink }}"
|
||||
integrity="{{ $bundleJS.Data.Integrity }}" data-copy="{{ i18n " code.copy" }}" data-copied="{{ i18n " code.copied"
|
||||
}}"></script>
|
||||
{{ end }}
|
||||
{{ if not .Site.Params.disableImageZoom | default true }}
|
||||
{{ $zoomJS := resources.Get "lib/zoom/zoom.min.js" | resources.Fingerprint "sha512" }}
|
||||
<script src="{{ $zoomJS.RelPermalink }}" integrity="{{ $zoomJS.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
{{/* Icons */}}
|
||||
{{ if templates.Exists "partials/favicons.html" }}
|
||||
{{ partialCached "favicons.html" .Site }}
|
||||
{{ else }}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ "apple-touch-icon.png" | relURL }}" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ "favicon-32x32.png" | relURL }}" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ "favicon-16x16.png" | relURL }}" />
|
||||
<link rel="manifest" href="{{ "site.webmanifest" | relURL }}" />
|
||||
{{ end }}
|
||||
{{/* Site Verification */}}
|
||||
{{ with .Site.Params.verification.google }}
|
||||
<meta name="google-site-verification" content="{{ . }}" />
|
||||
{{ end }}
|
||||
{{ with .Site.Params.verification.bing }}
|
||||
<meta name="msvalidate.01" content="{{ . }}" />
|
||||
{{ end }}
|
||||
{{ with .Site.Params.verification.pinterest }}
|
||||
<meta name="p:domain_verify" content="{{ . }}" />
|
||||
{{ end }}
|
||||
{{ with .Site.Params.verification.yandex }}
|
||||
<meta name="yandex-verification" content="{{ . }}" />
|
||||
{{ end }}
|
||||
{{ with .Site.Params.verification.fediverse }}
|
||||
<meta name="fediverse:creator" content="{{ . }}" />
|
||||
{{ end }}
|
||||
{{/* Social */}}
|
||||
{{ template "_internal/opengraph.html" . }}
|
||||
{{ template "_internal/twitter_cards.html" . }}
|
||||
{{/* Schema */}}
|
||||
{{ partial "schema.html" . }}
|
||||
{{/* Me */}}
|
||||
{{ with .Site.Params.Author.name }}
|
||||
<meta name="author" content="{{ . }}" />{{ end }}
|
||||
{{ with .Site.Params.Author.links }}
|
||||
{{ range $links := . }}
|
||||
{{ range $name, $url := $links }}
|
||||
<link href="{{ $url }}" rel="me" />{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{/* Vendor */}}
|
||||
{{ partial "vendor.html" . }}
|
||||
{{/* Analytics */}}
|
||||
{{ partial "analytics/main.html" .Site }}
|
||||
{{/* Extend head - eg. for custom analytics scripts, etc. */}}
|
||||
{{ if templates.Exists "partials/extend-head.html" }}
|
||||
{{ partialCached "extend-head.html" .Site }}
|
||||
{{ end }}
|
||||
<meta name="theme-color"/>
|
||||
{{/* Firebase */}}
|
||||
{{ with $.Site.Params.firebase }}
|
||||
{{ if isset $.Site.Params "firebase" }}
|
||||
<script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js"></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-firestore.js"></script>
|
||||
<script src="https://www.gstatic.com/firebasejs/8.10.0/firebase-auth.js"></script>
|
||||
<script>
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: {{ $.Site.Params.firebase.apiKey }},
|
||||
authDomain: {{ $.Site.Params.firebase.apiKey }},
|
||||
projectId: {{ $.Site.Params.firebase.projectId }},
|
||||
storageBucket: {{ $.Site.Params.firebase.storageBucket }},
|
||||
messagingSenderId: {{ $.Site.Params.firebase.messagingSenderId }},
|
||||
appId: {{ $.Site.Params.firebase.appId }},
|
||||
measurementId: {{ $.Site.Params.firebase.measurementId }}
|
||||
};
|
||||
|
||||
var app = firebase.initializeApp(firebaseConfig);
|
||||
var db = firebase.firestore();
|
||||
var auth = firebase.auth();
|
||||
|
||||
</script>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ $jsPanzoom := resources.Get "js/panzoom.min.js" | resources.Fingerprint "sha512"}}
|
||||
<script type="text/javascript" src="{{ $jsPanzoom.RelPermalink }}" integrity="{{ $jsPanzoom.Data.Integrity }}"></script>
|
||||
|
||||
{{ $jsPanzoomUtil := resources.Get "js/panzoom-util.js" | resources.Fingerprint "sha512"}}
|
||||
<script type="text/javascript" src="{{ $jsPanzoomUtil.RelPermalink }}" integrity="{{ $jsPanzoomUtil.Data.Integrity }}"></script>
|
||||
</head>
|
84
layouts/partials/vendor.html
Normal file
@ -0,0 +1,84 @@
|
||||
{{/* jQuery */}}
|
||||
{{ $jqueryLib := resources.Get "lib/jquery/jquery.slim.min.js" | resources.Fingerprint "sha512" }}
|
||||
<script src="{{ $jqueryLib.RelPermalink }}" integrity="{{ $jqueryLib.Data.Integrity }}"></script>
|
||||
|
||||
{{/* Mermaid */}}
|
||||
{{ if .Page.HasShortcode "mermaid" }}
|
||||
{{ $mermaidLib := resources.Get "lib/mermaid/mermaid.min.js" }}
|
||||
{{ $mermaidConfig := resources.Get "js/mermaid.js" }}
|
||||
{{ $mermaidConfig := $mermaidConfig | resources.Minify }}
|
||||
{{ $mermaidJS := slice $mermaidLib $mermaidConfig | resources.Concat "js/mermaid.bundle.js" | resources.Fingerprint "sha512" }}
|
||||
<script
|
||||
defer
|
||||
type="text/javascript"
|
||||
src="{{ $mermaidJS.RelPermalink }}"
|
||||
integrity="{{ $mermaidJS.Data.Integrity }}"
|
||||
></script>
|
||||
{{ end }}
|
||||
|
||||
{{/* Chart */}}
|
||||
{{ if .Page.HasShortcode "chart" }}
|
||||
{{ $chartLib := resources.Get "lib/chart/chart.min.js" }}
|
||||
{{ $chartConfig := resources.Get "js/chart.js" }}
|
||||
{{ $chartConfig := $chartConfig | resources.Minify }}
|
||||
{{ $chartJS := slice $chartLib $chartConfig | resources.Concat "js/chart.bundle.js" | resources.Fingerprint "sha512" }}
|
||||
<script defer type="text/javascript" src="{{ $chartJS.RelPermalink }}"
|
||||
integrity="{{ $chartJS.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{/* Katex */}}
|
||||
{{ if .Page.HasShortcode "katex" }}
|
||||
{{ $katexCSS := resources.Get "lib/katex/katex.min.css" }}
|
||||
{{ $katexCSS := $katexCSS | resources.Fingerprint "sha512" }}
|
||||
<link type="text/css" rel="stylesheet" href="{{ $katexCSS.RelPermalink }}" integrity="{{ $katexCSS.Data.Integrity }}" />
|
||||
{{ $katexJS := resources.Get "lib/katex/katex.min.js" }}
|
||||
{{ $katexJS := $katexJS | resources.Fingerprint "sha512" }}
|
||||
<script defer src="{{ $katexJS.RelPermalink }}" integrity="{{ $katexJS.Data.Integrity }}"></script>
|
||||
{{ $katexRenderJS := resources.Get "lib/katex/auto-render.min.js" }}
|
||||
{{ $katexRenderJS := $katexRenderJS | resources.Fingerprint "sha512" }}
|
||||
<script defer src="{{ $katexRenderJS.RelPermalink }}" integrity="{{ $katexRenderJS.Data.Integrity }}"
|
||||
onload="renderMathInElement(document.body);"></script>
|
||||
{{ $katexFonts := resources.Match "lib/katex/fonts/*" }}
|
||||
{{ range $katexFonts }}
|
||||
<!-- {{ .RelPermalink }} -->
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{/* TypeIt */}}
|
||||
{{ if .Page.HasShortcode "typeit" }}
|
||||
{{ $typeitLib := resources.Get "lib/typeit/typeit.umd.js" | resources.Fingerprint "sha512" }}
|
||||
<script defer src="{{ $typeitLib.RelPermalink }}" integrity="{{ $typeitLib.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{/* Packery */}}
|
||||
{{ if .Page.HasShortcode "gallery" }}
|
||||
{{ $packeryLib := resources.Get "lib/packery/packery.pkgd.min.js" }}
|
||||
<script defer src="{{ $packeryLib.RelPermalink }}" integrity="{{ $packeryLib.Data.Integrity }}"></script>
|
||||
|
||||
{{ $jsShortcodeGallery := resources.Get "js/shortcodes/gallery.js" }}
|
||||
{{ $jsShortcodeGallery = $jsShortcodeGallery | resources.Minify | resources.Fingerprint "sha512" }}
|
||||
<script type="text/javascript" src="{{ $jsShortcodeGallery.RelPermalink }}" integrity="{{ $jsShortcodeGallery.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Page.HasShortcode "panzoom-gallery" }}
|
||||
{{ $packeryLib := resources.Get "lib/packery/packery.pkgd.min.js" }}
|
||||
<script defer src="{{ $packeryLib.RelPermalink }}" integrity="{{ $packeryLib.Data.Integrity }}"></script>
|
||||
|
||||
{{ $jsShortcodeGallery := resources.Get "js/shortcodes/gallery.js" }}
|
||||
{{ $jsShortcodeGallery = $jsShortcodeGallery | resources.Minify | resources.Fingerprint "sha512" }}
|
||||
<script type="text/javascript" src="{{ $jsShortcodeGallery.RelPermalink }}" integrity="{{ $jsShortcodeGallery.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{/* tw-elements */}}
|
||||
{{ if or (.Page.HasShortcode "carousel") (.Page.HasShortcode "timeline")}}
|
||||
{{ $twelementsLib := resources.Get "lib/tw-elements/index.min.js" }}
|
||||
<script defer src="{{ $twelementsLib.RelPermalink }}" integrity="{{ $twelementsLib.Data.Integrity }}"></script>
|
||||
{{ end }}
|
||||
|
||||
{{/* youtubeLite */}}
|
||||
{{ if .Page.HasShortcode "youtubeLite" }}
|
||||
{{ $youtubeLiteJS := resources.Get "lib/lite-youtube-embed/lite-yt-embed.js" | resources.Fingerprint "sha512" }}
|
||||
{{ $youtubeLiteCSS := resources.Get "lib/lite-youtube-embed/lite-yt-embed.css" }}
|
||||
<link rel="stylesheet" href="{{ $youtubeLiteCSS.RelPermalink }}" integrity="{{ $youtubeLiteCSS.Data.Integrity }}"/>
|
||||
<script src="{{ $youtubeLiteJS.RelPermalink }}" integrity="{{ $youtubeLiteJS.Data.Integrity }}"></script>
|
||||
{{ end }}
|
71
layouts/shortcodes/panzoom-figure.html
Normal file
@ -0,0 +1,71 @@
|
||||
{{ $disableImageOptimization := .Site.Params.disableImageOptimization | default false }}
|
||||
{{ if .Get "default" }}
|
||||
{{ template "_internal/shortcodes/figure.html" . }}
|
||||
{{ else }}
|
||||
{{- $url := urls.Parse (.Get "src") }}
|
||||
{{- $altText := .Get "alt" }}
|
||||
{{- $caption := .Get "caption" }}
|
||||
{{- $href := .Get "href" }}
|
||||
{{- $class := .Get "class" }}
|
||||
{{- $target := .Get "target" | default "_blank" }}
|
||||
{{- $gallery_class := .Get "gallery_class" }}
|
||||
|
||||
<div class="panzoom-container {{ with $gallery_class }} {{ . }}{{ end }}">
|
||||
{{- with $href }}<a href="{{ . }}" {{ with $target }}target="{{ . }}"{{ end }}>{{ end -}}
|
||||
{{- if findRE "^https?" $url.Scheme }}
|
||||
<img class="my-0 rounded-md zoomable{{ with $class }} {{ . }}{{ end }}" src="{{ $url.String }}" alt="{{ $altText }}" />
|
||||
{{- else }}
|
||||
{{- $resource := "" }}
|
||||
{{- if $.Page.Resources.GetMatch ($url.String) }}
|
||||
{{- $resource = $.Page.Resources.GetMatch ($url.String) }}
|
||||
{{- else if resources.GetMatch ($url.String) }}
|
||||
{{- $resource = resources.Get ($url.String) }}
|
||||
{{- end }}
|
||||
{{- with $resource }}
|
||||
{{ $rotationString := ""}}
|
||||
{{- if ne .MediaType.SubType "svg" }}
|
||||
{{- with .Exif}}
|
||||
{{ $EXIFOrientation := .Tags.Orientation }}
|
||||
{{ if eq $EXIFOrientation 3 }}
|
||||
{{ $rotationString = "r180" }}
|
||||
{{ else if eq $EXIFOrientation 4}}
|
||||
{{ $rotationString = "r180" }}
|
||||
{{ else if eq $EXIFOrientation 5}}
|
||||
{{ $rotationString = "r270" }}
|
||||
{{ else if eq $EXIFOrientation 6}}
|
||||
{{ $rotationString = "r270" }}
|
||||
{{ else if eq $EXIFOrientation 7}}
|
||||
{{ $rotationString = "r90" }}
|
||||
{{ else if eq $EXIFOrientation 8}}
|
||||
{{ $rotationString = "r90" }}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if or $disableImageOptimization (eq .MediaType.SubType "svg")}}
|
||||
<img
|
||||
class="my-0 rounded-md zoomable{{ with $class }} {{ . }}{{ end }}"
|
||||
src="{{ .RelPermalink }}"
|
||||
alt="{{ $altText }}"
|
||||
data-src="{{ $url.String }}"
|
||||
/>
|
||||
{{- else }}
|
||||
<img
|
||||
class="my-0 rounded-md zoomable{{ with $class }} {{ . }}{{ end }}"
|
||||
src="{{ .RelPermalink }}"
|
||||
srcset="
|
||||
{{ (.Resize (printf "330x %s" $rotationString)).RelPermalink }} 330w,
|
||||
{{ (.Resize (printf "660x %s" $rotationString)).RelPermalink }} 660w,
|
||||
{{ (.Resize (printf "1320x %s" $rotationString)).RelPermalink }} 1320w,
|
||||
{{ .RelPermalink }} 2x"
|
||||
alt="{{ $altText }}"
|
||||
data-src="{{ $url.String }}"
|
||||
/>
|
||||
{{- end }}
|
||||
{{- else }}
|
||||
<img class="my-0 rounded-md zoomable{{ with $class }} {{ . }}{{ end }}" src="{{ $url.String }}" alt="{{ $altText }}" data-src="{{ $url.String }}"/>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{ if $href }}</a>{{ end }}
|
||||
</div>
|
||||
{{ with $caption }}<figcaption>{{ . | markdownify }}</figcaption>{{ end }}
|
||||
{{- end -}}
|
9
layouts/shortcodes/panzoom-gallery.html
Normal file
@ -0,0 +1,9 @@
|
||||
{{ $id := delimit (slice "gallery" (partial "functions/uid.html" .)) "-" }}
|
||||
|
||||
{{- $caption := .Get "caption" }}
|
||||
|
||||
|
||||
<div id="{{ $id }}" class="gallery">
|
||||
{{ .Inner }}
|
||||
</div>
|
||||
{{ with $caption }}<figcaption>{{ . | markdownify }}</figcaption>{{ end }}
|
1
layouts/shortcodes/smallest-text.html
Normal file
@ -0,0 +1 @@
|
||||
<span style="font-size: xx-small;">{{ .Inner }}</span>
|
After Width: | Height: | Size: 27 KiB |
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"6"}}}
|
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 53 KiB |
After Width: | Height: | Size: 150 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"6"}}}
|
After Width: | Height: | Size: 152 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 21 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{}}}
|
After Width: | Height: | Size: 226 KiB |
After Width: | Height: | Size: 137 KiB |
After Width: | Height: | Size: 58 KiB |
After Width: | Height: | Size: 15 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint32":"0"}}}
|
After Width: | Height: | Size: 121 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"1"}}}
|
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 211 KiB |
After Width: | Height: | Size: 143 KiB |
After Width: | Height: | Size: 72 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint32":"0"}}}
|
After Width: | Height: | Size: 173 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 54 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"1"}}}
|
After Width: | Height: | Size: 388 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 154 KiB |
After Width: | Height: | Size: 71 KiB |
After Width: | Height: | Size: 238 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"6"}}}
|
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 56 KiB |
After Width: | Height: | Size: 251 KiB |
After Width: | Height: | Size: 30 KiB |
After Width: | Height: | Size: 110 KiB |
After Width: | Height: | Size: 161 KiB |
After Width: | Height: | Size: 19 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint32":"0"}}}
|
After Width: | Height: | Size: 63 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 215 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"1"}}}
|
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 68 KiB |
After Width: | Height: | Size: 212 KiB |
@ -0,0 +1 @@
|
||||
{"Exif":{"Lat":0,"Long":0,"Date":"0001-01-01T00:00:00Z","Tags":{"Orientation|uint16":"6"}}}
|
After Width: | Height: | Size: 201 KiB |
After Width: | Height: | Size: 106 KiB |
After Width: | Height: | Size: 34 KiB |
After Width: | Height: | Size: 316 KiB |
After Width: | Height: | Size: 66 KiB |
After Width: | Height: | Size: 134 KiB |