├── landscape.jpg ├── portrait.jpg ├── manifest.json ├── README.md ├── params.js ├── network.js ├── main.js ├── screen.js ├── index.html └── m2-enclosure.scad /landscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basuke/air-sign/trunk/landscape.jpg -------------------------------------------------------------------------------- /portrait.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basuke/air-sign/trunk/portrait.jpg -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "$(MODDABLE)/examples/manifest_base.json", 4 | "$(MODDABLE)/examples/manifest_commodetto.json", 5 | "$(MODDABLE)/examples/manifest_net.json", 6 | "$(MODDABLE)/modules/files/file/manifest.json", 7 | "$(MODDABLE)/modules/files/preference/manifest.json", 8 | "$(MODDABLE)/modules/network/mdns/manifest.json" 9 | ], 10 | "modules": { 11 | "*": [ 12 | "./main", 13 | "./network", 14 | "./params", 15 | "./screen", 16 | "$(MODULES)/network/http/*" 17 | ], 18 | "commodetto/readJPEG": "$(COMMODETTO)/commodettoReadJPEG" 19 | }, 20 | "resources": { 21 | "*-mask": [ 22 | "$(MODDABLE)/examples/assets/fonts/OpenSans-Semibold-16", 23 | "$(MODDABLE)/examples/assets/fonts/OpenSans-Semibold-20", 24 | "$(MODDABLE)/examples/assets/fonts/OpenSans-Semibold-28" 25 | ] 26 | }, 27 | "data": { 28 | "*": [ 29 | "./index" 30 | ] 31 | }, 32 | "preload": [ 33 | "http" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # air-sign 2 | DIY sign board with API embedded. Change the display by sending a HTTP request. 3 | 4 | ## Featrue 5 | 6 | - It's a wireless sign board to display message. i.e. in the office door, kids room 7 | - Change the display contents by sending a HTTP request to the API. 8 | - mDNS supported. You can name it and locate using `some-name`.local instead of IP. 9 | 10 | ## Environment 11 | 12 | - Moddable One https://github.com/Moddable-OpenSource/moddable/blob/public/documentation/devices/moddable-one.md 13 | 14 | ## API 15 | 16 | The host is displayed to the screen on boot time. 17 | 18 | ### Change text 19 | 20 | Change the displayed text. 21 | 22 | Method | Address | BODY | Content-Type 23 | | - | - | - | - | 24 | POST | http://`host`/text | plain text to be displayed. Multi line text is okay with LF. | text/plain 25 | 26 | ### Change text color 27 | 28 | Change the color of text. 29 | 30 | Method | Address | BODY | Content-Type 31 | | - | - | - | - | 32 | POST | http://`host`/color | CSS 2 representation of color. i.e. #fcc, black, white, red | text/plain 33 | 34 | ### Change background color 35 | 36 | Change the color of background. 37 | 38 | Method | Address | BODY | Content-Type 39 | | - | - | - | - | 40 | POST | http://`host`/background | CSS 2 representation of color. i.e. #fcc, black, white, red | text/plain 41 | 42 | ### Change background image 43 | 44 | Change background image. 45 | 46 | Method | Address | BODY | Content-Type 47 | | - | - | - | - | 48 | POST | http://`host`/image | 320 x 240 JPEG image | image/jpeg 49 | -------------------------------------------------------------------------------- /params.js: -------------------------------------------------------------------------------- 1 | import Preference from "preference"; 2 | 3 | export const domain = 'air-sign'; 4 | 5 | export const params = { 6 | version: 2, 7 | 8 | read(key) { return Preference.get(domain, key) }, 9 | write(key, value) { 10 | if (value === null || value === undefined) { 11 | Preference.delete(domain, key); 12 | } else { 13 | Preference.set(domain, key, value); 14 | } 15 | }, 16 | 17 | get text() { return this.read('text') }, 18 | get color() { return this.read('color') }, 19 | get background() { return this.read('background') }, 20 | get font() { return this.read('font') }, 21 | get image() { return this.read('image') }, 22 | 23 | set text(value) { this.write('text', value) }, 24 | set color(value) { this.write('color', value) }, 25 | set background(value) { this.write('background', value) }, 26 | set font(value) { this.write('font', value) }, 27 | set image(value) { this.write('image', value) }, 28 | 29 | reset() { 30 | this.text = undefined; 31 | this.color = 'white'; 32 | this.background = 'black'; 33 | this.font = 'L'; 34 | this.image = null; 35 | }, 36 | 37 | init() { 38 | const savedVersion = this.read('version'); 39 | if (savedVersion != this.version) { 40 | this.write('version', this.version); 41 | this.reset(); 42 | } 43 | }, 44 | 45 | status() { 46 | return { 47 | text: this.text, 48 | color: this.color, 49 | background: this.background, 50 | font: this.font, 51 | image: this.image, 52 | }; 53 | } 54 | }; 55 | 56 | export default params; 57 | -------------------------------------------------------------------------------- /network.js: -------------------------------------------------------------------------------- 1 | import { Server } from "http" 2 | import MDNS from "mdns"; 3 | import { File } from "file"; 4 | 5 | export function registerHostName(hostName, callback) { 6 | new MDNS({hostName}, (message, value) => { 7 | if (message === MDNS.hostName && callback) 8 | callback(value); 9 | }); 10 | } 11 | 12 | const cacheFileName = 'cache'; 13 | 14 | export class HandyServer extends Server { 15 | constructor(dictionary) { 16 | super(dictionary); 17 | 18 | const verbose = dictionary.verbose; 19 | const root = dictionary.root; 20 | 21 | let file = undefined; 22 | 23 | const server = this; 24 | 25 | this.callback = function(message, arg1, arg2) { 26 | switch (message) { 27 | case Server.status: 28 | this.method = arg2; 29 | this.path = arg1; 30 | this.contentType = "application/octed"; 31 | this.body = undefined; 32 | this.file = undefined; 33 | break; 34 | 35 | case Server.header: 36 | switch (arg1) { 37 | case 'content-type': 38 | this.contentType = arg2; 39 | break; 40 | } 41 | break; 42 | 43 | case Server.headersComplete: 44 | if (this.total >= 1024) { 45 | if (File.exists(root + cacheFileName)) 46 | File.delete(root + cacheFileName); 47 | file = new File(root + cacheFileName, true); 48 | return true; // handling by myself 49 | } else { 50 | const [type, subtype] = this.contentType.split('/'); 51 | return type === 'text' || subtype === 'x-www-form-urlencoded' ? String : ArrayBuffer; 52 | } 53 | 54 | case Server.requestFragment: 55 | const fragment = this.socket.read(ArrayBuffer, arg1); 56 | file.write(fragment); 57 | break; 58 | 59 | case Server.requestComplete: 60 | if (file) { 61 | file.close(); 62 | file = undefined; 63 | 64 | this.file = root + cacheFileName; 65 | } else { 66 | this.body = arg1; 67 | } 68 | break; 69 | 70 | case Server.prepareResponse: 71 | const response = server.onRequest(this); 72 | if (response.data) { 73 | this.data = response.data; 74 | this.position = 0; 75 | } 76 | return response; 77 | 78 | case Server.responseFragment: 79 | if (this.position >= this.data.byteLength) 80 | return; 81 | 82 | const chunk = this.data.slice(this.position, this.position + arg1); 83 | this.position += chunk.byteLength; 84 | return chunk; 85 | } 86 | }; 87 | } 88 | 89 | onRequest(request) { 90 | switch (request.method) { 91 | case 'GET': 92 | return this.onGet(request); 93 | case 'POST': 94 | return this.onPost(request); 95 | case 'DELETE': 96 | return this.onDelete(request); 97 | default: 98 | return notFound(); 99 | } 100 | } 101 | 102 | onGet({path, contentType, body}) { 103 | return notFound(); 104 | } 105 | 106 | onPost({path, contentType, body}) { 107 | return notFound(); 108 | } 109 | 110 | onDelete({path, contentType, body}) { 111 | return notFound(); 112 | } 113 | }; 114 | 115 | function makeHeaders(headers) { 116 | const result = []; 117 | for (const key in headers) { 118 | result.push(key); 119 | result.push(headers[key]); 120 | } 121 | return result; 122 | } 123 | 124 | export function response(body, status, type = undefined, headers = {}) { 125 | if (type !== undefined) headers["Content-Type"] = type; 126 | 127 | let data = null; 128 | 129 | if (body && body.byteLength) { 130 | data = body; 131 | body = true; 132 | 133 | headers["Content-Length"] = data.byteLength; 134 | } 135 | 136 | return {body, data, status, headers: makeHeaders(headers)}; 137 | } 138 | 139 | export function textResponse(text, status = 200, headers = {}) { 140 | return response(text, status, "text/plain", headers); 141 | } 142 | 143 | export function htmlResponse(text, status = 200, headers = {}) { 144 | return response(text, status, "text/html; charset=utf-8", headers); 145 | } 146 | 147 | export function jsonResponse(data, status = 200, headers = {}) { 148 | return response(JSON.stringify(data) + "\n", status, "application/json", headers); 149 | } 150 | 151 | export function fileResponse(path, status, type, filename) { 152 | return response(fileObject, status, type, headers); 153 | } 154 | 155 | export function okResponse(status = 200, headers = {}) { 156 | return jsonResponse({success:true}, status, headers); 157 | } 158 | 159 | export function errorResponse(message, status = 400, headers = {}) { 160 | return jsonResponse({success: false, error: message}, status, headers); 161 | } 162 | 163 | export function notFound() { 164 | return errorResponse("Not found", 403); 165 | } 166 | 167 | export function ifTypeIs(expectedTypes, contentType, prepareIt, doIt, update) { 168 | if (!expectedTypes.includes(contentType)) { 169 | return errorResponse(`Content-type must be one of ${expectedTypes.join(', ')}\n`); 170 | } 171 | 172 | const value = prepareIt(); 173 | if (value === undefined) return errorResponse("Invalid value"); 174 | 175 | doIt(value); 176 | update(); 177 | return okResponse(); 178 | } 179 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Basuke 3 | */ 4 | 5 | import Net from "net" 6 | import config from "mc/config"; 7 | import Timer from "timer"; 8 | import Resource from "Resource"; 9 | import { 10 | HandyServer, 11 | ifTypeIs, 12 | htmlResponse, 13 | jsonResponse, 14 | okResponse, 15 | notFound, 16 | registerHostName 17 | } from "network"; 18 | import { 19 | getDimention, 20 | checkColor, 21 | makeColor, 22 | draw, 23 | drawOntoJpeg, 24 | fonts, 25 | registerTouchHandler, 26 | } from "screen"; 27 | import params from "params"; 28 | 29 | const port = config.port ? parseInt(config.port) : 80; 30 | if (!port) { 31 | trace(`Invalid port number: ${config.port}\n`); 32 | return; 33 | } 34 | 35 | let hostName = undefined; 36 | 37 | registerHostName(config.hostname ?? "air-sign", value => { 38 | const isInitialTextDisplayed = params.text == initialText(); 39 | 40 | hostName = value; 41 | 42 | if (isInitialTextDisplayed) { 43 | params.text = initialText(); 44 | update(); 45 | } 46 | }); 47 | 48 | function initialText() { 49 | const text = hostName ? `http://${hostName}.local\nor\n` : ''; 50 | return text + `http://${Net.get("IP")}`; 51 | } 52 | 53 | function update() { 54 | const {text, image} = params; 55 | 56 | const color = params.color ? makeColor(params.color) : undefined; 57 | const background = (!image && params.background) ? makeColor(params.background) : undefined; 58 | const font = fonts[params.font]; 59 | 60 | const task = render => { 61 | if (!image && background !== undefined) { 62 | render.fillRectangle(background, 0, 0, render.width, render.height); 63 | } 64 | 65 | if (text && color !== undefined) { 66 | const lines = text.split("\n"); 67 | let y = (render.height - font.height * lines.length) >> 1; 68 | 69 | for (const line of lines) { 70 | render.drawText(line, font, color, 71 | (render.width - render.getTextWidth(line, font)) >> 1, y); 72 | y += font.height; 73 | } 74 | } 75 | }; 76 | 77 | if (image) { 78 | if (!drawOntoJpeg(image, task)) { 79 | trace("Failed to render this JPEG.\n"); 80 | params.image = null; 81 | Timer.set(update, 0); 82 | } 83 | } else { 84 | draw(task); 85 | } 86 | } 87 | 88 | const server = new HandyServer({ 89 | port, 90 | verbose: ('verbose' in config ? !!config.verbose : true), 91 | root: config.file.root, 92 | }); 93 | 94 | const textType = 'text/plain'; 95 | const formType = 'application/x-www-form-urlencoded'; 96 | const imageType = 'image/jpeg'; 97 | 98 | server.onGet = ({path}) => { 99 | switch (path) { 100 | case '/': 101 | return htmlResponse(new Resource('index.html')); 102 | 103 | case '/reset': 104 | params.reset(); 105 | params.text = initialText(); 106 | update(); 107 | return okResponse(); 108 | 109 | case '/info': 110 | return jsonResponse({ 111 | ip: Net.get("IP"), 112 | ssid: Net.get("SSID"), 113 | hostName, 114 | fonts: Object.keys(fonts), 115 | ...getDimention(), 116 | }); 117 | 118 | case '/status': 119 | return jsonResponse(params.status()); 120 | 121 | default: 122 | return notFound(); 123 | } 124 | }; 125 | 126 | server.onPost = ({path, contentType, body, file}) => { 127 | const ifText = (prepareIt, doIt) => ifTypeIs([textType, formType], contentType, prepareIt, doIt, update); 128 | const ifImage = (prepareIt, doIt) => ifTypeIs([imageType], contentType, prepareIt, doIt, update); 129 | 130 | switch (path) { 131 | case '/busy': { 132 | const message = body ?? "BUSY!"; 133 | return ifText(() => message, value => { 134 | params.text = value; 135 | params.color = 'white'; 136 | params.background = 'maroon'; 137 | }); 138 | } 139 | 140 | case '/open': { 141 | const message = body ?? "Good to go!"; 142 | return ifText(() => message, value => { 143 | params.text = value; 144 | params.color = 'white'; 145 | params.background = 'green'; 146 | }); 147 | } 148 | 149 | case '/text': 150 | return ifText(() => body, value => params.text = value); 151 | 152 | case '/color': 153 | return ifText(() => checkColor(body) ? body : undefined, value => params.color = value); 154 | 155 | case '/background': 156 | return ifText(() => checkColor(body) ? body : undefined, value => params.background = value); 157 | 158 | case '/font': 159 | return ifText(() => body in fonts ? body : undefined, value => params.font = value); 160 | 161 | case '/image': 162 | return ifImage(() => file ? file : body, value => params.image = value); 163 | 164 | default: 165 | return notFound(); 166 | } 167 | }; 168 | 169 | server.onDelete = ({path}) => { 170 | switch (path) { 171 | case '/': 172 | params.text = undefined; 173 | params.color = undefined; 174 | params.background = undefined; 175 | params.font = 'L'; 176 | params.image = undefined; 177 | break; 178 | 179 | case '/text': 180 | params.text = undefined; 181 | break; 182 | 183 | case '/color': 184 | params.color = undefined; 185 | break; 186 | 187 | case '/background': 188 | params.background = undefined; 189 | break; 190 | 191 | case '/font': 192 | params.font = 'L'; 193 | break; 194 | 195 | case '/image': 196 | params.image = undefined; 197 | break; 198 | 199 | default: 200 | return notFound(); 201 | } 202 | 203 | update(); 204 | return okResponse(); 205 | }; 206 | 207 | params.init(); 208 | if (!params.text) params.text = initialText(); 209 | update(); 210 | 211 | const handler = registerTouchHandler({ 212 | onTouchBegan({x, y, began, ticks}) { 213 | trace(`onTouchBegan: ${x} ${y} ${began} ${ticks - began}\n`); 214 | }, 215 | 216 | onTouchMoved({x, y, began, ticks}) { 217 | trace(`onTouchMoved: ${x} ${y} ${began} ${ticks - began}\n`); 218 | }, 219 | 220 | onTouchEnded({x, y, began, ticks}) { 221 | trace(`onTouchEnded: ${x} ${y} ${began} ${ticks - began}\n`); 222 | }, 223 | }); 224 | -------------------------------------------------------------------------------- /screen.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Basuke 3 | */ 4 | 5 | import config from "mc/config"; 6 | import Poco from "commodetto/Poco"; 7 | import JPEG from "commodetto/readJPEG"; 8 | import { File } from "file"; 9 | import Resource from "Resource"; 10 | import parseBMF from "commodetto/parseBMF"; 11 | import Timer from "timer"; 12 | import Time from "time"; 13 | 14 | export const fonts = { 15 | S: parseBMF(new Resource("OpenSans-Semibold-16.bf4")), 16 | M: parseBMF(new Resource("OpenSans-Semibold-20.bf4")), 17 | L: parseBMF(new Resource("OpenSans-Semibold-28.bf4")), 18 | }; 19 | 20 | 21 | if (screen.rotation !== undefined) { 22 | screen.rotation = 270; 23 | } 24 | 25 | export const render = new Poco(screen, { displayListLength: 2048 }); 26 | 27 | export function getDimention() { 28 | return { width: render.width, height:render.height }; 29 | } 30 | 31 | export function draw(task) { 32 | render.begin(); 33 | task(render); 34 | render.end(); 35 | } 36 | 37 | function hex(str, offset, count) { 38 | let result = parseInt(str.substring(offset, offset + count), 16); 39 | if (count === 1) 40 | result *= 17; 41 | return result; 42 | } 43 | 44 | function rgba(name) { 45 | if (!name) return false; 46 | 47 | const cssColors = { 48 | black: [0x00, 0x00, 0x00, 0xff], 49 | silver: [0xc0, 0xc0, 0xc0, 0xff], 50 | gray: [0x80, 0x80, 0x80, 0xff], 51 | white: [0xff, 0xff, 0xff, 0xff], 52 | maroon: [0x80, 0x00, 0x00, 0xff], 53 | red: [0xff, 0x00, 0x00, 0xff], 54 | purple: [0x80, 0x00, 0x80, 0xff], 55 | fuchsia: [0xff, 0x00, 0xff, 0xff], 56 | green: [0x00, 0x80, 0x00, 0xff], 57 | lime: [0x00, 0xff, 0x00, 0xff], 58 | olive: [0x80, 0x80, 0x00, 0xff], 59 | yellow: [0xff, 0xff, 0x00, 0xff], 60 | navy: [0x00, 0x00, 0x80, 0xff], 61 | blue: [0x00, 0x00, 0xff, 0xff], 62 | teal: [0x00, 0x80, 0x80, 0xff], 63 | aqua: [0x00, 0xff, 0xff, 0xff], 64 | orange: [0xff, 0xa5, 0x00, 0xff], 65 | transparent: [0x00, 0x00, 0x00, 0x0], 66 | }; 67 | 68 | if (cssColors[name]) { 69 | return cssColors[name]; 70 | } 71 | 72 | let red = 0, green = 0, blue = 0, alpha = 255; 73 | if (name[0] === '#') { 74 | switch (name.length) { 75 | case 5: 76 | alpha = hex(name, 4, 1); 77 | case 4: 78 | red = hex(name, 1, 1); 79 | green = hex(name, 2, 1); 80 | blue = hex(name, 3, 1); 81 | break; 82 | 83 | case 9: 84 | alpha = hex(name, 7, 2); 85 | case 7: 86 | red = hex(name, 1, 2); 87 | green = hex(name, 3, 2); 88 | blue = hex(name, 5, 2); 89 | break; 90 | 91 | default: 92 | return false; 93 | } 94 | } 95 | return [red, green, blue, alpha]; 96 | } 97 | 98 | export function makeColor(name) { 99 | return render.makeColor(...(rgba(name) ?? [0, 0, 0, 0])); 100 | } 101 | 102 | export function checkColor(name) { 103 | return !!rgba(name); 104 | } 105 | 106 | export function drawOntoJpeg(path, task) { 107 | try { 108 | const jpeg = new JPEG(); 109 | let file = new File(path); 110 | 111 | while (file) { 112 | while (file) { 113 | const bytes = file.read(ArrayBuffer, 1024); 114 | jpeg.push(bytes); 115 | if (file.position === file.length) { 116 | jpeg.push(); 117 | file.close(); 118 | file = undefined; 119 | } 120 | if (jpeg.ready) break; 121 | } 122 | 123 | while (jpeg.ready) { 124 | const block = jpeg.read(); 125 | render.begin(block.x, block.y, block.width, block.height); 126 | render.drawBitmap(block, block.x, block.y); 127 | task(render); 128 | render.end(); 129 | } 130 | } 131 | } catch (error) { 132 | trace(`screen.js: ${error}\n`); 133 | return false; 134 | } 135 | 136 | return true; 137 | } 138 | 139 | const range = n => Array.from(new Array(n).keys()); 140 | 141 | export function registerTouchHandler(handlers, interval=10) { 142 | if (!config.Touch) 143 | return; 144 | 145 | const touch = new config.Touch; 146 | const touchCount = config.touchCount ? config.touchCount : 1; 147 | const points = range(touchCount).map(index => ({index})); 148 | const rotate = rotationFunction(screen); 149 | 150 | const timer = Timer.repeat(() => { 151 | touch.read(points); 152 | const ticks = Time.ticks; 153 | 154 | for (const point of points) { 155 | switch (point.state) { 156 | case 3: 157 | if (rotate) rotate(point); 158 | // fallthrough 159 | case 0: 160 | if (point.began) { 161 | point.ticks = ticks; 162 | handlers.onTouchEnded(point); 163 | delete point.began; 164 | delete point.ticks; 165 | delete point.x; 166 | delete point.y; 167 | } 168 | break; 169 | 170 | case 1: 171 | case 2: 172 | if (rotate) rotate(point); 173 | 174 | if (point.began) { 175 | point.ticks = ticks; 176 | handlers.onTouchMoved(point); 177 | } else { 178 | point.ticks = point.began = ticks; 179 | handlers.onTouchBegan(point); 180 | } 181 | break; 182 | } 183 | } 184 | }, interval); 185 | 186 | return { 187 | touch, 188 | timer, 189 | touchCount, 190 | points, 191 | }; 192 | } 193 | 194 | function rotationFunction(screen) { 195 | const {width, height} = screen; 196 | 197 | switch (screen.rotation) { 198 | case 90: 199 | return point => { 200 | const x = point.x; 201 | point.x = point.y; 202 | point.y = height - x; 203 | }; 204 | case 180: 205 | return point => { 206 | point.x = width - point.x; 207 | point.y = height - point.y; 208 | }; 209 | case 270: 210 | return point => { 211 | const x = point.x; 212 | point.x = width - point.y; 213 | point.y = x; 214 | }; 215 | default: 216 | return undefined; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | air-sign 5 | 6 | 7 |
8 |

air-sign

9 |
10 |
11 | 12 | 13 |
14 |
15 |
16 |

Custom message

17 | 18 | 19 |
20 |
21 |
22 |

Text Color

23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 |
43 |

Background Color

44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 |
64 | 65 |
66 |
67 |
68 | 69 | 133 | 134 | 232 | 233 | 234 | -------------------------------------------------------------------------------- /m2-enclosure.scad: -------------------------------------------------------------------------------- 1 | Flip = true; 2 | SnapRadius = 0.7; 3 | SnapCount = 4; 4 | SnapMargin = 20; 5 | 6 | module enclosure() { 7 | 8 | C = 0.25; // clearance 9 | T = 2; // basic thickness 10 | S = 2; // space between base and Moddable Two 11 | 12 | // Body and PCB board 13 | body_width = 81.73; 14 | body_height = 47.37; 15 | body_depth = 4.64; 16 | 17 | // LCD panel 18 | panel_width = 66.68; 19 | panel_height = 44.45; 20 | panel_depth = 1.04; 21 | panel_offset_left = 9.04; 22 | panel_offset_top = 1.80; 23 | 24 | // Screen 25 | screen_width = 49.48; 26 | screen_height = 37.11; 27 | screen_offset_left = 11.55; 28 | screen_offset_top = 3.38; 29 | 30 | // Entire dimmension 31 | max_width = body_width; 32 | max_height = body_height; 33 | max_depth = 8.42; 34 | 35 | // Mount hole 36 | hole_diameter = 2.5; 37 | hole_radius = hole_diameter / 2; 38 | hole_offset_left = 1.47; 39 | hole_offset_top = 1.60; 40 | hole_offset_right = 1.2; 41 | hole_offset_bottom = 1.2; 42 | 43 | // Bigger pins 44 | pin_top = 7.78; 45 | 46 | // Whole model 47 | // adjustment. make left and right side equal width. 48 | left_width = T + C + panel_offset_left + screen_offset_left; 49 | 50 | width = left_width * 2 + screen_width; // max_width + C * 2 + T * 2; 51 | height = max_height + C * 2 + T * 2; 52 | depth = max_depth + C * 2 + T * 2 + S; 53 | 54 | module rCutOut(radius=T, height=1, rotate=0) { 55 | D = 0.1; 56 | h = height + D * 2; 57 | rotate([0, 0, 180 + rotate]) 58 | translate([-radius, -radius, 0]) 59 | difference() { 60 | translate([0, 0, -D]) cube([radius + D, radius + D, h]); 61 | translate([0, 0, -D * 2]) cylinder(r=T, h=h + D * 2, $fn=60); 62 | } 63 | } 64 | 65 | module snap() { 66 | radius = SnapRadius; 67 | dx = (width - SnapMargin * 2) / (SnapCount - 1); 68 | 69 | for (i=[0:SnapCount - 1]) { 70 | translate([SnapMargin + i * dx, 0, 0]) 71 | sphere(r=radius, $fn=20); 72 | } 73 | 74 | } 75 | 76 | module cover(flip=false) { 77 | module screenHole() { 78 | function scaleForAngle(length, height, angle) = 79 | (length + 2 * height * tan(angle)) / length; 80 | function scalesForAngle(width, height, depth, angle) = 81 | [scaleForAngle(width, depth, angle), scaleForAngle(height, depth, angle)]; 82 | 83 | left = T + C + panel_offset_left + screen_offset_left; 84 | top = T + C + panel_offset_top + screen_offset_top; 85 | scale = scalesForAngle(screen_width, screen_height, T, 45); 86 | 87 | translate([left + C, top + C, 0]) 88 | translate([screen_width / 2, screen_height / 2, T]) 89 | rotate([180, 0, 0]) 90 | linear_extrude(height=T, scale=scale) 91 | square([screen_width, screen_height], center=true); 92 | } 93 | 94 | module front() { 95 | difference() { 96 | cube([width, height, T]); 97 | translate([0, 0, -0.001]) scale([1, 1, 1.01]) screenHole(); 98 | } 99 | } 100 | 101 | module top() { 102 | cube([width, T, depth]); 103 | } 104 | 105 | module bottom() { 106 | translate([0, height - T, 0]) top(); 107 | } 108 | 109 | module right() { 110 | translate([width - T, 0, 0]) cube([T, height, depth]); 111 | } 112 | 113 | module mounterPole(isLeft, isTop) { 114 | function mounterPosition(left, top) = 115 | let ( 116 | d = T + C, 117 | r = hole_radius, 118 | left = d + hole_offset_left + r, 119 | top = d + hole_offset_top + r, 120 | right = max_width + d - hole_offset_right - r, 121 | bottom = max_height + d - hole_offset_bottom - r 122 | ) 123 | isLeft 124 | ? isTop 125 | ? [left, top, 0] 126 | : [left, bottom, 0] 127 | : isTop 128 | ? [right, top, 0] 129 | : [right, bottom, 0]; 130 | 131 | h = T + panel_depth + body_depth + 2; 132 | r = hole_radius * 0.9; 133 | 134 | translate(mounterPosition()) 135 | cylinder(r=hole_radius, h=h); 136 | } 137 | 138 | module rightStopper() { 139 | y = 10; 140 | stopper_height = T + C + panel_depth + body_depth + T; 141 | translate([T + C + max_width, (height - y) / 2, 0]) union() { 142 | cube([T, y, stopper_height]); 143 | translate([-T, 0, stopper_height - T]) cube([T * 2, y, T]); 144 | } 145 | } 146 | 147 | module leftStopper(isTop) { 148 | radius = 1; 149 | length = 8; 150 | z = T + C + panel_depth + body_depth + radius; 151 | 152 | module body() { 153 | translate([T + C, T, z]) 154 | rotate([0, 90, 0]) 155 | cylinder(r=radius, h=length, $fn=60); 156 | } 157 | 158 | if (isTop) 159 | body(); 160 | else 161 | translate([0, max_height + C * 2, 0]) 162 | body(); 163 | } 164 | 165 | module base() { 166 | difference() { 167 | union() { 168 | front(); 169 | top(); 170 | bottom(); 171 | right(); 172 | 173 | rightStopper(); 174 | leftStopper(true); 175 | leftStopper(false); 176 | 177 | translate([0, T, depth - T / 2]) snap(); 178 | translate([0, height - T, depth - T / 2]) snap(); 179 | } 180 | rCutOut(height=depth); 181 | translate([width, 0, 0]) rCutOut(height=depth, rotate=90); 182 | translate([width, height, 0]) rCutOut(height=depth, rotate=180); 183 | translate([0, height, 0]) rCutOut(height=depth, rotate=270); 184 | } 185 | } 186 | 187 | if (flip) 188 | translate([0, height * 1.2, 0]) 189 | base(); 190 | else 191 | translate([0, height, depth]) 192 | rotate([180, 0, 0]) 193 | base(); 194 | } 195 | 196 | module base() { 197 | base_height = height - T * 2; 198 | 199 | module usbCutOut() { 200 | cutout_width = 15; 201 | cutout_height = 20; // was 10, but make cutout through top for convenience. 202 | cutout_center_from_bottom = 11.60; 203 | usb_height = 3.60; 204 | 205 | cutout_y = cutout_center_from_bottom - cutout_width / 2; 206 | cutout_z = depth - T - panel_depth - body_depth - usb_height / 2 - cutout_height / 2; 207 | 208 | translate([0, cutout_y, cutout_z]) 209 | cube([T, cutout_width, cutout_height]); 210 | } 211 | 212 | module left() { // USB Connector side 213 | translate([0, T + C, 0]) 214 | difference() { 215 | cube([T, base_height - C * 2, depth - T]); 216 | scale([1.1, 1, 1]) 217 | translate([-0.01, 0, 0]) 218 | usbCutOut(); 219 | } 220 | } 221 | 222 | module right() { // To hold the cover 223 | translate([width - T * 2, T + C, 0]) 224 | cube([T, base_height - C * 2, depth - T]); 225 | } 226 | 227 | module back() { 228 | module cutOut() { 229 | dx = 12; 230 | dy = 8; 231 | 232 | translate([dx, dy, 0]) 233 | cube([width - T - dx * 2, base_height - dy * 2, T]); 234 | } 235 | 236 | translate([0, T, 0]) 237 | difference() { 238 | cube([width - T, base_height, T]); 239 | scale([1, 1, 1.1]) translate([0, 0, -0.01]) cutOut(); 240 | translate([0, 0, T / 2]) snap(); 241 | translate([0, height - 2 * T, T / 2]) snap(); 242 | } 243 | } 244 | 245 | union() { 246 | back(); 247 | left(); 248 | right(); 249 | } 250 | } 251 | 252 | color("white") cover(flip=Flip); 253 | color("green") base(); 254 | } 255 | 256 | enclosure(); 257 | --------------------------------------------------------------------------------