Musson Industrial's Embr-Periscope Module

I have to update my Blob Server and my Image Streamer for this. Since there won't be any more features in the 8.1 branch, I will simply keep the previous module file available for users not yet on 8.1.49+. No separate branch.

If there are any bug fixes, users will have to upgrade the platform too, just like any IA bugfix.

2 Likes

Release Notice

Embr v8.1 - 2026.6.6

This release bumps the minimum compatible Ignition version from 8.1.33 to 8.1.49, allowing users to run Embr Periscope on 8.1.49+.

Minor Changes

  • f64c92d: Bump minimum compatible Ignition version from 8.1.33 to 8.1.49.
2 Likes

Critical Bug Report

The upgrade to Jython 2.7.4 in Ignition 8.3.8 has impacted usage of Periscope's system.perspective.runJavaScript* functions.

I have a fix coming but I'll need a day or two to properly test things.
Until then, a workaround is available in the GitHub issue.

I'll update this post once a fix is available.

Thanks @jason_wellman for the report!

Fix Available

This has been fixed in:
(8.3) Periscope 0.14.5
(8.1) Periscope 0.16.4

See Embr's GitHub Release page for the latest available versions.

4 Likes

Release Notice

Embr v8.1 - 2026.8.31
Embr v8.3 - 2026.8.31

This release resolves a runJavaScript error reported by @jason_wellman
Thanks Jason!

This error was a result of a pretty naive approach to grabbing the current Periscope component context from the current PyFrame :flushed_face:. The fixed approach has been backported to 8.1, even though it didn't seem to be affected.

5 Likes

Example Use Case, Primary View Middleware

This usage example uses Client Resources to introduce a routing middleware, that allows you to dynamically decide the viewPath used for a Page URL.

The router also subscribes to the session's custom properties, making it possible to create reactive rules.

Usage:

// Client Resource
// middleware.routes
import { router } from "./router";

router.use((path, session, viewPath) => {
  const canViewAdmin = session.custom.readBoolean("permissions.admin", false);

  if ((path === "/admin" || path.startsWith("/admin/")) && !canViewAdmin) {
    return "Middleware/AccessDenied";
  }
});

router.use((path) => {
  if (path === "/virtual/path/to/view") {
    return "Middleware/View1";
  }
});

Video:
In this example, the checkboxes are bidirectionally bound to session.custom.permissions.admin.

  1. When the property is false, the router returns Middleware/AccessDenied as the viewPath for all Page URLS that start with /admin.
  2. When the property becomes true, the router reevaluates and returns no value. Because no value is returned, the default Primary View for the Page URL is returned.
  3. The same thing happens in reverse when the property becomes false.

Another natural example is 404 pages; if no viewPath exists for a given Page URL, supply a default.

Project Export:
clientresource_middleware.zip (7.7 KB)

2 Likes

I hope @pturmel sees this - curious to get his take on the opportunity within this module. At a previous company, Phil built me essentially a view router heavily optimized on his Integration Toolkit functions that allowed dynamic assembly of views and navigation. It was SO cool, and I'm eager to see this become more of a standard approach for Ignition solutions.

As you should know, I watch everything here.

I think the example is terrible, as security filtering applied client-side in javascript is essentially no security at all.

Such UI manipulation, if all the relevant data is already client side, make sense as performance optimizations. If data necessary for the end result is on the gateway, then the gateway should push it. I like the dynamic view component more than this kind of router manipulation. But the 404 diversion looks useful. :man_shrugging:

Sure. But this example still works with View permissions; instead of a user being shown the default "Access Denied" screen, you could divert them somewhere else.

One of the problems with single-page web apps is that the entire thing is client side, even if you intend to hide parts of it from the user. Having server side controls on the data supplied to view is good and necessary, but sometimes just having access to the structure of a view aids potential mis-use. Especially if the user can manipulate the browser to trick client-side security, and the server doesn't also check.

Your dynamic view component can entirely withhold a view's structure from an unauthorized viewer. That's a win in my book.

1 Like

That’s exactly why I think the router example is passable :man_shrugging: It’s not any less secure than letting users hit the default Access Denied screen. But I agree that by itself it is not a method of security.

Hm, I've never really thought about that before…

2 Likes

I would just like to say thank you as I have finally found the most ideal use case for the Periscope Resources.

6 Likes

I love it.

Three.js? If so, how did you handle loading it? CDN?

Third party dependencies are an area that needs some workability improvements.

1 Like

credit goes to this dude Rubik's cube in Javascript

I made an ai agent from scanning your periscope modules, so it uses the correct syntax for the periscope resources then just fed it that code and got it to convert it

here are the files but they were made by a clanker

Main.jsx

import styles, { applyStyle } from './Style.css'

const { useEffect, useRef } = React

const faces = ['left', 'right', 'top', 'bottom', 'back', 'front'] as const
const colors = ['blue', 'green', 'white', 'yellow', 'orange', 'red'] as const

function classNames(...names: string[]) {
  return names.filter(Boolean).join(' ')
}

export default function RubiksCube() {
  const sceneRef = useRef<HTMLDivElement | null>(null)
  const pivotRef = useRef<HTMLDivElement | null>(null)
  const cubeRef = useRef<HTMLDivElement | null>(null)
  const guideRef = useRef<HTMLDivElement | null>(null)

  useEffect(() => {
    applyStyle()

    const scene = sceneRef.current
    const pivot = pivotRef.current
    const cube = cubeRef.current
    const guide = guideRef.current

    if (scene == null || pivot == null || cube == null || guide == null) {
      console.warn('RubiksCube failed to initialize because required DOM nodes were not found.')
      return
    }

    const pieces = Array.from(cube.getElementsByClassName(styles.piece)) as HTMLElement[]

    function mx(i: number, j: number) {
      return ([2, 4, 3, 5][j % 4 | 0] + (i % 2) * (((j | 0) % 4) * 2 + 3) + 2 * (i / 2 | 0)) % 6
    }

    function getAxis(face: number) {
      return String.fromCharCode('X'.charCodeAt(0) + face / 2)
    }

    function getPieceBy(face: number, index: number, corner: number) {
      const pieceId = (1 << face) + (1 << mx(face, index)) + (1 << mx(face, index + 1)) * corner
      return cube.querySelector(`[data-piece-id="${pieceId}"]`) as HTMLElement | null
    }

    function assembleCube() {
      for (const piece of pieces) {
        piece.removeAttribute('data-piece-id')
        Array.from(piece.getElementsByClassName(styles.sticker)).forEach((sticker) => sticker.remove())
      }

      for (let i = 0; i < pieces.length; i++) {
        let id = 0

        function moveTo(face: number) {
          id += 1 << face

          const sticker = document.createElement('div')
          sticker.className = classNames(styles.sticker, styles[colors[face]])
          pieces[i].children[face].appendChild(sticker)

          return `translate${getAxis(face)}(${face % 2 * 4 - 2}em)`
        }

        const x = mx(i, i % 18)
        pieces[i].style.transform =
          `rotateX(0deg)${moveTo(i % 6)}` + (i > 5 ? moveTo(x) + (i > 17 ? moveTo(mx(x, x + 2)) : '') : '')
        pieces[i].setAttribute('data-piece-id', String(id))
      }
    }

    function swapPieces(face: number, times: number) {
      for (let i = 0; i < 6 * times; i++) {
        const piece1 = getPieceBy(face, i / 2, i % 2)
        const piece2 = getPieceBy(face, i / 2 + 1, i % 2)

        if (piece1 == null || piece2 == null) {
          console.warn(`RubiksCube could not find pieces for face ${face}.`)
          return
        }

        for (let j = 0; j < 5; j++) {
          const sticker1 = piece1.children[j < 4 ? mx(face, j) : face].firstChild as HTMLElement | null
          const sticker2 = piece2.children[j < 4 ? mx(face, j + 1) : face].firstChild as HTMLElement | null
          const className = sticker1?.className ?? ''

          if (className && sticker1 != null && sticker2 != null) {
            sticker1.className = sticker2.className
            sticker2.className = className
          }
        }
      }
    }

    function animateRotation(face: number, clockwise: boolean, currentTime: number) {
      const k = 0.3 * (face % 2 * 2 - 1) * (2 * Number(clockwise) - 1)
      const qubes = Array(9)
        .fill(pieces[face])
        .map((value, index) => (index ? getPieceBy(face, index / 2, index % 2) : value))
        .filter((piece): piece is HTMLElement => piece != null)

      function rotatePieces() {
        const passed = Date.now() - currentTime
        const style = `rotate${getAxis(face)}(${k * passed * Number(passed < 300)}deg)`

        qubes.forEach((piece) => {
          piece.style.transform = piece.style.transform.replace(/rotate.\(\S+\)/, style)
        })

        if (passed >= 300) {
          swapPieces(face, 3 - 2 * Number(clockwise))
          return
        }

        requestAnimationFrame(rotatePieces)
      }

      rotatePieces()
    }

    function mousedown(mouseDownEvent: MouseEvent) {
      const startXY = pivot.style.transform.match(/-?\d+\.?\d*/g)?.map(Number) ?? [-35, -135]
      const target = mouseDownEvent.target as Element
      const element = target.closest(`.${styles.element}`) as HTMLElement | null
      const face = element == null ? -1 : Array.prototype.indexOf.call(element.parentNode?.children ?? [], element)

      function mousemove(mouseMoveEvent: MouseEvent) {
        if (element != null) {
          const anchor = document
            .elementFromPoint(mouseMoveEvent.clientX, mouseMoveEvent.clientY)
            ?.closest('[data-anchor]') as HTMLElement | null
          const anchorIndex = anchor?.dataset.anchor

          if (anchorIndex != null) {
            mouseup()

            const rotatedFace = mx(face, Number(anchorIndex) + 3)
            const clockwise = element.parentElement?.children[rotatedFace]?.hasChildNodes() ?? false
            animateRotation(mx(face, Number(anchorIndex) + 1 + 2 * Number(clockwise)), clockwise, Date.now())
          }
        } else {
          pivot.style.transform =
            `rotateX(${startXY[0] - (mouseMoveEvent.pageY - mouseDownEvent.pageY) / 2}deg)` +
            `rotateY(${startXY[1] + (mouseMoveEvent.pageX - mouseDownEvent.pageX) / 2}deg)`
        }
      }

      function mouseup() {
        guide.style.display = 'none'
        scene.appendChild(guide)
        scene.removeEventListener('mousemove', mousemove)
        document.removeEventListener('mouseup', mouseup)
        scene.addEventListener('mousedown', mousedown)
      }

      if (element != null) {
        guide.style.display = 'block'
        element.appendChild(guide)
      }

      scene.addEventListener('mousemove', mousemove)
      document.addEventListener('mouseup', mouseup)
      scene.removeEventListener('mousedown', mousedown)
    }

    function preventDrag(event: DragEvent) {
      event.preventDefault()
    }

    assembleCube()
    scene.addEventListener('mousedown', mousedown)
    document.addEventListener('dragstart', preventDrag)

    return () => {
      scene.removeEventListener('mousedown', mousedown)
      document.removeEventListener('dragstart', preventDrag)
    }
  }, [])

  return (
    <div className={styles.cubeStage}>
      <div ref={sceneRef} className={styles.scene}>
        <div
          ref={pivotRef}
          className={classNames(styles.pivot, styles.centered)}
        >
          <div ref={cubeRef} className={styles.cube}>
            {Array.from({ length: 26 }, (_, piece) => (
              <div key={piece} className={styles.piece}>
                {faces.map((face) => (
                  <div key={face} className={classNames(styles.element, styles[face])} />
                ))}
              </div>
            ))}
          </div>
          <div ref={guideRef} className={styles.guide}>
            {Array.from({ length: 4 }, (_, anchor) => (
              <div
                key={anchor}
                data-anchor={anchor}
                className={styles.anchor}
                style={{
                  transform: `translateZ(3px) translateY(-33.33%) rotate(${-90 * anchor}deg) translateY(66.67%)`,
                }}
              />
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}


Style.css

.cubeStage {
  width: 100%;
  height: 100%;
  min-height: 320px;
  overflow: hidden;
  color: #2f2f31;
  background: radial-gradient(circle, white, rgba(0, 0, 0, 0.5));
  background-blend-mode: overlay;
  user-select: none;
}

.credits {
  top: 90%;
  width: 100%;
}

.text {
  position: initial;
  color: grey;
  font-family: Helvetica, Arial, sans-serif;
  font-size: 0.8rem;
  pointer-events: none;
  text-align: center;
}

.centered {
  position: absolute;
  inset: 0;
  margin: auto;
}

.scene {
  position: relative;
  width: 100%;
  height: 100%;
  perspective: 1200px;
  transform-style: preserve-3d;
}

.scene div {
  position: absolute;
  transform-style: inherit;
}

.pivot {
  width: 0;
  height: 0;
  transition: 0.18s;
}

.guide {
  display: none;
}

.anchor {
  width: 2em;
  height: 6em;
}

.cube {
  margin-top: -1em;
  margin-left: -1em;
  font-size: 190%;
}

.piece {
  width: 1.9em;
  height: 1.9em;
}

.element {
  width: 100%;
  height: 100%;
  background: #0a0a0a;
  border: 0.05em solid #0a0a0a;
  border-radius: 10%;
  outline: 1px solid transparent;
}

.left {
  transform: rotateX(0deg) rotateY(-90deg) rotateZ(180deg) translateZ(1em);
}

.right {
  transform: rotateX(0deg) rotateY(90deg) rotateZ(90deg) translateZ(1em);
}

.back {
  transform: rotateX(0deg) rotateY(180deg) rotateZ(-90deg) translateZ(1em);
}

.front {
  transform: rotateX(0deg) rotateY(0deg) rotateZ(0deg) translateZ(1em);
}

.bottom {
  transform: rotateX(-90deg) rotateY(0deg) rotateZ(-90deg) translateZ(1em);
}

.top {
  transform: rotateX(90deg) rotateY(0deg) rotateZ(180deg) translateZ(1em);
}

.sticker {
  position: absolute;
  inset: 0;
  width: 95%;
  height: 95%;
  margin: auto;
  border-radius: 10%;
  box-shadow:
    inset 0.05em 0.05em 0.2rem 0 rgba(255, 255, 255, 0.25),
    inset -0.05em -0.05em 0.2rem 0 rgba(0, 0, 0, 0.25);
  outline: 1px solid transparent;
  transform: translateZ(2px);
}

.blue {
  background-color: #001ca8;
}

.green {
  background-color: #006e16;
}

.white {
  background-color: #ddd;
}

.yellow {
  background-color: #e0ae00;
}

.orange {
  background-color: #ff5000;
}

.red {
  background-color: #df0500;
}

3 Likes

I see a future where this gets added as a requirement to 21 CFR Part 11 :joy: /s

3 Likes

@James_Caudle

  • This is an example pick and place application using Periscope to store tasks results on device local storage.
  • When connectivity is re-established, the results are automatically sent to the gateway (received by a message handler).

I think this is a fantastic example of Client Resources, and it's the type of thing that is only possible once users can write client-side code. Perspective "offline mode" without client-side code is just a waste of time, IMO.

pick_place_2026-09-26_1744.zip (179.4 KB)

7 Likes

I'd like to take this opportunity to suggest another enhancement for the Table component.

I think it would be really useful to be able to freeze columns (by name or index) so we can scroll horizontally while keeping, for example, the first 3 columns fixed in place.

Release Notice

Patch Changes

  • 8dab726: Add the following component events: onMount, onUnmount, onVisible, onHidden, onPageVisible, and onPageHidden.
  • 8dab726: Add a new JavaScript component action, which directly runs JavaScript on the client without a round trip to the Gateway.

Embr v8.1 - 2026.10.6
Embr v8.3 - 2026.10.6

This update adds the following:

  1. New Component events: onMount, onUnmount, onVisible, onHidden, onPageVisible, and onPageHidden. These events do what you'd expect.

  1. A JavaScript event action. This client-scoped action runs directly in the client, without any Gateway interaction. The execution context is exactly the same as the existing system.perspective.runJavaScript... functions. The same this, periscope, and perspective globals are available for use, and has JavaScript syntax highlighting.


Individually these features are kinda meh, but together some really cool things become possible.

(Sneaky peaky :wink:)

Also, thank you @drojas and Automation Station for the sponsorship!!

17 Likes

Release Notice

This release contains a patch fix for inherited Client Resources in 8.3.
Previously, inherited Client Resources were listed in the manifest but not actually retrievable over the project's Client Resource endpoint.

Thanks @James_Caudle!

Patch Changes

  • aa80b36: (Client Resource): Fix 404 errors for inherited resources.
    • Inherited resources were visible in the manifest, but not accessible through the resource handler endpoint.
    • @embr-modules/periscope-web@0.14.7

Embr v8.3 - 2026.10.20

2 Likes

Hi @bmusson,

I'm trying to change transitions for the toasts and I was wondering if the React-Toastify transition components Slide, Zoom, Flip and Bounce mentioned in the doc are exposed somewhere through Periscope.

Passing the value as a string doesn't work since it expects the actual component reference. And if I pass a string it actually blocks the toast and I have to reload the page. I also tried to import the transitions using your Client Resources, but that results in an Error locating resource. They don't seem to be exposed on window either.

Is there a supported way to access these transition components through Periscope? I was able to customize almost everything I wanted, even icon components using Client Resources, but for transitions I'm hitting a wall. I guess I could create my own css classes and use the className parameter but I'd rather not have to redefine everything if the elements already exist

Thanks!

1 Like

No, not currently...

They're not exported onto the global EmbrPeriscope object, and it looks like only the bounce transition is included with the module's web resource (with the rest are being tree-shaken out by the bundler).

It wouldn't be hard to expose these Transition objects somewhere, but I'll have to give it some thought as to where to put them. The most convenient current place would be on the periscope.toast object itself, but I'm not 100% convinced I want to be appending to the react-toastify implementation :man_shrugging::

// one possible location
periscope.toast('Toast!', {
  transition: periscope.toast.Transition.Slide
})

I'd rather it live in a module package, but those are difficult to use from system.persicope.runJavaScript calls ATM because Client Resources have to be accessed asynchronously due to Perspective-specific BrowserResource loading reasons :face_with_diagonal_mouth:.

This would be "cleaner" from my POV; you'd have to import + export the transitions from a Client Resource, but it's explicit and avoids global scope creep:

// my-client-resource.tsx
import { Transition } from 'react-toastify'   // ideally this could work
import { Transition } from 'system:periscope' // fallback option

export const Slide = Transition.Slide
export const Zoom = Transition.Zoom
export const Flip = Transition.Flip
export const Bounce = Transition.Bounce

// runJavaScriptAsync
periscope.toast('Toast!', {
  transition: (await periscope.UNSTABLE_resource('my-client-resource')).Slide
})
// NOTE: you can currently access Client Resources this way, but it's marked as unstable because I hate it.

If we had a way to synchronously load Client Resource on client start (wink wink @ynejati :wink:), we could make this simpler to use:

periscope.toast('Toast!', {
  transition: periscope.resource('my-client-resource').Slide
})

TLDR: No, it's not possible now, but I'd like for it to be. However, I want to do it in a way that plays nice with Client Resources + future external package support and doesn't feel like we're adding more magic global objects everywhere.

2 Likes