diff options
-rw-r--r-- | .bumpversion.cfg | 2 | ||||
-rw-r--r-- | PKGBUILD | 2 | ||||
-rw-r--r-- | kvmd/__init__.py | 2 | ||||
-rw-r--r-- | kvmd/apps/__init__.py | 4 | ||||
-rw-r--r-- | kvmd/apps/kvmd/api/export.py | 10 | ||||
-rw-r--r-- | kvmd/apps/kvmd/api/ugpio.py | 3 | ||||
-rw-r--r-- | kvmd/apps/kvmd/server.py | 5 | ||||
-rw-r--r-- | kvmd/apps/kvmd/ugpio.py | 56 | ||||
-rwxr-xr-x | setup.py | 2 | ||||
-rw-r--r-- | testenv/v2-hdmi-rpi4.override.yaml | 71 | ||||
-rw-r--r-- | web/kvm/index.html | 3 | ||||
-rw-r--r-- | web/kvm/navbar-gpio.pug | 4 | ||||
-rw-r--r-- | web/kvm/navbar.pug | 1 | ||||
-rw-r--r-- | web/share/css/main.css | 7 | ||||
-rw-r--r-- | web/share/js/index/main.js | 2 | ||||
-rw-r--r-- | web/share/js/kvm/gpio.js | 163 | ||||
-rw-r--r-- | web/share/js/kvm/session.js | 5 | ||||
-rw-r--r-- | web/share/svg/led-circle.svg | 128 |
18 files changed, 447 insertions, 23 deletions
diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0592f4fa..7a68dbc7 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 1.98 +current_version = 1.99 parse = (?P<major>\d+)\.(?P<minor>\d+)(\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?)? serialize = {major}.{minor} @@ -26,7 +26,7 @@ for _variant in "${_variants[@]}"; do pkgname+=(kvmd-platform-$_platform-$_board) done pkgbase=kvmd -pkgver=1.98 +pkgver=1.99 pkgrel=1 pkgdesc="The main Pi-KVM daemon" url="https://github.com/pikvm/kvmd" diff --git a/kvmd/__init__.py b/kvmd/__init__.py index 3e3c37d4..b570eafa 100644 --- a/kvmd/__init__.py +++ b/kvmd/__init__.py @@ -20,4 +20,4 @@ # ========================================================================== # -__version__ = "1.98" +__version__ = "1.99" diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 28fbfc75..7202cfbc 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -187,6 +187,7 @@ def _patch_dynamic( # pylint: disable=too-many-locals } if mode == "output": ch_scheme.update({ + "busy_delay": Option(0.2, type=valid_float_f01), "initial": Option(False, type=valid_bool), "switch": Option(True, type=valid_bool), "pulse": { @@ -328,8 +329,7 @@ def _get_config_scheme() -> Dict: "scheme": {}, # Dymanic content "view": { "header": { - "title": Option("Switches"), - "leds": Option([], type=valid_string_list), + "title": Option("GPIO"), }, "table": Option([], type=valid_ugpio_view_table), }, diff --git a/kvmd/apps/kvmd/api/export.py b/kvmd/apps/kvmd/api/export.py index c37feb57..6dae0ce1 100644 --- a/kvmd/apps/kvmd/api/export.py +++ b/kvmd/apps/kvmd/api/export.py @@ -32,27 +32,33 @@ from aiohttp.web import Response from ....plugins.atx import BaseAtx from ..info import InfoManager +from ..ugpio import UserGpio from ..http import exposed_http # ===== class ExportApi: - def __init__(self, info_manager: InfoManager, atx: BaseAtx) -> None: + def __init__(self, info_manager: InfoManager, atx: BaseAtx, user_gpio: UserGpio) -> None: self.__info_manager = info_manager self.__atx = atx + self.__user_gpio = user_gpio # ===== @exposed_http("GET", "/export/prometheus/metrics") async def __prometheus_metrics_handler(self, _: Request) -> Response: - (atx_state, hw_state) = await asyncio.gather(*[ + (atx_state, hw_state, gpio_state) = await asyncio.gather(*[ self.__atx.get_state(), self.__info_manager.get_submanager("hw").get_state(), + self.__user_gpio.get_state(), ]) rows: List[str] = [] self.__append_prometheus_rows(rows, atx_state["enabled"], "pikvm_atx_enabled") self.__append_prometheus_rows(rows, atx_state["leds"]["power"], "pikvm_atx_power") + for mode in ["input", "output"]: + for (channel, gch) in gpio_state[f"{mode}s"].items(): + self.__append_prometheus_rows(rows, gch["state"], f"pikvm_gpio_input_{channel}") if hw_state is not None: self.__append_prometheus_rows(rows, hw_state["health"], "pikvm_hw") return Response(text="\n".join(rows)) diff --git a/kvmd/apps/kvmd/api/ugpio.py b/kvmd/apps/kvmd/api/ugpio.py index 69ac6646..66f7b358 100644 --- a/kvmd/apps/kvmd/api/ugpio.py +++ b/kvmd/apps/kvmd/api/ugpio.py @@ -44,8 +44,7 @@ class UserGpioApi: @exposed_http("GET", "/gpio") async def __state_handler(self, _: Request) -> Response: return make_json_response({ - "scheme": (await self.__user_gpio.get_scheme()), - "view": (await self.__user_gpio.get_view()), + "model": (await self.__user_gpio.get_model()), "state": (await self.__user_gpio.get_state()), }) diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 30d5c035..57d8c3e9 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -191,7 +191,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins AtxApi(atx), MsdApi(msd, sync_chunk_size), StreamerApi(streamer), - ExportApi(info_manager, atx), + ExportApi(info_manager, atx, user_gpio), ] self.__ws_handlers: Dict[str, Callable] = {} @@ -244,8 +244,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins await client.ws.prepare(request) await self.__register_ws_client(client) try: - await self.__broadcast_event("gpio_scheme_state", await self.__user_gpio.get_scheme()) - await self.__broadcast_event("gpio_view_state", await self.__user_gpio.get_view()) + await self.__broadcast_event("gpio_model_state", await self.__user_gpio.get_model()) await asyncio.gather(*[ self.__broadcast_event(component.event_type, await component.get_state()) for component in self.__components diff --git a/kvmd/apps/kvmd/ugpio.py b/kvmd/apps/kvmd/ugpio.py index 97556aba..c6cfc121 100644 --- a/kvmd/apps/kvmd/ugpio.py +++ b/kvmd/apps/kvmd/ugpio.py @@ -23,6 +23,7 @@ import asyncio import operator +from typing import List from typing import Dict from typing import AsyncGenerator from typing import Optional @@ -88,6 +89,7 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes self.__pulse_delay: float = config.pulse.delay self.__min_pulse_delay: float = config.pulse.min_delay self.__max_pulse_delay: float = config.pulse.max_delay + self.__busy_delay: float = config.busy_delay self.__state = config.initial self.__region = aiotools.AioExclusiveRegion(GpioChannelIsBusyError, notifier) @@ -97,8 +99,8 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes "switch": self.__switch, "pulse": { "delay": self.__pulse_delay, - "min_delay": self.__min_pulse_delay, - "max_delay": self.__max_pulse_delay, + "min_delay": (self.__min_pulse_delay if self.__pulse_delay else 0), + "max_delay": (self.__max_pulse_delay if self.__pulse_delay else 0), }, } @@ -125,8 +127,10 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes self.__write(state) self.__state = state get_logger(0).info("Switched GPIO %s to %d", self, state) + await asyncio.sleep(self.__busy_delay) return True self.__state = real_state + await asyncio.sleep(self.__busy_delay) return False @aiotools.atomic @@ -146,7 +150,7 @@ class _GpioOutput: # pylint: disable=too-many-instance-attributes await asyncio.sleep(delay) finally: self.__write(False) - await asyncio.sleep(1) + await asyncio.sleep(self.__busy_delay) get_logger(0).info("Pulsed GPIO %s", self) def __read(self) -> bool: @@ -185,15 +189,15 @@ class UserGpio: else: # output: self.__outputs[channel] = _GpioOutput(channel, ch_config, self.__state_notifier) - async def get_scheme(self) -> Dict: + async def get_model(self) -> Dict: return { - "inputs": {channel: gin.get_scheme() for (channel, gin) in self.__inputs.items()}, - "outputs": {channel: gout.get_scheme() for (channel, gout) in self.__outputs.items()}, + "scheme": { + "inputs": {channel: gin.get_scheme() for (channel, gin) in self.__inputs.items()}, + "outputs": {channel: gout.get_scheme() for (channel, gout) in self.__outputs.items()}, + }, + "view": self.__make_view(), } - async def get_view(self) -> Dict: - return self.__view - async def get_state(self) -> Dict: return { "inputs": {channel: gin.get_state() for (channel, gin) in self.__inputs.items()}, @@ -240,3 +244,37 @@ class UserGpio: if gout is None: raise GpioChannelNotFoundError() await gout.pulse(delay) + + # ===== + + def __make_view(self) -> Dict: + table: List[Optional[List[Dict]]] = [] + for row in self.__view["table"]: + if len(row) == 0: + table.append(None) + continue + + items: List[Dict] = [] + for item in map(str.strip, row): + if item.startswith("#") or len(item) == 0: + items.append({ + "type": "label", + "text": item[1:].strip(), + }) + elif (parts := list(map(str.strip, item.split(",", 1)))): + if parts[0] in self.__inputs: + items.append({ + "type": "input", + "channel": parts[0], + }) + elif parts[0] in self.__outputs: + items.append({ + "type": "output", + "channel": parts[0], + "text": (parts[1] if len(parts) > 1 else "Click"), + }) + table.append(items) + return { + "header": self.__view["header"], + "table": table, + } @@ -67,7 +67,7 @@ def main() -> None: setup( name="kvmd", - version="1.98", + version="1.99", url="https://github.com/pikvm/kvmd", license="GPLv3", author="Maxim Devaev", diff --git a/testenv/v2-hdmi-rpi4.override.yaml b/testenv/v2-hdmi-rpi4.override.yaml index a297e78b..1977393a 100644 --- a/testenv/v2-hdmi-rpi4.override.yaml +++ b/testenv/v2-hdmi-rpi4.override.yaml @@ -37,6 +37,77 @@ kvmd: - "--notify-parent" - "--no-log-colors" + gpio: + scheme: + host1: # any name like foo_bar_baz + pin: 1 + mode: input + host2: + pin: 2 + mode: input + host3: + pin: 3 + mode: input + host4: + pin: 4 + mode: input + change_host: + pin: 5 + mode: output + switch: false + + host1_pwr: + pin: 11 + mode: input + host2_pwr: + pin: 12 + mode: input + host3_pwr: + pin: 13 + mode: input + host4_pwr: + pin: 14 + mode: input + + host1_pwr_btn: + pin: 21 + mode: output + switch: false + host2_pwr_btn: + pin: 22 + mode: output + switch: false + host3_pwr_btn: + pin: 23 + mode: output + switch: false + host4_pwr_btn: + pin: 24 + mode: output + switch: false + + lamp: + pin: 50 + mode: output + pulse: + delay: 0 + + view: + header: + title: Switch + table: + - ["#Multihost controller"] + - [] + - ["", "#Current", "#Power"] + - ["#host1.localdomain:", host1, host1_pwr, "host1_pwr_btn,Pwr"] + - ["#host2.localdomain:", host2, host2_pwr, "host2_pwr_btn,Pwr"] + - ["#host3.localdomain:", host3, host3_pwr, "host3_pwr_btn,Pwr"] + - ["#host4.localdomain:", host4, host4_pwr, "host4_pwr_btn,Pwr"] + - [] + - ["change_host,Change host"] + - [] + - ["#Lamp in the rack", lamp] + vnc: keymap: /usr/share/kvmd/keymaps/ru diff --git a/web/kvm/index.html b/web/kvm/index.html index 727888ae..0c0b3ff0 100644 --- a/web/kvm/index.html +++ b/web/kvm/index.html @@ -328,6 +328,9 @@ </div> </div> </li> + <li class="right feature-disabled" id="gpio-dropdown"><a class="menu-button" id="gpio-menu-button" href="#">GPIO ↴</a> + <div class="menu" data-dont-hide-menu id="gpio-menu"></div> + </li> <li class="right"><a class="menu-button" href="#"><img class="led-gray" data-dont-hide-menu id="hid-recorder-led" src="/share/svg/led-gear.svg">Macro ↴</a> <div class="menu" data-dont-hide-menu> <div class="text"><b>Record and play keyboard & mouse actions<br></b><sub>For security reasons, the record will not saved on Pi-KVM</sub></div> diff --git a/web/kvm/navbar-gpio.pug b/web/kvm/navbar-gpio.pug new file mode 100644 index 00000000..280ba3fd --- /dev/null +++ b/web/kvm/navbar-gpio.pug @@ -0,0 +1,4 @@ +li(id="gpio-dropdown" class="right feature-disabled") + a(class="menu-button" id="gpio-menu-button" href="#") + | GPIO ↴ + div(data-dont-hide-menu id="gpio-menu" class="menu") diff --git a/web/kvm/navbar.pug b/web/kvm/navbar.pug index d7743160..422cf236 100644 --- a/web/kvm/navbar.pug +++ b/web/kvm/navbar.pug @@ -23,5 +23,6 @@ ul(id="navbar") include navbar-system.pug include navbar-atx.pug include navbar-msd.pug + include navbar-gpio.pug include navbar-macro.pug include navbar-shortcuts.pug diff --git a/web/share/css/main.css b/web/share/css/main.css index 443b637a..b41ea15a 100644 --- a/web/share/css/main.css +++ b/web/share/css/main.css @@ -94,6 +94,13 @@ img.inline-lamp { margin-right: 2px; } +img.inline-lamp-big { + vertical-align: middle; + height: 20px; + margin-left: 2px; + margin-right: 2px; +} + button, select { border: none; diff --git a/web/share/js/index/main.js b/web/share/js/index/main.js index 24a694b8..ac6cf3fc 100644 --- a/web/share/js/index/main.js +++ b/web/share/js/index/main.js @@ -40,7 +40,7 @@ export function main() { function __setAppText() { $("app-text").innerHTML = ` <span class="code-comment"># On Linux using Chromium/Chrome via any terminal:<br> - $</span> \`which chromium 2>/dev/null || which chrome 2>/dev/null\` --app="${window.location.href}"<br> + $</span> \`which chromium 2>/dev/null || which chrome 2>/dev/null || which google-chrome\` --app="${window.location.href}"<br> <br> <span class="code-comment"># On MacOS using Terminal application:<br> $</span> /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --app="${window.location.href}"<br> diff --git a/web/share/js/kvm/gpio.js b/web/share/js/kvm/gpio.js new file mode 100644 index 00000000..56bd5d14 --- /dev/null +++ b/web/share/js/kvm/gpio.js @@ -0,0 +1,163 @@ +/***************************************************************************** +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +*****************************************************************************/ + + +"use strict"; + + +import {tools, $, $$$} from "../tools.js"; +import {wm} from "../wm.js"; + + +export function Gpio() { + var self = this; + + /************************************************************************/ + + var __state = null; + + /************************************************************************/ + + self.setState = function(state) { + if (state) { + for (let channel in state.inputs) { + let el = $(`gpio-led-${channel}`); + if (el) { + __setLedState(el, state.inputs[channel].state); + } + } + for (let channel in state.outputs) { + for (let type of ["switch", "button"]) { + let el = $(`gpio-${type}-${channel}`); + if (el) { + wm.switchEnabled(el, !state.outputs[channel].busy); + } + } + } + } else { + for (let el of $$$(".gpio-led")) { + __setLedState(el, false); + } + for (let selector of [".gpio-switch", ".gpio-button"]) { + for (let el of $$$(selector)) { + wm.switchEnabled(el, false); + } + } + } + __state = state; + }; + + self.setModel = function(model) { + tools.featureSetEnabled($("gpio-dropdown"), model.view.table.length); + if (model.view.table.length) { + $("gpio-menu-button").innerHTML = `${model.view.header.title} ↴`; + } + + let switches = []; + let buttons = []; + let content = "<table class=\"kv\">"; + for (let row of model.view.table) { + if (row === null) { + content += "</table><hr><table class=\"kv\">"; + } else { + content += "<tr>"; + for (let item of row) { + if (item.type === "output") { + item.scheme = model.scheme.outputs[item.channel]; + } + content += `<td align="center">${__createItem(item, switches, buttons)}</td>`; + } + content += "</tr>"; + } + } + content += "</table>"; + $("gpio-menu").innerHTML = content; + + for (let channel of switches) { + tools.setOnClick($(`gpio-switch-${channel}`), () => __switchChannel(channel)); + } + for (let channel of buttons) { + tools.setOnClick($(`gpio-button-${channel}`), () => __pulseChannel(channel)); + } + + self.setState(__state); + }; + + var __createItem = function(item, switches, buttons) { + if (item.type === "label") { + return item.text; + } else if (item.type === "input") { + return `<img id="gpio-led-${item.channel}" class="gpio-led inline-lamp-big led-gray" src="/share/svg/led-circle.svg" />`; + } else if (item.type === "output") { + let controls = []; + if (item.scheme["switch"]) { + switches.push(item.channel); + controls.push(` + <td><div class="switch-box"> + <input disabled type="checkbox" id="gpio-switch-${item.channel}" class="gpio-switch" /> + <label for="gpio-switch-${item.channel}"> + <span class="switch-inner"></span> + <span class="switch"></span> + </label> + </div></td> + `); + } + if (item.scheme.pulse.delay) { + buttons.push(item.channel); + controls.push(`<td><button disabled id="gpio-button-${item.channel}" class="gpio-button">${item.text}</button></td>`); + } + return `<table><tr>${controls.join("<td> </td>")}</tr></table>`; + } else { + return ""; + } + }; + + var __setLedState = function(el, state) { + if (state) { + el.classList.add("led-green"); + el.classList.remove("led-gray"); + } else { + el.classList.add("led-gray"); + el.classList.remove("led-green"); + } + }; + + var __switchChannel = function(channel) { + let to = ($(`gpio-switch-${channel}`).checked ? "1" : "0"); + __sendPost(`/api/gpio/switch?channel=${channel}&state=${to}`); + }; + + var __pulseChannel = function(channel) { + __sendPost(`/api/gpio/pulse?channel=${channel}`); + }; + + var __sendPost = function(url) { + let http = tools.makeRequest("POST", url, function() { + if (http.readyState === 4) { + if (http.status === 409) { + wm.error("Performing another operation for this GPIO channel.<br>Please try again later"); + } else if (http.status !== 200) { + wm.error("GPIO error:<br>", http.responseText); + } + } + }); + }; +} diff --git a/web/share/js/kvm/session.js b/web/share/js/kvm/session.js index 06ffcd95..7112d51c 100644 --- a/web/share/js/kvm/session.js +++ b/web/share/js/kvm/session.js @@ -31,6 +31,7 @@ import {Atx} from "./atx.js"; import {Msd} from "./msd.js"; import {Streamer} from "./stream.js"; import {WakeOnLan} from "./wol.js"; +import {Gpio} from "./gpio.js"; export function Session() { @@ -48,6 +49,7 @@ export function Session() { var __msd = new Msd(); var __streamer = new Streamer(); var __wol = new WakeOnLan(); + var __gpio = new Gpio(); var __init__ = function() { __startSession(); @@ -211,6 +213,8 @@ export function Session() { case "info_hw_state": __setAboutInfoHw(data.event); break; case "info_system_state": __setAboutInfoSystem(data.event); break; case "wol_state": __wol.setState(data.event); break; + case "gpio_model_state": __gpio.setModel(data.event); break; + case "gpio_state": __gpio.setState(data.event); break; case "hid_state": __hid.setState(data.event); break; case "atx_state": __atx.setState(data.event); break; case "msd_state": __msd.setState(data.event); break; @@ -237,6 +241,7 @@ export function Session() { __ping_timer = null; } + __gpio.setState(null); __hid.setSocket(null); __atx.setState(null); __msd.setState(null); diff --git a/web/share/svg/led-circle.svg b/web/share/svg/led-circle.svg new file mode 100644 index 00000000..761b9dcf --- /dev/null +++ b/web/share/svg/led-circle.svg @@ -0,0 +1,128 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + height="481.1192" + width="481.1192" + inkscape:version="1.0 (4035a4fb49, 2020-05-01)" + sodipodi:docname="led-circle.svg" + xml:space="preserve" + viewBox="0 0 481.02523 481.1192" + y="0px" + x="0px" + id="Layer_1" + version="1.1"><metadata + id="metadata85"><rdf:RDF><cc:Work + rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs + id="defs83" /><sodipodi:namedview + fit-margin-bottom="0" + fit-margin-right="0" + fit-margin-left="0" + fit-margin-top="0" + lock-margins="false" + inkscape:document-rotation="0" + inkscape:current-layer="Layer_1" + inkscape:window-maximized="1" + inkscape:window-y="28" + inkscape:window-x="0" + inkscape:cy="226.75611" + inkscape:cx="207.75746" + inkscape:zoom="0.921875" + showgrid="false" + id="namedview81" + inkscape:window-height="714" + inkscape:window-width="1366" + inkscape:pageshadow="2" + inkscape:pageopacity="0" + guidetolerance="10" + gridtolerance="10" + objecttolerance="10" + borderopacity="1" + bordercolor="#666666" + pagecolor="#ffffff" /> + + + + + + + + +<g + transform="translate(-15.683731,-12.674362)" + id="g50"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g52"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g54"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g56"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g58"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g60"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g62"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g64"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g66"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g68"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g70"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g72"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g74"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g76"> +</g> +<g + transform="translate(-15.683731,-12.674362)" + id="g78"> +</g> +<rect + y="45.901909" + x="10.350166" + height="351.45764" + width="478.37289" + id="rect4830" + style="opacity:1;fill:none;fill-opacity:1;paint-order:normal" /><circle + r="240.5596" + cy="240.5596" + cx="240.51262" + id="path857" + style="fill:#000000" /></svg> |